diff --git a/config.py b/config.py index ee71d1c..520c065 100644 --- a/config.py +++ b/config.py @@ -9,6 +9,7 @@ from beaver_gateway.agents.base import BaseAgent, ExposedMcp from beaver_gateway.agents.claude import ClaudeAgent, ClaudeOptions, Prompts from beaver_gateway.agents.raycast import RaycastAgent, RemoteTool, UserPreferences from beaver_gateway.core.conversations import ConversationTexts, SeedContext +from beaver_gateway.core.distill import DistillContext, Distiller, LineCap from beaver_gateway.core.prompt import assemble from beaver_gateway.core.registry import Gateway from beaver_gateway.core.rotation import HandoutContext, RotationPolicy @@ -33,6 +34,12 @@ PROMPTS = BEAVER / "промпты" GRANULES = PROMPTS / "гранулы" SKILLS = BEAVER / "скиллы" DAYS = BEAVER / "дни" +DIGESTS = BEAVER / "выжимки" +INDEX = BEAVER / "индекс.md" +STATE = BEAVER / "состояние.md" +STATE_MAX_LINES = 60 +# §4.5: ночное закрытие трогает только чаты с активностью после запуска. +LAUNCH = datetime(2026, 8, 28, tzinfo=ZoneInfo(TZ)) _log = logging.getLogger("beaver_agent.config") @@ -79,6 +86,10 @@ DISPATCHER_PROMPTS = Prompts( fork=_DISTILL, job=_DISTILL, ) +# §4.1: дистиллятор без голоса - иначе выжимки с матом: профиль, окружение +# джоба, оверлей. Форк закрываемого чата и недельная чистка состояния. +_DISTILLER = (PROFILE, environment("джоб"), overlay("distiller", "дистиллятор")) +DISTILLER_PROMPTS = Prompts(fork=_DISTILLER, job=_DISTILLER) # §4.1: триаж без голоса и без vault - профиль, окружение джоба, оверлей. TRIAGE_PROMPTS = Prompts(job=(PROFILE, environment("джоб"), overlay("triage", "триаж"))) QUICK_PROMPT = assemble((*VOICE, PROFILE, overlay("quick", "быстрый"))) @@ -173,6 +184,8 @@ def deep(name: str, model: str, effort: str | None = None) -> ClaudeAgent: cwd=VAULT, prompts=DEEP_PROMPTS, skill_sets=DEEP_SKILLS, + # §8.4: «ок, обсудили» - тулза, закрытие после ответа. + gateway_tools=("close_chat",), options=ClaudeOptions( effort=effort, # §3.7: у глубоких вопросы текстом, планов и сабагентов нет. @@ -188,6 +201,21 @@ def deep(name: str, model: str, effort: str | None = None) -> ClaudeAgent: ) +def distiller(name: str, model: str, effort: str | None = None) -> ClaudeAgent: + """§4.1: форк закрытого чата - opus medium, без MCP, Read/Write в мета/бобер.""" + return ClaudeAgent( + name=name, + model=model, + cwd=VAULT, + prompts=DISTILLER_PROMPTS, + options=ClaudeOptions( + effort=effort, + tools=("Read", "Write"), + disallowed_tools=("AskUserQuestion", "Task", "WebSearch", "WebFetch"), + ), + ) + + def triage(name: str, model: str, effort: str | None = None) -> ClaudeAgent: """§4.1: дешёвый крон-тёрн - sonnet low, без vault, только say/inject.""" return ClaudeAgent( @@ -230,6 +258,7 @@ agents: list[BaseAgent] = [ deep("beaver-opus-medium", "claude-opus-5", effort="medium"), 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"), triage("beaver-triage", "claude-sonnet-5", effort="low"), raycast("beaver-gemini-pro-low", "google-gemini-3.1-pro", reasoning_effort="low"), raycast( @@ -336,11 +365,32 @@ NEW_DAY = ( "в сиде выше. Скажи Бобру что-то через say, только если есть что сказать." ) +DISTILL = ( + "Глубокий чат [[{chat}]] закрыт ({reason}), сегодня {day}. Выжимка - `Write` в " + '`{path}`, фронтматтер `source: "[[{chat}]]"`, `date: {day}`; слив - текст ' + "ответа, до 5 строк, третье лицо." +) +DISTILL_NO_MEMORY = ( + "Глубокий чат [[{chat}]] закрыт ({reason}), сегодня {day}. Память для него " + "выключена: файл не пиши, только слив текстом ответа - до 5 строк, третье лицо." +) + + +def distill_prompt(ctx: DistillContext) -> str: + """§8.4: экземплярное для дистиллятора - какой чат, куда файл.""" + day = ctx.day.isoformat() + if not ctx.memory: + return DISTILL_NO_MEMORY.format(chat=ctx.chat_name, reason=ctx.reason, day=day) + path = DIGESTS / f"{day} - {slugify(ctx.chat_name, maxlen=60)}.md" + return DISTILL.format(chat=ctx.chat_name, reason=ctx.reason, day=day, path=path) + + texts = ConversationTexts( merge_prompt=_read(GRANULES / "слив.md") or ConversationTexts().merge_prompt, seed=seed_body, handout=handout_prompt, new_day=NEW_DAY, + distill=distill_prompt, ) # §4.6: конверт - полный дифф только у дневника за сегодня, остальное именами; @@ -432,6 +482,43 @@ async def deploy(run: JobRun) -> None: run.background(komodo_deploy()) +async def close_idle(run: JobRun) -> None: + """§4.5, §8.4: ночью, после ротации - глубокие без активности 2 дня, ≤ 3.""" + for result in await run.close_idle(kind="deep", days=2, limit=3, since=LAUNCH): + _log.info( + "closed deep chat %s: digest=%s error=%s", + result.conversation.external_id, + result.digest.path if result.digest else None, + result.error, + ) + + +MEMORY = ( + "Воскресенье, {day}. Перепиши `{state}` из последних хендаутов: {handouts}. " + "`## сейчас` - только невыводимое из vault, `## мои хвосты` - capability " + "requests в работе и обещания длиннее дня. Меньше {max_lines} строк." +) + + +async def memory(run: JobRun) -> None: + """§4.5, §6.2: вс 04:30 - состояние.md из 7 хендаутов, потолок держит gateway.""" + handouts = sorted(DAYS.glob("????-??-??.md"))[-7:] if DAYS.exists() else [] + if not handouts: + _log.info("memory: no handouts yet, nothing to rewrite") + return + await run.spawn_job( + agent="beaver-distiller", + title="память", + text=MEMORY.format( + day=_today().isoformat(), + state=STATE.relative_to(VAULT), + handouts=", ".join(f"`{h.relative_to(VAULT)}`" for h in handouts), + max_lines=STATE_MAX_LINES, + ), + line_cap=LineCap(STATE, max_lines=STATE_MAX_LINES), + ) + + async def komodo_alert(run: JobRun) -> None: payload = run.payload summary = payload.get("raw") or {k: v for k, v in payload.items() if k != "trigger"} @@ -441,6 +528,8 @@ async def komodo_alert(run: JobRun) -> None: jobs = [ Job("ротация", rotate, cron="0 * * * *"), Job("вайбграм", vibegram, cron="*/10 * * * *", critical=False), + Job("закрытие", close_idle, cron="20 4 * * *", critical=False), + Job("память", memory, cron="30 4 * * 0", critical=False), Job("deploy", deploy, webhook=True), Job("komodo", komodo_alert, webhook=True), ] @@ -454,6 +543,7 @@ gateway = Gateway( rotation=RotationPolicy(tz=TZ), watch=watch, budget=Budget(threshold=0.7), + distiller=Distiller(agent="beaver-distiller", dir=DIGESTS, index=INDEX), tz=TZ, port=62990, public_url=PUBLIC_BASE_URL or None,