feat(config,curator,recall): memory curator job on opus high, notes head inline in the envelope, portrait in the morning seed

This commit is contained in:
hh
2026-09-01 16:48:25 +02:00
parent 58e343aae0
commit 3a77a12447
7 changed files with 375 additions and 27 deletions
+64 -1
View File
@@ -30,6 +30,7 @@ from beaver_gateway.frontends.mcp_server import McpServerFrontend
from beaver_gateway.frontends.telegram import TelegramFrontend
from beaver_gateway.mcp.types import HttpMcp, McpServer, McpServerT
from curator import Watched, briefing
from mcps.homeassistant import HomeAssistant
from mcps.komodo import Komodo
from mcps.komodo_alerts import HOLD, AlertMemory, describe, parse, still_bad
@@ -45,7 +46,7 @@ from policy import (
skill_tracker,
vault_zones,
)
from recall import Recall, ReplyLog
from recall import Recall, ReplyLog, notes_head
TZ = "Europe/Warsaw"
VAULT = Path("/vault")
@@ -60,6 +61,8 @@ DIGESTS = BEAVER / "выжимки"
INDEX = BEAVER / "индекс.md"
NOTES = BEAVER / "наблюдения"
REPLIES = BEAVER / "реплики"
CURATOR_JOURNAL = BEAVER / "куратор.md"
PORTRAIT = NOTES / "Бобёр - наблюдения.md"
PEOPLE = VAULT / "👤 люди"
BOARDS = VAULT / "📆 доски"
STATE = BEAVER / "состояние.md"
@@ -118,6 +121,10 @@ _DISTILLER = (PROFILE, environment("джоб"), overlay("distiller", "дисти
DISTILLER_PROMPTS = Prompts(fork=_DISTILLER, job=_DISTILLER)
# §4.1: триаж без голоса и без vault - профиль, окружение джоба, оверлей.
TRIAGE_PROMPTS = Prompts(job=(PROFILE, environment("джоб"), overlay("triage", "триаж")))
# Куратор памяти - тот же менеджер бобрения (голос и всё), в окружении джоба.
CURATOR_PROMPTS = Prompts(
job=(*_BASE, environment("джоб"), overlay("curator", "куратор"))
)
QUICK_PROMPT = assemble((*VOICE, PROFILE, overlay("quick", "быстрый")))
# §4.3: наборы скиллов = папки = плагины. Глубокие грузят общие + vault.
@@ -321,6 +328,23 @@ def distiller(name: str, model: str, effort: str | None = None) -> ClaudeAgent:
)
def curator(name: str, model: str, effort: str | None = None) -> ClaudeAgent:
"""Куратор памяти: opus high, файлы в мета/бобер, без MCP, `inject` в мастер."""
return ClaudeAgent(
name=name,
model=model,
cwd=VAULT,
prompts=CURATOR_PROMPTS,
gateway_tools=("inject",),
options=ClaudeOptions(
effort=effort,
tools=("Read", "Write", "Edit", "Grep", "Glob"),
disallowed_tools=DISTILLER_DISALLOWED,
),
policy=(vault_zones(STRICT_ZONES),),
)
def triage(name: str, model: str, effort: str | None = None) -> ClaudeAgent:
"""§4.1: дешёвый крон-тёрн - sonnet low, без vault, только say/inject."""
return ClaudeAgent(
@@ -364,6 +388,7 @@ agents: list[BaseAgent] = [
deep("beaver-fable-medium", "claude-fable-5", effort="medium"),
deep("beaver-opus-xhigh", "claude-opus-5", effort="xhigh"),
distiller("beaver-distiller", "claude-opus-5", effort="medium"),
curator("beaver-curator", "claude-opus-5", effort="high"),
triage("beaver-triage", "claude-sonnet-5", effort="low"),
raycast("beaver-gemini-pro-low", "google-gemini-3.1-pro", reasoning_effort="low"),
raycast(
@@ -442,6 +467,12 @@ def seed_body(ctx: SeedContext) -> str | None:
parts.append(
f"Состояние (первые строки, полностью в мета/бобер/состояние.md):\n{signal}"
)
portrait = notes_head(PORTRAIT, ("сейчас", "паттерны"), cap=40)
if portrait:
parts.append(
"Портрет Бобра (шапка "
f"`{PORTRAIT.relative_to(VAULT)}`, лента - там же):\n{portrait}"
)
return "\n\n".join(parts)
@@ -686,6 +717,35 @@ async def memory(run: JobRun) -> None:
)
CURATOR_WATCH = [
Watched("хендауты", DAYS, "????-??-??.md"),
Watched("выжимки", DIGESTS, "*.md"),
Watched("наблюдения", NOTES, "*.md"),
Watched("состояние", BEAVER, "состояние.md"),
Watched("дневник", DIARY, "????-??-??.md"),
Watched("карточки", PEOPLE, "**/*.md"),
]
async def curate(run: JobRun) -> None:
"""§6.0: куратор памяти раз в несколько часов.
Крон собирает «что нового», модель раскладывает по шапкам и лентам;
нечего раскладывать - джоб не спавнится.
"""
text = briefing(
vault=VAULT,
journal=CURATOR_JOURNAL,
replies_dir=REPLIES,
watched=CURATOR_WATCH,
tz=TZ,
)
if text is None:
_log.info("curator: nothing new since the last run")
return
await run.spawn_job(agent="beaver-curator", title="куратор", text=text)
T3CODE_QUESTION_HINT = (
"Если ответ следует из задания - ответь сам: t3_answer(thread_id, "
"{id вопроса: label}). Не знаешь - спроси Бобра через say своими словами, "
@@ -790,6 +850,9 @@ jobs = [
Job("вайбграм", vibegram, cron="*/10 * * * *", critical=False),
Job("закрытие", close_idle, cron="20 4 * * *", critical=False),
Job("память", memory, cron="30 4 * * 0", critical=False),
# крон в UTC контейнера: 02/10/14/18/22 по Варшаве летом;
# руками - POST /api/jobs/куратор/run
Job("куратор", curate, cron="0 0,8,12,16,20 * * *", critical=False),
Job("deploy", deploy, webhook=True),
# алерты идут пачками (тревога и отбой подряд) - схлопывать нельзя
Job("komodo", komodo_alert, webhook=True, dedupe=False),
+128
View File
@@ -0,0 +1,128 @@
"""Куратор памяти: что нового с прошлого прогона (архитектура §4.5, §6.0).
Крон дёргает детерминированный код, модель судит: здесь собирается брифинг
для служебного тёрна `beaver-curator` - новые реплики Бобра целиком и имена
файлов, изменившихся с прошлого прогона. Время прошлого прогона куратор
сам пишет первой строкой `мета/бобер/куратор.md`; нет ничего нового -
брифинга нет и джоб не спавнится.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo
if TYPE_CHECKING:
from pathlib import Path
LAST_RUN_RE = re.compile(r"^последний прогон:\s*(\S+)")
REPLY_RE = re.compile(r"^- (\d{4}-\d{2}-\d{2} \d{2}:\d{2}) · ")
MAX_REPLY_CHARS = 60_000
@dataclass(frozen=True)
class Watched:
label: str
root: Path
pattern: str = "**/*.md"
def last_run(journal: Path, *, default: datetime) -> datetime:
"""`последний прогон: <ISO>` из начала журнала, иначе ``default``."""
if not journal.exists():
return default
for line in journal.read_text(encoding="utf-8", errors="ignore").splitlines()[:5]:
m = LAST_RUN_RE.match(line.strip())
if m:
try:
stamp = datetime.fromisoformat(m.group(1))
except ValueError:
return default
return stamp if stamp.tzinfo else stamp.replace(tzinfo=UTC)
return default
def new_replies(replies_dir: Path, since: datetime, tz: str) -> list[str]:
"""Строки `реплики/YYYY-MM.md` позже ``since`` (время в строках локальное)."""
zone = ZoneInfo(tz)
local_since = since.astimezone(zone).replace(tzinfo=None)
out: list[str] = []
if not replies_dir.exists():
return out
for path in sorted(replies_dir.glob("????-??.md"))[-2:]:
for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
m = REPLY_RE.match(line)
if not m:
continue
try:
at = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M") # noqa: DTZ007
except ValueError:
continue
if at > local_since:
out.append(line)
return out
def changed_files(
watched: list[Watched], since: datetime, vault: Path, *, skip: tuple[str, ...] = ()
) -> list[str]:
"""`путь (размер)` для файлов с mtime позже ``since``; ``skip`` - подстроки."""
out: list[tuple[str, str]] = []
stamp = since.timestamp()
for w in watched:
if not w.root.exists():
continue
for path in w.root.glob(w.pattern):
if not path.is_file() or path.name.startswith("."):
continue
rel = str(path.relative_to(vault))
if any(s in rel for s in skip):
continue
if path.stat().st_mtime > stamp:
out.append((w.label, f"`{rel}` ({path.stat().st_size // 1024} КБ)"))
out.sort()
return [f"{label}: {desc}" for label, desc in out]
def briefing(
*,
vault: Path,
journal: Path,
replies_dir: Path,
watched: list[Watched],
tz: str,
now: datetime | None = None,
default_window: timedelta = timedelta(hours=24),
) -> str | None:
"""Первое сообщение куратору или ``None``, если с прошлого прогона ничего нет."""
now = now or datetime.now(UTC)
since = last_run(journal, default=now - default_window)
replies = new_replies(replies_dir, since, tz)
changed = changed_files(
watched, since, vault, skip=(str(journal.relative_to(vault)),)
)
if not replies and not changed:
return None
zone = ZoneInfo(tz)
head = (
f"Прогон куратора {now.astimezone(zone):%Y-%m-%d %H:%M} ({tz}); прошлый - "
f"{since.astimezone(zone):%Y-%m-%d %H:%M}. Ниже - что изменилось с тех пор; "
"остальное читай сам по слоям. В конце обнови первую строку "
f"`{journal.relative_to(vault)}`: `последний прогон: "
f"{now.astimezone(zone).isoformat(timespec='seconds')}`."
)
parts = [head]
if replies:
text = "\n".join(replies)
if len(text) > MAX_REPLY_CHARS:
text = text[-MAX_REPLY_CHARS:]
text = "…(обрезано, начало - в файле)\n" + text[text.find("\n") + 1 :]
parts.append(f"Новые реплики Бобра ({len(replies)}):\n{text}")
else:
parts.append("Новых реплик Бобра нет.")
if changed:
parts.append("Изменились файлы:\n" + "\n".join(f"- {c}" for c in changed[:80]))
return "\n\n".join(parts)
+1
View File
@@ -61,6 +61,7 @@ services:
- ./config.py:/config/config.py:ro
- ./policy.py:/config/policy.py:ro
- ./recall.py:/config/recall.py:ro
- ./curator.py:/config/curator.py:ro
- ./mcps:/config/mcps:ro
- ./config.json:/config/config.json:ro
# §3.7: vault - дом диспетчера, rw целиком; границы (`.obsidian`,
+54 -14
View File
@@ -153,6 +153,39 @@ def mentions(text: str, people: list[Person], *, limit: int = 3) -> list[Person]
return [by_name[name] for name, _ in order]
def notes_sections(path: Path) -> dict[str, list[str]]:
"""`## заголовок` → непустые строки под ним (до следующего `## `)."""
out: dict[str, list[str]] = {}
current: str | None = None
for raw in path.read_text(encoding="utf-8", errors="ignore").splitlines():
line = raw.rstrip()
if line.startswith("## "):
current = line[3:].strip().lower()
out.setdefault(current, [])
continue
if current is not None and line.strip():
out[current].append(line.strip())
return out
def notes_head(path: Path, sections: tuple[str, ...], *, cap: int = 40) -> str | None:
"""Выбранные секции файла записок одним блоком - для сида (портрет Бобра)."""
if not path.exists():
return None
parsed = notes_sections(path)
lines: list[str] = []
for name in sections:
body = parsed.get(name, [])
if body:
lines.append(f"## {name}")
lines.extend(body)
if not lines:
return None
if len(lines) > cap:
lines = [*lines[:cap], f"… ещё {len(lines) - cap} строк в файле"]
return "\n".join(lines)
@dataclass
class Recall:
"""Справка по сообщению: указатели в vault, которые gateway не может знать."""
@@ -186,30 +219,37 @@ class Recall:
return None
return "\n".join([LABEL, *lines])
def person_line(self, person: Person) -> str:
def person_line(self, person: Person, *, now_lines: int = 6) -> str:
"""Указатели по человеку и `## сейчас` из его записок - инлайн."""
parts = [f"👤 {person.name} - карточка `{person.path}`"]
notes = self.notes_dir / f"{person.name} - наблюдения.md"
rel = notes.relative_to(self.vault)
now: list[str] = []
if notes.exists():
entries = [
ln.strip()
for ln in notes.read_text(
encoding="utf-8", errors="ignore"
).splitlines()
if ln.strip().startswith("- ")
sections = notes_sections(notes)
feed = sections.get("лента", [])
now = [ln for ln in sections.get("сейчас", []) if ln.startswith("- ")]
patterns = [
ln for ln in sections.get("паттерны", []) if ln.startswith("- ")
]
if entries:
last = entries[-1][2:]
last = last if len(last) <= 110 else last[:109] + ""
parts.append(f"записки `{rel}`: {len(entries)} стр., последняя: {last}")
else:
parts.append(f"записки `{rel}`: пусто")
desc = f"записки `{rel}`: лента {len(feed)} стр."
if patterns:
desc += f", паттернов {len(patterns)}"
if not now and feed:
last = feed[-1][2:]
desc += ", последняя: " + (
last if len(last) <= 110 else last[:109] + ""
)
parts.append(desc)
else:
parts.append(f"записок нет (`{rel}`)")
days = self.diary_hits(person)
if days:
parts.append("дневник: " + ", ".join(f"[[{d}]]" for d in days))
return "; ".join(parts)
line = "; ".join(parts)
if now:
line += "; сейчас:\n" + "\n".join(f" {ln}" for ln in now[:now_lines])
return line
def diary_hits(
self, person: Person, *, limit: int = 3, ttl: float = 600.0
+10 -6
View File
@@ -371,15 +371,19 @@ def append_notes(name: str, lines: list[str]) -> int:
def sort_notes() -> None:
"""Сортирует и дедупит только `## лента`; шапку (сейчас/паттерны/…) не трогает."""
for path in NOTES.glob("* - наблюдения.md"):
text = path.read_text(encoding="utf-8")
head, bullets = [], []
for ln in text.splitlines():
(bullets if ln.startswith("- ") else head).append(ln)
head_text, sep, feed = text.partition("## лента\n")
bullets = [ln for ln in feed.splitlines() if ln.startswith("- ")]
bullets = sorted(dict.fromkeys(bullets), key=lambda ln: ln[2:12])
while head and not head[-1].strip():
head.pop()
path.write_text("\n".join([*head, "", *bullets]) + "\n", encoding="utf-8")
if not sep:
head_text = head_text.rstrip("\n") + "\n\n"
head_text = head_text.replace("\n- ", "\n").rstrip() + "\n\n"
path.write_text(
head_text.rstrip("\n") + "\n\n## лента\n" + "\n".join(bullets) + "\n",
encoding="utf-8",
)
def link_card(person_path: Path, name: str) -> bool:
+80
View File
@@ -0,0 +1,80 @@
from datetime import UTC, datetime, timedelta
from pathlib import Path
from curator import Watched, briefing, last_run, new_replies
def test_last_run_reads_first_line_or_default(tmp_path: Path) -> None:
default = datetime(2026, 9, 1, tzinfo=UTC)
assert last_run(tmp_path / "нет.md", default=default) == default
j = tmp_path / "куратор.md"
j.write_text(
"последний прогон: 2026-09-01T18:00:00+02:00\n\n# журнал\n", encoding="utf-8"
)
assert last_run(j, default=default) == datetime(2026, 9, 1, 16, 0, tzinfo=UTC)
j.write_text("# журнал без строки\n", encoding="utf-8")
assert last_run(j, default=default) == default
def test_briefing_lists_new_replies_and_changed_files_or_nothing(
tmp_path: Path,
) -> None:
vault = tmp_path
beaver = vault / "мета" / "бобер"
replies = beaver / "реплики"
notes = beaver / "наблюдения"
replies.mkdir(parents=True)
notes.mkdir()
(replies / "2026-09.md").write_text(
"# реплики\n\n- 2026-09-01 12:00 · мастер · старое\n- 2026-09-01 19:30 · мастер · новое сообщение\n",
encoding="utf-8",
)
journal = beaver / "куратор.md"
journal.write_text(
"последний прогон: 2026-09-01T18:00:00+02:00\n", encoding="utf-8"
)
old = notes / "Глафира - наблюдения.md"
old.write_text("# Глафира\n", encoding="utf-8")
import os
stale = (datetime(2026, 9, 1, 10, tzinfo=UTC)).timestamp()
os.utime(old, (stale, stale))
fresh = notes / "Зина - наблюдения.md"
fresh.write_text(
"# Зина\n## лента\n- 2026-09-01 · факт · [[2026-09-01]]\n", encoding="utf-8"
)
now = datetime(2026, 9, 1, 20, 0, tzinfo=UTC)
recent = (now - timedelta(minutes=30)).timestamp()
os.utime(fresh, (recent, recent))
text = briefing(
vault=vault,
journal=journal,
replies_dir=replies,
watched=[Watched("наблюдения", notes)],
tz="Europe/Warsaw",
now=now,
)
assert text is not None
assert "прошлый - 2026-09-01 18:00" in text
assert (
"Новые реплики Бобра (1):\n- 2026-09-01 19:30 · мастер · новое сообщение"
in text
)
assert "наблюдения: `мета/бобер/наблюдения/Зина - наблюдения.md`" in text
assert "Глафира" not in text
assert "последний прогон: 2026-09-01T22:00:00+02:00" in text
journal.write_text(f"последний прогон: {now.isoformat()}\n", encoding="utf-8")
assert new_replies(replies, now, "Europe/Warsaw") == []
later = now + timedelta(hours=1)
assert (
briefing(
vault=vault,
journal=journal,
replies_dir=replies,
watched=[Watched("наблюдения", notes)],
tz="Europe/Warsaw",
now=later,
)
is None
)
+38 -6
View File
@@ -4,7 +4,7 @@ from pathlib import Path
from beaver_gateway.core.conversations import UserSaid
from beaver_gateway.core.envelope import RecallContext
from recall import LABEL, Recall, ReplyLog, frontmatter_aliases, mentions
from recall import LABEL, Recall, ReplyLog, frontmatter_aliases, mentions, notes_head
def _card(path: Path, aliases: list[str] | None = None) -> None:
@@ -38,10 +38,17 @@ def _vault(tmp_path: Path) -> Recall:
notes = tmp_path / "мета" / "бобер" / "наблюдения"
notes.mkdir(parents=True)
(notes / "Зина - наблюдения.md").write_text(
"# Зина\n\n- 2026-08-26 · перенесла встречу на час · [[2026-08-26]]\n"
"# Зина - наблюдения\n\n## сейчас\n- просила перезвонить вечером 26.08\n"
"- ждёт ответа про поездку до четверга\n\n## паттерны\n"
"- переносит встречи: [[2026-08-10]], [[2026-08-26]]\n\n## факты\n\n## открытое\n\n"
"## лента\n- 2026-08-26 · перенесла встречу на час · [[2026-08-26]]\n"
"- 2026-08-31 · прислала список покупок · транскрипт\n",
encoding="utf-8",
)
(notes / "Прохор - наблюдения.md").write_text(
"# Прохор - наблюдения\n\n## сейчас\n\n## лента\n- 2026-08-07 · вернулся · [[2026-08-07]]\n",
encoding="utf-8",
)
boards = tmp_path / "📆 доски"
boards.mkdir()
(boards / "организация.md").write_text(
@@ -91,14 +98,24 @@ def test_block_points_to_card_notes_diary_and_deadlines_once(tmp_path: Path) ->
assert block is not None
lines = block.splitlines()
assert lines[0] == LABEL
assert lines[1].startswith(
"👤 Зина - карточка `👤 люди/личное/Зина.md`; записки `мета/бобер/наблюдения/Зина - наблюдения.md`: 2 стр., последняя: 2026-08-31 · четыре смягчения"
assert lines[1] == (
"👤 Зина - карточка `👤 люди/личное/Зина.md`; записки "
"`мета/бобер/наблюдения/Зина - наблюдения.md`: лента 2 стр., паттернов 1; "
"дневник: [[2026-08-31]], [[2026-08-26]], [[2026-08-13]]; сейчас:"
)
assert lines[1].endswith("дневник: [[2026-08-31]], [[2026-08-26]], [[2026-08-13]]")
assert lines[2:] == [
assert lines[2:4] == [
" - просила перезвонить вечером 26.08",
" - ждёт ответа про поездку до четверга",
]
assert lines[4:] == [
"📆 09-01 организация: заполнить файрфлай",
"📆 09-02 организация: витамины",
]
artem = recall.person_line(
next(p for p in recall.people.all() if p.name == "Прохор")
)
assert "лента 1 стр., последняя: 2026-08-07 · вернулся" in artem
assert "сейчас:" not in artem
again = recall.block(RecallContext(text="и ещё про Зину", kind="master", now=now))
assert again is not None and "📆" not in again
branch = recall.block(RecallContext(text="Фёдор звонил", kind="branch", now=now))
@@ -150,3 +167,18 @@ def test_reply_log_appends_one_line_per_message_and_strips_seed(tmp_path: Path)
"- 2026-09-01 14:04 · ветка «крипто-карта» · сравни Trustee и RedotPay\n"
)
assert date.fromisoformat("2026-09-01") # tz shift stays inside the month here
def test_notes_head_picks_sections_for_the_seed(tmp_path: Path) -> None:
recall = _vault(tmp_path)
path = recall.notes_dir / "Зина - наблюдения.md"
head = notes_head(path, ("сейчас", "паттерны"))
assert head == (
"## сейчас\n- просила перезвонить вечером 26.08\n"
"- ждёт ответа про поездку до четверга\n"
"## паттерны\n- переносит встречи: [[2026-08-10]], [[2026-08-26]]"
)
assert notes_head(path, ("факты",)) is None
assert notes_head(recall.notes_dir / "нет.md", ("сейчас",)) is None
short = notes_head(path, ("сейчас", "паттерны"), cap=2)
assert short is not None and short.endswith("… ещё 3 строк в файле")