feat(memory,jobs): replies and curator runs go to daily files

This commit is contained in:
hh committed 2026-10-13 01:23:20 +02:00
1 parent 0273f29589
commit dea793dbd7
6 files changed
+47 -16

No files matched your search

+2
View File
@@ -11,6 +11,7 @@ from beaver_agent.memory.curator import briefing
from beaver_agent.memory.watch import CURATOR_WATCH from beaver_agent.memory.watch import CURATOR_WATCH
from beaver_agent.vault import ( from beaver_agent.vault import (
CURATOR_JOURNAL, CURATOR_JOURNAL,
CURATOR_RUNS,
DAYS, DAYS,
LEISURE, LEISURE,
REPLIES, REPLIES,
@@ -57,6 +58,7 @@ async def curate(run: JobRun) -> None:
text = briefing( text = briefing(
vault=VAULT, vault=VAULT,
journal=CURATOR_JOURNAL, journal=CURATOR_JOURNAL,
runs=CURATOR_RUNS,
replies_dir=REPLIES, replies_dir=REPLIES,
watched=CURATOR_WATCH, watched=CURATOR_WATCH,
tz=TZ, tz=TZ,
+15 -8
View File
@@ -3,8 +3,9 @@
Крон дёргает детерминированный код, модель судит: здесь собирается брифинг Крон дёргает детерминированный код, модель судит: здесь собирается брифинг
для служебного тёрна `beaver-curator` - новые реплики Бобра целиком и имена для служебного тёрна `beaver-curator` - новые реплики Бобра целиком и имена
файлов, изменившихся с прошлого прогона. Время прошлого прогона куратор файлов, изменившихся с прошлого прогона. Время прошлого прогона куратор
сам пишет первой строкой `мета/бобер/куратор.md`; нет ничего нового - сам пишет первой строкой `мета/бобер/куратор.md`, запись о прогоне - в файл
брифинга нет и джоб не спавнится. дня `мета/бобер/куратор/YYYY-MM-DD.md`; нет ничего нового - брифинга нет и
джоб не спавнится.
""" """
from __future__ import annotations 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]: def new_replies(replies_dir: Path, since: datetime, tz: str) -> list[str]:
"""Строки `реплики/YYYY-MM.md` позже ``since`` (время в строках локальное).""" """Строки `реплики/YYYY-MM-DD.md` позже ``since`` (время в строках локальное)."""
zone = ZoneInfo(tz) zone = ZoneInfo(tz)
local_since = since.astimezone(zone).replace(tzinfo=None) local_since = since.astimezone(zone).replace(tzinfo=None)
out: list[str] = [] out: list[str] = []
if not replies_dir.exists(): if not replies_dir.exists():
return out 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(): for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
m = REPLY_RE.match(line) m = REPLY_RE.match(line)
if not m: if not m:
@@ -91,6 +95,7 @@ def briefing(
*, *,
vault: Path, vault: Path,
journal: Path, journal: Path,
runs: Path,
replies_dir: Path, replies_dir: Path,
watched: list[Watched], watched: list[Watched],
tz: str, tz: str,
@@ -111,12 +116,14 @@ def briefing(
if not replies and not changed: if not replies and not changed:
return None return None
zone = ZoneInfo(tz) zone = ZoneInfo(tz)
local = now.astimezone(zone)
head = ( 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"{since.astimezone(zone):%Y-%m-%d %H:%M}. Ниже - что изменилось с тех пор; "
"остальное читай сам по слоям. В конце обнови первую строку " "остальное читай сам по слоям. В конце запись о прогоне - в "
f"`{journal.relative_to(vault)}`: `последний прогон: " f"`{(runs / f'{local:%Y-%m-%d}.md').relative_to(vault)}`, а в "
f"{now.astimezone(zone).isoformat(timespec='seconds')}`." f"`{journal.relative_to(vault)}` только первая строка: `последний прогон: "
f"{local.isoformat(timespec='seconds')}`."
) )
parts = [head] parts = [head]
if replies: if replies:
+4 -4
View File
@@ -6,7 +6,7 @@ Gateway путей не знает; здесь - что искать по тек
дни дневника, где он встречался, и раз в день - подошедшие сроки из досок и дни дневника, где он встречался, и раз в день - подошедшие сроки из досок и
ближайшая неделя календарей. ближайшая неделя календарей.
``ReplyLog.write`` кладёт каждую реплику Бобра из мастера и веток в ``ReplyLog.write`` кладёт каждую реплику Бобра из мастера и веток в
`мета/бобер/реплики/YYYY-MM.md` - единственное место, где они grep-абельны: `мета/бобер/реплики/YYYY-MM-DD.md` - единственное место, где они grep-абельны:
транскрипт живёт в Postgres, а глубокие чаты и так лежат файлами в `💬 чаты/`. транскрипт живёт в Postgres, а глубокие чаты и так лежат файлами в `💬 чаты/`.
""" """
@@ -388,7 +388,7 @@ def event_line(event: Event, zone: ZoneInfo) -> str:
@dataclass @dataclass
class ReplyLog: class ReplyLog:
"""`мета/бобер/реплики/YYYY-MM.md`: строка на реплику Бобра из мастера и веток.""" """`мета/бобер/реплики/YYYY-MM-DD.md`: строка на реплику из мастера и веток."""
dir: Path dir: Path
tz: str = "Europe/Warsaw" tz: str = "Europe/Warsaw"
@@ -416,11 +416,11 @@ class ReplyLog:
if seeded: if seeded:
# не человек сказал, а механизм спавна: видно по источнику, не по тексту # не человек сказал, а механизм спавна: видно по источнику, не по тексту
where = f"{where} (бриф)" 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) self.dir.mkdir(parents=True, exist_ok=True)
if not path.exists(): if not path.exists():
path.write_text( path.write_text(
f"# реплики Бобра, {local:%Y-%m}\n\n" f"# реплики Бобра, {local:%Y-%m-%d}\n\n"
"> пишет gateway: что Бобёр сказал в мастере и ветках, " "> пишет gateway: что Бобёр сказал в мастере и ветках, "
"строка на сообщение. Глубокие чаты лежат в `💬 чаты/` сами.\n\n", "строка на сообщение. Глубокие чаты лежат в `💬 чаты/` сами.\n\n",
encoding="utf-8", encoding="utf-8",
+7
View File
@@ -18,6 +18,7 @@ CHATS = VAULT / "💬 чаты"
DIARY = VAULT / "📅 дни" DIARY = VAULT / "📅 дни"
PEOPLE = VAULT / "👤 люди" PEOPLE = VAULT / "👤 люди"
BOARDS = VAULT / "📆 доски" BOARDS = VAULT / "📆 доски"
THOUGHTS = VAULT / "🧠 мысли"
BEAVER = VAULT / "мета" / "бобер" BEAVER = VAULT / "мета" / "бобер"
"""Дом агента: всё, что он ведёт сам, лежит здесь.""" """Дом агента: всё, что он ведёт сам, лежит здесь."""
@@ -31,6 +32,12 @@ NOTES = BEAVER / "наблюдения"
PORTRAIT = NOTES / "Бобёр - наблюдения.md" PORTRAIT = NOTES / "Бобёр - наблюдения.md"
REPLIES = BEAVER / "реплики" REPLIES = BEAVER / "реплики"
CURATOR_JOURNAL = BEAVER / "куратор.md" CURATOR_JOURNAL = BEAVER / "куратор.md"
"""Первая строка - время прошлого прогона; сами записи лежат в `CURATOR_RUNS`."""
CURATOR_RUNS = BEAVER / "куратор"
OWN_DIARY = BEAVER / "дневник"
"""Его собственный дневник, файл на день: то, что он думает, без логов."""
JOURNAL = BEAVER / "журнал"
"""Рабочий журнал, файл на день: следы тиков, логи, разбор промахов."""
LEISURE = BEAVER / "досуг.md" LEISURE = BEAVER / "досуг.md"
"""Раскладка суток по тикам досуга: пишет её модель, механизм только читает.""" """Раскладка суток по тикам досуга: пишет её модель, механизм только читает."""
STATE_DIR = Path(os.environ.get("BEAVER_STATE_DIR", BEAVER / ".состояние")) STATE_DIR = Path(os.environ.get("BEAVER_STATE_DIR", BEAVER / ".состояние"))
+7 -1
View File
@@ -25,7 +25,10 @@ def test_briefing_lists_new_replies_and_changed_files_or_nothing(
notes = beaver / "наблюдения" notes = beaver / "наблюдения"
replies.mkdir(parents=True) replies.mkdir(parents=True)
notes.mkdir() 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", "# реплики\n\n- 2026-09-01 12:00 · мастер · старое\n- 2026-09-01 19:30 · мастер · новое сообщение\n",
encoding="utf-8", encoding="utf-8",
) )
@@ -49,6 +52,7 @@ def test_briefing_lists_new_replies_and_changed_files_or_nothing(
text = briefing( text = briefing(
vault=vault, vault=vault,
journal=journal, journal=journal,
runs=beaver / "куратор",
replies_dir=replies, replies_dir=replies,
watched=[Watched("наблюдения", notes)], watched=[Watched("наблюдения", notes)],
tz="Europe/Warsaw", tz="Europe/Warsaw",
@@ -63,6 +67,7 @@ def test_briefing_lists_new_replies_and_changed_files_or_nothing(
assert "наблюдения: `мета/бобер/наблюдения/Зина - наблюдения.md`" in text assert "наблюдения: `мета/бобер/наблюдения/Зина - наблюдения.md`" in text
assert "Глафира" not in text assert "Глафира" not in text
assert "последний прогон: 2026-09-01T22:00:00+02:00" 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") journal.write_text(f"последний прогон: {now.isoformat()}\n", encoding="utf-8")
assert new_replies(replies, now, "Europe/Warsaw") == [] assert new_replies(replies, now, "Europe/Warsaw") == []
@@ -71,6 +76,7 @@ def test_briefing_lists_new_replies_and_changed_files_or_nothing(
briefing( briefing(
vault=vault, vault=vault,
journal=journal, journal=journal,
runs=beaver / "куратор",
replies_dir=replies, replies_dir=replies,
watched=[Watched("наблюдения", notes)], watched=[Watched("наблюдения", notes)],
tz="Europe/Warsaw", tz="Europe/Warsaw",
+12 -3
View File
@@ -168,8 +168,8 @@ def test_reply_log_appends_one_line_per_message_and_strips_seed(tmp_path: Path)
at=at, at=at,
) )
) )
text = (tmp_path / "реплики" / "2026-09.md").read_text(encoding="utf-8") text = (tmp_path / "реплики" / "2026-09-01.md").read_text(encoding="utf-8")
assert text.startswith("# реплики Бобра, 2026-09\n") assert text.startswith("# реплики Бобра, 2026-09-01\n")
assert text.endswith( assert text.endswith(
"- 2026-09-01 14:04 · мастер · привет ⏎ ты меня любишь\n" "- 2026-09-01 14:04 · мастер · привет ⏎ ты меня любишь\n"
"- 2026-09-01 14:04 · ветка «крипто-карта» (бриф) · " "- 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") == [ assert new_replies(tmp_path / "реплики", since, "Europe/Warsaw") == [
line for line in text.splitlines() if line.startswith("- ") 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: def test_notes_head_picks_sections_for_the_seed(tmp_path: Path) -> None: