feat(memory,jobs): replies and curator runs go to daily files
This commit is contained in:
1 parent
0273f29589
commit
dea793dbd7
6 files changed
+47
-16
No files matched your search
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 / ".состояние"))
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in new issue
Block a user