diff --git a/config.py b/config.py index a1ffa9e..930b9a8 100644 --- a/config.py +++ b/config.py @@ -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), diff --git a/curator.py b/curator.py new file mode 100644 index 0000000..013770e --- /dev/null +++ b/curator.py @@ -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: + """`последний прогон: ` из начала журнала, иначе ``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) diff --git a/docker-compose.yml b/docker-compose.yml index da442e3..0e0c181 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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`, diff --git a/recall.py b/recall.py index 1f1fc6c..e59ef89 100644 --- a/recall.py +++ b/recall.py @@ -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 diff --git a/scripts/seed_memory.py b/scripts/seed_memory.py index cea7abc..5bcccf2 100644 --- a/scripts/seed_memory.py +++ b/scripts/seed_memory.py @@ -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: diff --git a/tests/test_curator.py b/tests/test_curator.py new file mode 100644 index 0000000..be46156 --- /dev/null +++ b/tests/test_curator.py @@ -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 + ) diff --git a/tests/test_recall.py b/tests/test_recall.py index c566f69..8e28c0a 100644 --- a/tests/test_recall.py +++ b/tests/test_recall.py @@ -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 строк в файле")