diff --git a/beaver_agent/jobs/memory.py b/beaver_agent/jobs/memory.py index 3abcb68..918213c 100644 --- a/beaver_agent/jobs/memory.py +++ b/beaver_agent/jobs/memory.py @@ -11,6 +11,7 @@ from beaver_agent.memory.curator import briefing from beaver_agent.memory.watch import CURATOR_WATCH from beaver_agent.vault import ( CURATOR_JOURNAL, + CURATOR_RUNS, DAYS, LEISURE, REPLIES, @@ -57,6 +58,7 @@ async def curate(run: JobRun) -> None: text = briefing( vault=VAULT, journal=CURATOR_JOURNAL, + runs=CURATOR_RUNS, replies_dir=REPLIES, watched=CURATOR_WATCH, tz=TZ, diff --git a/beaver_agent/memory/curator.py b/beaver_agent/memory/curator.py index 154cb4e..1dc4a29 100644 --- a/beaver_agent/memory/curator.py +++ b/beaver_agent/memory/curator.py @@ -3,8 +3,9 @@ Крон дёргает детерминированный код, модель судит: здесь собирается брифинг для служебного тёрна `beaver-curator` - новые реплики Бобра целиком и имена файлов, изменившихся с прошлого прогона. Время прошлого прогона куратор -сам пишет первой строкой `мета/бобер/куратор.md`; нет ничего нового - -брифинга нет и джоб не спавнится. +сам пишет первой строкой `мета/бобер/куратор.md`, запись о прогоне - в файл +дня `мета/бобер/куратор/YYYY-MM-DD.md`; нет ничего нового - брифинга нет и +джоб не спавнится. """ from __future__ import annotations @@ -46,13 +47,16 @@ def last_run(journal: Path, *, default: datetime) -> datetime: def new_replies(replies_dir: Path, since: datetime, tz: str) -> list[str]: - """Строки `реплики/YYYY-MM.md` позже ``since`` (время в строках локальное).""" + """Строки `реплики/YYYY-MM-DD.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:]: + first = f"{local_since:%Y-%m-%d}.md" + for path in sorted(replies_dir.glob("????-??-??.md")): + if path.name < first: + continue for line in path.read_text(encoding="utf-8", errors="ignore").splitlines(): m = REPLY_RE.match(line) if not m: @@ -91,6 +95,7 @@ def briefing( *, vault: Path, journal: Path, + runs: Path, replies_dir: Path, watched: list[Watched], tz: str, @@ -111,12 +116,14 @@ def briefing( if not replies and not changed: return None zone = ZoneInfo(tz) + local = now.astimezone(zone) head = ( - f"Прогон куратора {now.astimezone(zone):%Y-%m-%d %H:%M} ({tz}); прошлый - " + f"Прогон куратора {local:%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')}`." + "остальное читай сам по слоям. В конце запись о прогоне - в " + f"`{(runs / f'{local:%Y-%m-%d}.md').relative_to(vault)}`, а в " + f"`{journal.relative_to(vault)}` только первая строка: `последний прогон: " + f"{local.isoformat(timespec='seconds')}`." ) parts = [head] if replies: diff --git a/beaver_agent/memory/recall.py b/beaver_agent/memory/recall.py index 4f27487..ecff7bd 100644 --- a/beaver_agent/memory/recall.py +++ b/beaver_agent/memory/recall.py @@ -6,7 +6,7 @@ Gateway путей не знает; здесь - что искать по тек дни дневника, где он встречался, и раз в день - подошедшие сроки из досок и ближайшая неделя календарей. ``ReplyLog.write`` кладёт каждую реплику Бобра из мастера и веток в -`мета/бобер/реплики/YYYY-MM.md` - единственное место, где они grep-абельны: +`мета/бобер/реплики/YYYY-MM-DD.md` - единственное место, где они grep-абельны: транскрипт живёт в Postgres, а глубокие чаты и так лежат файлами в `💬 чаты/`. """ @@ -388,7 +388,7 @@ def event_line(event: Event, zone: ZoneInfo) -> str: @dataclass class ReplyLog: - """`мета/бобер/реплики/YYYY-MM.md`: строка на реплику Бобра из мастера и веток.""" + """`мета/бобер/реплики/YYYY-MM-DD.md`: строка на реплику из мастера и веток.""" dir: Path tz: str = "Europe/Warsaw" @@ -416,11 +416,11 @@ class ReplyLog: if seeded: # не человек сказал, а механизм спавна: видно по источнику, не по тексту where = f"{where} (бриф)" - path = self.dir / f"{local:%Y-%m}.md" + path = self.dir / f"{local:%Y-%m-%d}.md" self.dir.mkdir(parents=True, exist_ok=True) if not path.exists(): path.write_text( - f"# реплики Бобра, {local:%Y-%m}\n\n" + f"# реплики Бобра, {local:%Y-%m-%d}\n\n" "> пишет gateway: что Бобёр сказал в мастере и ветках, " "строка на сообщение. Глубокие чаты лежат в `💬 чаты/` сами.\n\n", encoding="utf-8", diff --git a/beaver_agent/vault.py b/beaver_agent/vault.py index 99923bf..c9854c7 100644 --- a/beaver_agent/vault.py +++ b/beaver_agent/vault.py @@ -18,6 +18,7 @@ CHATS = VAULT / "💬 чаты" DIARY = VAULT / "📅 дни" PEOPLE = VAULT / "👤 люди" BOARDS = VAULT / "📆 доски" +THOUGHTS = VAULT / "🧠 мысли" BEAVER = VAULT / "мета" / "бобер" """Дом агента: всё, что он ведёт сам, лежит здесь.""" @@ -31,6 +32,12 @@ NOTES = BEAVER / "наблюдения" PORTRAIT = NOTES / "Бобёр - наблюдения.md" REPLIES = BEAVER / "реплики" CURATOR_JOURNAL = BEAVER / "куратор.md" +"""Первая строка - время прошлого прогона; сами записи лежат в `CURATOR_RUNS`.""" +CURATOR_RUNS = BEAVER / "куратор" +OWN_DIARY = BEAVER / "дневник" +"""Его собственный дневник, файл на день: то, что он думает, без логов.""" +JOURNAL = BEAVER / "журнал" +"""Рабочий журнал, файл на день: следы тиков, логи, разбор промахов.""" LEISURE = BEAVER / "досуг.md" """Раскладка суток по тикам досуга: пишет её модель, механизм только читает.""" STATE_DIR = Path(os.environ.get("BEAVER_STATE_DIR", BEAVER / ".состояние")) diff --git a/tests/test_curator.py b/tests/test_curator.py index 387d416..b65e3dc 100644 --- a/tests/test_curator.py +++ b/tests/test_curator.py @@ -25,7 +25,10 @@ def test_briefing_lists_new_replies_and_changed_files_or_nothing( notes = beaver / "наблюдения" replies.mkdir(parents=True) notes.mkdir() - (replies / "2026-09.md").write_text( + (replies / "2026-08-31.md").write_text( + "# реплики\n\n- 2026-08-31 23:50 · мастер · вчерашнее\n", encoding="utf-8" + ) + (replies / "2026-09-01.md").write_text( "# реплики\n\n- 2026-09-01 12:00 · мастер · старое\n- 2026-09-01 19:30 · мастер · новое сообщение\n", encoding="utf-8", ) @@ -49,6 +52,7 @@ def test_briefing_lists_new_replies_and_changed_files_or_nothing( text = briefing( vault=vault, journal=journal, + runs=beaver / "куратор", replies_dir=replies, watched=[Watched("наблюдения", notes)], tz="Europe/Warsaw", @@ -63,6 +67,7 @@ def test_briefing_lists_new_replies_and_changed_files_or_nothing( assert "наблюдения: `мета/бобер/наблюдения/Зина - наблюдения.md`" in text assert "Глафира" not in text assert "последний прогон: 2026-09-01T22:00:00+02:00" in text + assert "запись о прогоне - в `мета/бобер/куратор/2026-09-01.md`" in text journal.write_text(f"последний прогон: {now.isoformat()}\n", encoding="utf-8") assert new_replies(replies, now, "Europe/Warsaw") == [] @@ -71,6 +76,7 @@ def test_briefing_lists_new_replies_and_changed_files_or_nothing( briefing( vault=vault, journal=journal, + runs=beaver / "куратор", replies_dir=replies, watched=[Watched("наблюдения", notes)], tz="Europe/Warsaw", diff --git a/tests/test_recall.py b/tests/test_recall.py index f2d1dee..8a7e339 100644 --- a/tests/test_recall.py +++ b/tests/test_recall.py @@ -168,8 +168,8 @@ def test_reply_log_appends_one_line_per_message_and_strips_seed(tmp_path: Path) at=at, ) ) - text = (tmp_path / "реплики" / "2026-09.md").read_text(encoding="utf-8") - assert text.startswith("# реплики Бобра, 2026-09\n") + text = (tmp_path / "реплики" / "2026-09-01.md").read_text(encoding="utf-8") + assert text.startswith("# реплики Бобра, 2026-09-01\n") assert text.endswith( "- 2026-09-01 14:04 · мастер · привет ⏎ ты меня любишь\n" "- 2026-09-01 14:04 · ветка «крипто-карта» (бриф) · " @@ -180,7 +180,16 @@ def test_reply_log_appends_one_line_per_message_and_strips_seed(tmp_path: Path) assert new_replies(tmp_path / "реплики", since, "Europe/Warsaw") == [ line for line in text.splitlines() if line.startswith("- ") ] - assert date.fromisoformat("2026-09-01") # tz shift stays inside the month here + late = datetime(2026, 9, 1, 22, 30, tzinfo=UTC) + log.write( + UserSaid(conversation_id="c1", kind="master", title=None, text="ночь", at=late) + ) + night = (tmp_path / "реплики" / "2026-09-02.md").read_text(encoding="utf-8") + assert night.endswith("- 2026-09-02 00:30 · мастер · ночь\n") + assert new_replies(tmp_path / "реплики", late, "Europe/Warsaw") == [] + assert new_replies( + tmp_path / "реплики", datetime(2026, 9, 1, 13, tzinfo=UTC), "Europe/Warsaw" + ) == ["- 2026-09-02 00:30 · мастер · ночь"] def test_notes_head_picks_sections_for_the_seed(tmp_path: Path) -> None: