Compare commits

...
4 Commits
11 changed files with 538 additions and 32 deletions

No files matched your search

+11 -2
View File
@@ -26,6 +26,9 @@ from beaver_agent.voice import AGENT as VOICE
if TYPE_CHECKING: if TYPE_CHECKING:
from beaver_gateway.agents.base import BaseAgent from beaver_gateway.agents.base import BaseAgent
REASONING = {"type": "adaptive", "display": "summarized"}
"""Рассуждения приезжают текстом: их хранит транскрипт и показывает админка."""
def dispatcher(name: str, model: str, effort: str) -> ClaudeAgent: def dispatcher(name: str, model: str, effort: str) -> ClaudeAgent:
"""Мастер и ветки: полный голос, все руки, vault - дом.""" """Мастер и ветки: полный голос, все руки, vault - дом."""
@@ -36,7 +39,9 @@ def dispatcher(name: str, model: str, effort: str) -> ClaudeAgent:
prompts=prompts.DISPATCHER, prompts=prompts.DISPATCHER,
skill_sets=skills.DISPATCHER, skill_sets=skills.DISPATCHER,
gateway_tools=("read_conversation", "spawn", "say", "schedule", "send_file"), gateway_tools=("read_conversation", "spawn", "say", "schedule", "send_file"),
options=ClaudeOptions(effort=effort, disallowed_tools=DISPATCHER_DISALLOWED), options=ClaudeOptions(
effort=effort, thinking=REASONING, disallowed_tools=DISPATCHER_DISALLOWED
),
policy=( policy=(
*VAULT_POLICY, *VAULT_POLICY,
forbid_skills(skills.MASTER_FORBIDDEN, kinds=("master",)), forbid_skills(skills.MASTER_FORBIDDEN, kinds=("master",)),
@@ -54,7 +59,9 @@ def deep(name: str, model: str, effort: str) -> ClaudeAgent:
prompts=prompts.DEEP, prompts=prompts.DEEP,
skill_sets=skills.DEEP, skill_sets=skills.DEEP,
gateway_tools=("close_chat",), gateway_tools=("close_chat",),
options=ClaudeOptions(effort=effort, disallowed_tools=DEEP_DISALLOWED), options=ClaudeOptions(
effort=effort, thinking=REASONING, disallowed_tools=DEEP_DISALLOWED
),
policy=VAULT_POLICY, policy=VAULT_POLICY,
expose_mcps=hands.CLAUDE, expose_mcps=hands.CLAUDE,
) )
@@ -69,6 +76,7 @@ def distiller(name: str, model: str, effort: str) -> ClaudeAgent:
prompts=prompts.DISTILLER, prompts=prompts.DISTILLER,
options=ClaudeOptions( options=ClaudeOptions(
effort=effort, effort=effort,
thinking=REASONING,
tools=("Read", "Write"), tools=("Read", "Write"),
disallowed_tools=DISTILLER_DISALLOWED, disallowed_tools=DISTILLER_DISALLOWED,
), ),
@@ -86,6 +94,7 @@ def curator(name: str, model: str, effort: str) -> ClaudeAgent:
gateway_tools=("inject",), gateway_tools=("inject",),
options=ClaudeOptions( options=ClaudeOptions(
effort=effort, effort=effort,
thinking=REASONING,
tools=("Read", "Write", "Edit", "Grep", "Glob"), tools=("Read", "Write", "Edit", "Grep", "Glob"),
disallowed_tools=DISTILLER_DISALLOWED, disallowed_tools=DISTILLER_DISALLOWED,
), ),
+113 -14
View File
@@ -6,19 +6,26 @@
`мета/бобер/досуг.md` и пишется моделью; механизм его только читает, поэтому `мета/бобер/досуг.md` и пишется моделью; механизм его только читает, поэтому
переписать друг друга они не могут. Сколько досуговых слотов израсходовано, переписать друг друга они не могут. Сколько досуговых слотов израсходовано,
считается из плана и текущего часа, а не из журнала - хранить нечего. считается из плана и текущего часа, а не из журнала - хранить нечего.
Два своих часа в сутках пустые: дела в них не заходят вовсе, и к тику
приложено эхо - абзац из его же старого дневника или из мыслей Бобра. Тик
называет и файлы дня: дневник для того, что он думает, журнал для следов.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
import random
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from beaver_agent.vault import LEISURE, VAULT, ZONE, read from beaver_agent.vault import JOURNAL, LEISURE, OWN_DIARY, THOUGHTS, VAULT, ZONE, read
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path
from beaver_gateway.jobs.scheduler import JobRun from beaver_gateway.jobs.scheduler import JobRun
from beaver_gateway.storage.models import Conversation from beaver_gateway.storage.models import Conversation
@@ -32,6 +39,15 @@ QUIET_TICKS = (4, 6, 8, 10)
MINE_DEFAULT = (13, 15, 18, 21, 0, 2, 6) MINE_DEFAULT = (13, 15, 18, 21, 0, 2, 6)
MINE_MIN = 7 MINE_MIN = 7
"""Меньше семи чистых слотов в сутках не бывает: недостающие механизм доберёт.""" """Меньше семи чистых слотов в сутках не бывает: недостающие механизм доберёт."""
EMPTY_DEFAULT = (18, 0)
EMPTY_MIN = 2
"""Столько своих часов в сутках пустые; не названы в плане - механизм выберет."""
ECHO_MIN, ECHO_MAX = 200, 1200
"""Абзац для эха: короче - реплика без мысли, длиннее - уже чтение, а не повод.
Абзац с кодом или путём в бэктиках - рабочая запись, эхом он не становится."""
ECHO_AGE = timedelta(days=7)
"""Свой дневник отзывается эхом не раньше, чем через неделю."""
BUSY_RETRY = timedelta(minutes=5) BUSY_RETRY = timedelta(minutes=5)
BUSY_ATTEMPTS = 3 BUSY_ATTEMPTS = 3
@@ -66,19 +82,34 @@ SWITCH_RE = re.compile(r"^досуг:\s*(\S+)", re.IGNORECASE)
DAY_RE = re.compile(r"^сутки:\s*(\d{4}-\d{2}-\d{2})", re.IGNORECASE) DAY_RE = re.compile(r"^сутки:\s*(\d{4}-\d{2}-\d{2})", re.IGNORECASE)
QUIET_RE = re.compile(r"^тихие часы:\s*(.+)", re.IGNORECASE) QUIET_RE = re.compile(r"^тихие часы:\s*(.+)", re.IGNORECASE)
MINE_RE = re.compile(r"^мои часы:\s*(.+)", re.IGNORECASE) MINE_RE = re.compile(r"^мои часы:\s*(.+)", re.IGNORECASE)
EMPTY_RE = re.compile(r"^пустые часы:\s*(.+)", re.IGNORECASE)
OFF = ("выкл", "off", "нет", "0", "false") OFF = ("выкл", "off", "нет", "0", "false")
NO_PLAN = ( NO_PLAN = (
"Расписания на эти сутки нет - работаю по умолчанию. Разложи часы и запиши " "Расписания на эти сутки нет - работаю по умолчанию. Разложи часы и запиши "
"в `{path}` строками «сутки:», «тихие часы:», «мои часы:»." "в `{path}` строками «сутки:», «тихие часы:», «мои часы:», «пустые часы:»."
) )
PLANNING = ( PLANNING = (
"Первый тик суток. Разложи оставшиеся часы: какие твои, какие смешанные, " "Первый тик суток. Разложи оставшиеся часы: какие твои, какие смешанные, "
"какие четыре часа тихого окна оставить. Запиши в `{path}` строками " "какие четыре часа тихого окна оставить. Запиши в `{path}` строками "
"«сутки:», «тихие часы:», «мои часы:». Своих - минимум {min}; запишешь " "«сутки:», «тихие часы:», «мои часы:», «пустые часы:». Своих - минимум {min}, "
"меньше - механизм доберёт сам." "из них пустых - {empty}; запишешь меньше - механизм доберёт сам."
) )
TAIL = "Задания нет. Чем занять час - решаешь ты, «ничем» - тоже ответ." TAIL = "Задания нет. Чем занять час - решаешь ты, «ничем» - тоже ответ."
KINDS = {
"mine": "досуг - час твой, дела Бобра сюда не лезут.",
"mixed": "смешанный - можно и своё, и его.",
"empty": (
"пустой - час без дел вообще: прод, логи, хвосты, зонды и разбор "
"промахов сюда не заходят, ни его, ни твои. Думать, читать, писать - "
"или ничего."
),
}
FILES = (
"Дневник - `{diary}`: то, что ты думаешь, и только оно. След тика, логи "
"и промахи - строкой в `{journal}`."
)
ECHO = "Эхо - `{path}`:\n{quote}\nЭто повод, не задание: можно спорить, можно мимо."
PLAN_PATH = str(LEISURE.relative_to(VAULT)) PLAN_PATH = str(LEISURE.relative_to(VAULT))
@@ -91,6 +122,8 @@ class Plan:
mine: tuple[int, ...] mine: tuple[int, ...]
written: bool written: bool
"""План прочитан из файла на эти сутки; иначе он собран по умолчанию.""" """План прочитан из файла на эти сутки; иначе он собран по умолчанию."""
empty: tuple[int, ...] = ()
"""Свои часы, в которые дела не заходят вовсе; всегда часть ``mine``."""
def quiet_hour(hour: int) -> bool: def quiet_hour(hour: int) -> bool:
@@ -135,13 +168,16 @@ def enabled(text: str | None) -> bool:
def plan_of(text: str | None, day: str) -> Plan: def plan_of(text: str | None, day: str) -> Plan:
"""План на ``day`` из файла; чего нет или что не сходится - берётся дефолт.""" """План на ``day`` из файла; чего нет или что не сходится - берётся дефолт."""
quiet, mine, written = QUIET_TICKS, MINE_DEFAULT, False quiet, mine, written = QUIET_TICKS, MINE_DEFAULT, False
empty: tuple[int, ...] = ()
if text is not None and _first(text, DAY_RE) == day: if text is not None and _first(text, DAY_RE) == day:
written = True written = True
raw_quiet = _first(text, QUIET_RE) raw_quiet = _first(text, QUIET_RE)
raw_mine = _first(text, MINE_RE) raw_mine = _first(text, MINE_RE)
raw_empty = _first(text, EMPTY_RE)
named = _hours(raw_quiet) if raw_quiet else () named = _hours(raw_quiet) if raw_quiet else ()
quiet = tuple(h for h in named if quiet_hour(h)) or QUIET_TICKS quiet = tuple(h for h in named if quiet_hour(h)) or QUIET_TICKS
mine = _hours(raw_mine) if raw_mine else () mine = _hours(raw_mine) if raw_mine else ()
empty = _hours(raw_empty) if raw_empty else ()
ticks = tick_hours(quiet) ticks = tick_hours(quiet)
kept = [h for h in mine if h in ticks] kept = [h for h in mine if h in ticks]
for candidate in (*MINE_DEFAULT, *ticks): for candidate in (*MINE_DEFAULT, *ticks):
@@ -150,11 +186,18 @@ def plan_of(text: str | None, day: str) -> Plan:
if candidate in ticks and candidate not in kept: if candidate in ticks and candidate not in kept:
kept.append(candidate) kept.append(candidate)
order = {h: i for i, h in enumerate(ticks)} order = {h: i for i, h in enumerate(ticks)}
hollow = [h for h in empty if h in kept]
for candidate in (*EMPTY_DEFAULT, *reversed(kept)):
if len(hollow) >= EMPTY_MIN:
break
if candidate in kept and candidate not in hollow:
hollow.append(candidate)
return Plan( return Plan(
day=day, day=day,
quiet=quiet, quiet=quiet,
mine=tuple(sorted(kept, key=lambda h: order[h])), mine=tuple(sorted(kept, key=lambda h: order[h])),
written=written, written=written,
empty=tuple(sorted(hollow, key=lambda h: order[h])),
) )
@@ -173,7 +216,50 @@ def _left(now: datetime) -> str:
return f"Сутки заканчиваются через {hours} ч (в {end:%H:%M %d.%m})." return f"Сутки заканчиваются через {hours} ч (в {end:%H:%M %d.%m})."
def brief(now: datetime, plan: Plan) -> str: def kind_of(hour: int, plan: Plan) -> str:
if hour in plan.empty:
return "empty"
return "mine" if hour in plan.mine else "mixed"
def echo(
now: datetime, *, diary: Path = OWN_DIARY, thoughts: Path = THOUGHTS
) -> tuple[Path, str] | None:
"""Абзац для пустого часа: из своего старого дневника или из мыслей Бобра.
Выбор случаен, но привязан к часу тика: тот же тик даёт то же эхо.
"""
stale = f"{(now - ECHO_AGE):%Y-%m-%d}.md"
own = [p for p in diary.glob("????-??-??.md") if p.name < stale]
files = sorted([*own, *thoughts.rglob("*.md")])
rng = random.Random(f"{now:%Y-%m-%d %H}") # noqa: S311
rng.shuffle(files)
for path in files:
try:
text = path.read_text(encoding="utf-8")
except OSError:
continue
found = [
block.strip()
for block in re.split(r"\n\s*\n", text)
if ECHO_MIN <= len(block.strip()) <= ECHO_MAX
and block.lstrip()[:1] not in "#->|"
and "`" not in block
]
if found:
return path, rng.choice(found)
return None
def _files(now: datetime) -> str:
name = f"{now:%Y-%m-%d}.md"
return FILES.format(
diary=(OWN_DIARY / name).relative_to(VAULT),
journal=(JOURNAL / name).relative_to(VAULT),
)
def brief(now: datetime, plan: Plan, *, heard: tuple[Path, str] | None = None) -> str:
"""Текст инжекта: конверта в таком тёрне нет, время несёт он сам.""" """Текст инжекта: конверта в таком тёрне нет, время несёт он сам."""
hour = now.hour hour = now.hour
ticks = tick_hours(plan.quiet) ticks = tick_hours(plan.quiet)
@@ -183,12 +269,7 @@ def brief(now: datetime, plan: Plan) -> str:
spent = [h for h in plan.mine if order[h] < here] spent = [h for h in plan.mine if order[h] < here]
ahead = [h for h in plan.mine if order[h] > here] ahead = [h for h in plan.mine if order[h] > here]
lines = [ lines = [
f"Тик {_stamp(now)}. Тип: {'досуг' if mine else 'смешанный'}" f"Тик {_stamp(now)}. Тип: {KINDS[kind_of(hour, plan)]}",
+ (
" - час твой, дела Бобра сюда не лезут."
if mine
else " - можно и своё, и его."
),
_left(now), _left(now),
"Мои часы: " "Мои часы: "
+ ", ".join(f"{h:02d}" for h in plan.mine) + ", ".join(f"{h:02d}" for h in plan.mine)
@@ -198,6 +279,11 @@ def brief(now: datetime, plan: Plan) -> str:
if ahead if ahead
else ", впереди больше нет" else ", впереди больше нет"
) )
+ (
". Пустые: " + ", ".join(f"{h:02d}" for h in plan.empty)
if plan.empty
else ""
)
+ ".", + ".",
f"Тихое окно {QUIET_FROM:02d}:00-{QUIET_UNTIL - 1:02d}:59, тикают " f"Тихое окно {QUIET_FROM:02d}:00-{QUIET_UNTIL - 1:02d}:59, тикают "
+ ", ".join(f"{h:02d}" for h in plan.quiet) + ", ".join(f"{h:02d}" for h in plan.quiet)
@@ -206,7 +292,16 @@ def brief(now: datetime, plan: Plan) -> str:
if not plan.written: if not plan.written:
lines.append(NO_PLAN.format(path=PLAN_PATH)) lines.append(NO_PLAN.format(path=PLAN_PATH))
if here == 0: if here == 0:
lines.append(PLANNING.format(path=PLAN_PATH, min=MINE_MIN)) lines.append(PLANNING.format(path=PLAN_PATH, min=MINE_MIN, empty=EMPTY_MIN))
lines.append(_files(now))
if heard is not None:
path, quote = heard
lines.append(
ECHO.format(
path=path.relative_to(VAULT),
quote="\n".join(f"> {line}" for line in quote.splitlines()),
)
)
lines.append(TAIL) lines.append(TAIL)
return "\n".join(lines) return "\n".join(lines)
@@ -268,12 +363,16 @@ async def tick(run: JobRun) -> None:
_log.info("досуг: мастер занят, сдвиг на %s (попытка %s)", BUSY_RETRY, attempt) _log.info("досуг: мастер занят, сдвиг на %s (попытка %s)", BUSY_RETRY, attempt)
return return
riding = await _riding(run, master, now) riding = await _riding(run, master, now)
kind = kind_of(now.hour, plan)
heard = echo(now) if kind == "empty" else None
await run.inject_master( await run.inject_master(
brief(now, plan), urgency="normal" if riding else "wake", origin="досуг" brief(now, plan, heard=heard),
urgency="normal" if riding else "wake",
origin="досуг",
) )
_log.info( _log.info(
"досуг: тик %02d:00 (%s), слияние с отложенным инжектом: %s", "досуг: тик %02d:00 (%s), слияние с отложенным инжектом: %s",
now.hour, now.hour,
"досуг" if now.hour in plan.mine else "смешанный", kind,
riding, riding,
) )
+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 / ".состояние"))
+1
View File
@@ -26,6 +26,7 @@ unfixable = ["F401"]
[lint.per-file-ignores] [lint.per-file-ignores]
"config.py" = ["INP001"] "config.py" = ["INP001"]
"scripts/split_logs.py" = ["INP001", "T201"]
"tests/*" = ["ALL"] "tests/*" = ["ALL"]
[lint.pydocstyle] [lint.pydocstyle]
+283
View File
@@ -0,0 +1,283 @@
"""Разовая нарезка вечных и месячных файлов `мета/бобер/` по дням.
BEAVER_VAULT=<vault> PYTHONPATH=. uv run python scripts/split_logs.py
BEAVER_VAULT=<vault> PYTHONPATH=. uv run python scripts/split_logs.py --apply
Без `--apply` только печатает, что будет.
Реплики и журнал куратора режутся точно: дата стоит в каждой записи. Дневник
режется по заголовкам и датированным строкам: записи под заголовком едут в
`дневник/`, строки вида `- 05:4x 12.10 …` - в `журнал/`. Журнал досуга на две
трети без дат, поэтому уезжает одним архивом. Оригиналы копируются в
`мета/бобер/.архив/<сегодня>/` до первой записи.
"""
from __future__ import annotations
import re
import shutil
import sys
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import date, timedelta
from typing import TYPE_CHECKING
from beaver_agent.vault import (
BEAVER,
CURATOR_JOURNAL,
CURATOR_RUNS,
JOURNAL,
LEISURE,
OWN_DIARY,
REPLIES,
VAULT,
today,
)
if TYPE_CHECKING:
from pathlib import Path
YEAR = 2026
OLD_DIARY = BEAVER / "дневник.md"
LEISURE_ARCHIVE = JOURNAL / "архив досуга.md"
REPLY = re.compile(r"^- (\d{4}-\d{2}-\d{2}) \d{2}:\d{2} · ")
RUN = re.compile(r"^## (\d{4}-\d{2}-\d{2})\b")
ISO_HEAD = re.compile(r"^#{2,3}\s+(\d{4})-(\d{2})-(\d{2})\b")
HEAD = re.compile(r"^#{2,3}\s")
HEAD_DAY = re.compile(r"\b(\d{2})\.(\d{2})\b")
HEAD_HOUR = re.compile(r"^#{2,3}\s+(\d{2}):[\dx]{2}")
LINE = re.compile(r"^- (\d{2}):[\dx]{2}(?:-\d{2}:[\dx]{2})? (\d{2})\.(\d{2})\b")
LINE_DAY_FIRST = re.compile(r"^- (\d{2})\.(\d{2}) (\d{2}):[\dx]{2}\b")
CURATOR_STUB = (
"\n# куратор\n\n"
"> Здесь только строка прошлого прогона. Записи прогонов - в "
"`куратор/YYYY-MM-DD.md`, файл на день, свежие сверху.\n"
)
DIARY_STUB = (
"\n---\n\n"
"> С 13.10.2026 дневник лежит по дням: `дневник/YYYY-MM-DD.md` - то, что "
"я думаю; следы тиков, логи и разбор промахов - `журнал/YYYY-MM-DD.md`. "
"Сюда больше не дописывать.\n"
)
@dataclass
class Plan:
writes: dict[Path, str] = field(default_factory=dict)
removes: list[Path] = field(default_factory=list)
backups: list[Path] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
def note(self, text: str) -> None:
self.notes.append(text)
def _join(blocks: list[str], sep: str) -> str:
return sep.join(block.strip("\n") for block in blocks if block.strip()) + "\n"
def _merge(path: Path, old: str, *, old_first: bool) -> str:
"""Старое рядом с тем, что уже успели положить в файл дня."""
if not path.exists():
return old
fresh = path.read_text(encoding="utf-8").rstrip("\n") + "\n"
return f"{old}\n{fresh}" if old_first else f"{fresh}\n{old}"
def split_replies(plan: Plan) -> None:
for month in sorted(REPLIES.glob("????-??.md")):
days: dict[str, list[str]] = defaultdict(list)
stray = 0
for line in month.read_text(encoding="utf-8").splitlines():
match = REPLY.match(line)
if match:
days[match.group(1)].append(line)
elif line.strip() and not line.startswith(("#", ">")):
stray += 1
for day, lines in days.items():
path = REPLIES / f"{day}.md"
known = (
[
line
for line in path.read_text(encoding="utf-8").splitlines()
if REPLY.match(line)
]
if path.exists()
else []
)
seen = set(lines)
merged = sorted(
[*lines, *(line for line in known if line not in seen)],
key=lambda line: line[:18],
)
plan.writes[path] = (
f"# реплики Бобра, {day}\n\n"
"> пишет gateway: что Бобёр сказал в мастере и ветках, "
"строка на сообщение. Глубокие чаты лежат в `💬 чаты/` сами.\n\n"
+ "\n".join(merged)
+ "\n"
)
plan.removes.append(month)
plan.backups.append(month)
plan.note(
f"{month.name}: {sum(map(len, days.values()))} реплик на {len(days)} дней"
+ (f", {stray} строк без даты пропали" if stray else "")
)
def split_curator(plan: Plan) -> None:
if not CURATOR_JOURNAL.exists():
return
lines = CURATOR_JOURNAL.read_text(encoding="utf-8").splitlines()
days: dict[str, list[str]] = defaultdict(list)
runs: list[str] | None = None
for line in lines:
match = RUN.match(line)
if match:
runs = days[match.group(1)]
runs.append(line)
elif runs is not None:
runs[-1] += "\n" + line
if not days:
return
for day, entries in days.items():
path = CURATOR_RUNS / f"{day}.md"
plan.writes[path] = _merge(path, _join(entries, "\n\n"), old_first=False)
plan.writes[CURATOR_JOURNAL] = lines[0] + "\n" + CURATOR_STUB
plan.backups.append(CURATOR_JOURNAL)
plan.note(
f"{CURATOR_JOURNAL.name}: {sum(map(len, days.values()))} прогонов "
f"на {len(days)} дней"
)
def _day(month: str, day: str) -> date:
return date(YEAR, int(month), int(day))
def split_diary(plan: Plan) -> None:
if not OLD_DIARY.exists():
return
lines = OLD_DIARY.read_text(encoding="utf-8").splitlines()
first = next((i for i, line in enumerate(lines) if HEAD.match(line)), len(lines))
preamble = "\n".join(lines[:first]).rstrip().removesuffix("---").rstrip()
thoughts: dict[date, list[str]] = defaultdict(list)
traces: dict[date, list[str]] = defaultdict(list)
current: date | None = None
hour = 0
chunk: list[str] | None = None
for number, line in enumerate(lines[first:], first + 1):
iso, timed = ISO_HEAD.match(line), HEAD_HOUR.match(line)
log = LINE.match(line)
day_first = LINE_DAY_FIRST.match(line)
stamp: date | None = None
target = thoughts
if iso:
stamp = date(int(iso.group(1)), int(iso.group(2)), int(iso.group(3)))
elif HEAD.match(line):
named = HEAD_DAY.search(line)
if named:
stamp = _day(named.group(2), named.group(1))
elif timed and current is not None:
rolled = int(timed.group(1)) < hour
stamp = current + timedelta(days=1) if rolled else current
if timed:
hour = int(timed.group(1))
elif log:
stamp, target = _day(log.group(3), log.group(2)), traces
elif day_first:
stamp, target = _day(day_first.group(2), day_first.group(1)), traces
if stamp is None:
if chunk is not None:
chunk[-1] += "\n" + line
continue
if current is not None and stamp < current:
plan.note(f"дневник, строка {number}: дата идёт назад, {current} → {stamp}")
current = stamp
chunk = target[stamp]
chunk.append(line)
if not thoughts and not traces:
return
for day, blocks in thoughts.items():
path = OWN_DIARY / f"{day}.md"
plan.writes[path] = _merge(path, _join(blocks, "\n\n"), old_first=True)
for day, blocks in traces.items():
path = JOURNAL / f"{day}.md"
plan.writes[path] = _merge(path, _join(blocks, "\n"), old_first=True)
plan.writes[OLD_DIARY] = preamble + "\n" + DIARY_STUB
plan.backups.append(OLD_DIARY)
plan.note(
f"{OLD_DIARY.name}: {sum(map(len, thoughts.values()))} записей на "
f"{len(thoughts)} дней в дневник, {sum(map(len, traces.values()))} строк на "
f"{len(traces)} дней в журнал"
)
def split_leisure(plan: Plan) -> None:
if not LEISURE.exists():
return
text = LEISURE.read_text(encoding="utf-8")
head, mark, journal = text.partition("\n## журнал\n")
if not mark or not journal.strip():
return
plan.writes[LEISURE_ARCHIVE] = (
"# журнал досуга до 13.10.2026\n\n"
"> Архив из `досуг.md`: записи шли без дат и в разном порядке, по дням "
"их не разложить. Дальше следы тиков лежат в `журнал/YYYY-MM-DD.md`.\n\n"
+ journal.strip("\n")
+ "\n"
)
plan.writes[LEISURE] = head.rstrip("\n") + "\n"
plan.backups.append(LEISURE)
plan.note(
f"{LEISURE.name}: {len(journal) // 1024} КБ журнала в архив, "
f"в файле остаётся {len(head) // 1024 + 1} КБ раскладки"
)
def backup(sources: list[Path]) -> Path:
target = BEAVER / ".архив" / today()
target.mkdir(parents=True, exist_ok=True)
for path in sources:
if path.exists() and not (target / path.name).exists():
shutil.copy2(path, target / path.name)
return target
def main() -> None:
plan = Plan()
split_replies(plan)
split_curator(plan)
split_diary(plan)
split_leisure(plan)
for note in plan.notes:
print(note)
folders: dict[str, int] = defaultdict(int)
for path in plan.writes:
folders[str(path.parent.relative_to(VAULT))] += 1
for folder, count in sorted(folders.items()):
print(f"{folder}: {count} файлов")
largest = max(plan.writes.items(), key=lambda item: len(item[1]), default=None)
if largest:
print(
f"самый большой: {largest[0].relative_to(VAULT)}, "
f"{len(largest[1].encode()) // 1024} КБ"
)
if "--apply" not in sys.argv:
print("ничего не записано: это прогон без --apply")
return
if not plan.writes:
return
print(f"оригиналы: {backup(plan.backups).relative_to(VAULT)}")
for path, text in plan.writes.items():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
for path in plan.removes:
path.unlink()
print(f"записано {len(plan.writes)} файлов, убрано {len(plan.removes)}")
if __name__ == "__main__":
main()
+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",
+83
View File
@@ -1,17 +1,22 @@
from datetime import datetime from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from beaver_agent.jobs.leisure import ( from beaver_agent.jobs.leisure import (
EMPTY_DEFAULT,
MINE_MIN, MINE_MIN,
QUIET_TICKS, QUIET_TICKS,
Plan, Plan,
brief, brief,
day_of, day_of,
echo,
enabled, enabled,
kind_of,
plan_of, plan_of,
quiet_hour, quiet_hour,
tick_hours, tick_hours,
) )
from beaver_agent.vault import VAULT
ZONE = ZoneInfo("Europe/Warsaw") ZONE = ZoneInfo("Europe/Warsaw")
@@ -121,3 +126,81 @@ def test_last_night_tick_says_nothing_is_left_ahead():
assert "впереди больше нет" in text assert "впереди больше нет" in text
assert "израсходовано 2 из 2" in text assert "израсходовано 2 из 2" in text
assert "Сутки заканчиваются через 6 ч" in text assert "Сутки заканчиваются через 6 ч" in text
def test_two_of_my_hours_are_empty_named_or_not():
named = plan_of(
"сутки: 2026-09-08\nмои часы: 13, 15, 18, 21, 00, 02, 06\n"
"пустые часы: 21, 02\n",
"2026-09-08",
)
assert named.empty == (21, 2)
unnamed = plan_of(None, "2026-09-08")
assert unnamed.empty == EMPTY_DEFAULT
assert set(unnamed.empty) <= set(unnamed.mine)
foreign = plan_of(
"сутки: 2026-09-08\nмои часы: 13, 14, 15, 16, 17, 19, 20\n"
"пустые часы: 22, 20\n",
"2026-09-08",
)
assert foreign.empty == (19, 20)
assert kind_of(20, foreign) == "empty"
assert kind_of(13, foreign) == "mine"
assert kind_of(21, foreign) == "mixed"
def test_an_empty_hour_carries_no_work_but_an_echo_and_the_files_of_the_day():
plan = plan_of(None, "2026-09-08")
heard = (VAULT / "мета" / "бобер" / "дневник" / "2026-08-30.md", "Раз.\nДва.")
text = brief(at("2026-09-08 18:00"), plan, heard=heard)
assert "Тип: пустой - час без дел вообще" in text
assert "Пустые: 18, 00." in text
assert "Эхо - `мета/бобер/дневник/2026-08-30.md`:\n> Раз.\n> Два.\n" in text
assert "Дневник - `мета/бобер/дневник/2026-09-08.md`" in text
assert "`мета/бобер/журнал/2026-09-08.md`" in text
night = brief(at("2026-09-09 00:00"), plan)
assert "Тип: пустой" in night
assert "мета/бобер/дневник/2026-09-09.md" in night
assert "Эхо" not in night
mine = brief(at("2026-09-08 13:00"), plan)
assert "Тип: досуг" in mine
assert "мета/бобер/журнал/2026-09-08.md" in mine
def test_echo_is_a_paragraph_of_an_old_diary_day_or_a_thought(tmp_path: Path):
diary = tmp_path / "дневник"
thoughts = tmp_path / "мысли" / "2026"
diary.mkdir()
thoughts.mkdir(parents=True)
old = "Думал про то, зачем мне вообще помнить вчерашний день. " * 5
fresh = "Свежая запись, которой ещё рано возвращаться ко мне эхом. " * 5
(diary / "2026-08-30.md").write_text(
f"## 2026-08-30, 01:07\n\n{old}\n\n- 02:07 30.08 лог, а не мысль {'x' * 220}\n"
"\nкоротко\n",
encoding="utf-8",
)
(diary / "2026-09-07.md").write_text(fresh, encoding="utf-8")
now = at("2026-09-08 18:00")
assert echo(now, diary=diary, thoughts=thoughts) == (
diary / "2026-08-30.md",
old.strip(),
)
thought = "Заметка Бобра о том, почему скука полезнее расписания. " * 5
(diary / "2026-08-30.md").unlink()
(thoughts / "скука.md").write_text(
f"---\ntags: [мысли]\n---\n\n{thought}\n", encoding="utf-8"
)
assert echo(now, diary=diary, thoughts=thoughts) == (
thoughts / "скука.md",
thought.strip(),
)
assert echo(now, diary=diary, thoughts=thoughts) == echo(
now, diary=diary, thoughts=thoughts
)
(thoughts / "скука.md").unlink()
assert echo(now, diary=diary, thoughts=thoughts) is None
+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: