From 3b2f5057da7fb2ff48fc8a905c0d2f8b9c61170e Mon Sep 17 00:00:00 2001 From: h Date: Wed, 2 Sep 2026 00:24:31 +0200 Subject: [PATCH] refactor: split config.py into the beaver_agent package (vault, prompts, skills, policy, hands, agents, frontends, texts, memory, jobs) --- beaver_agent/__init__.py | 4 + beaver_agent/agents.py | 138 +++ beaver_agent/frontends.py | 60 ++ beaver_agent/hands/__init__.py | 142 +++ beaver_agent/hands/calendars.py | 19 + {mcps => beaver_agent/hands}/homeassistant.py | 2 +- {mcps => beaver_agent/hands}/komodo.py | 2 +- {mcps => beaver_agent/hands}/komodo_alerts.py | 2 +- {mcps => beaver_agent/hands}/vibegram.py | 2 +- beaver_agent/jobs/__init__.py | 25 + beaver_agent/jobs/closing.py | 23 + beaver_agent/jobs/deploy.py | 33 + beaver_agent/jobs/komodo.py | 72 ++ beaver_agent/jobs/memory.py | 66 ++ beaver_agent/jobs/rotation.py | 16 + beaver_agent/jobs/t3code.py | 51 + beaver_agent/jobs/vibegram.py | 53 + beaver_agent/memory/__init__.py | 1 + curator.py => beaver_agent/memory/curator.py | 2 +- beaver_agent/memory/handouts.py | 117 +++ recall.py => beaver_agent/memory/recall.py | 6 +- beaver_agent/memory/watch.py | 65 ++ policy.py => beaver_agent/policy.py | 49 +- beaver_agent/prompts.py | 60 ++ beaver_agent/skills.py | 30 + beaver_agent/texts.py | 185 ++++ beaver_agent/vault.py | 44 + config.py | 927 +----------------- docker-compose.yml | 5 +- mcps/__init__.py | 0 ruff.toml | 1 - scripts/seed_memory.py | 4 +- tests/test_curator.py | 2 +- tests/test_komodo.py | 2 +- tests/test_komodo_alerts.py | 8 +- tests/test_policy.py | 4 +- tests/test_recall.py | 13 +- tests/test_vibegram.py | 2 +- 38 files changed, 1302 insertions(+), 935 deletions(-) create mode 100644 beaver_agent/__init__.py create mode 100644 beaver_agent/agents.py create mode 100644 beaver_agent/frontends.py create mode 100644 beaver_agent/hands/__init__.py create mode 100644 beaver_agent/hands/calendars.py rename {mcps => beaver_agent/hands}/homeassistant.py (98%) rename {mcps => beaver_agent/hands}/komodo.py (99%) rename {mcps => beaver_agent/hands}/komodo_alerts.py (98%) rename {mcps => beaver_agent/hands}/vibegram.py (99%) create mode 100644 beaver_agent/jobs/__init__.py create mode 100644 beaver_agent/jobs/closing.py create mode 100644 beaver_agent/jobs/deploy.py create mode 100644 beaver_agent/jobs/komodo.py create mode 100644 beaver_agent/jobs/memory.py create mode 100644 beaver_agent/jobs/rotation.py create mode 100644 beaver_agent/jobs/t3code.py create mode 100644 beaver_agent/jobs/vibegram.py create mode 100644 beaver_agent/memory/__init__.py rename curator.py => beaver_agent/memory/curator.py (98%) create mode 100644 beaver_agent/memory/handouts.py rename recall.py => beaver_agent/memory/recall.py (98%) create mode 100644 beaver_agent/memory/watch.py rename policy.py => beaver_agent/policy.py (84%) create mode 100644 beaver_agent/prompts.py create mode 100644 beaver_agent/skills.py create mode 100644 beaver_agent/texts.py create mode 100644 beaver_agent/vault.py delete mode 100644 mcps/__init__.py diff --git a/beaver_agent/__init__.py b/beaver_agent/__init__.py new file mode 100644 index 0000000..40e215e --- /dev/null +++ b/beaver_agent/__init__.py @@ -0,0 +1,4 @@ +"""Сетап Бобра поверх beaver-gateway. + +vault, промпты, скиллы, руки, агенты, фронтенды, память, джобы - по модулю на каждое. +""" diff --git a/beaver_agent/agents.py b/beaver_agent/agents.py new file mode 100644 index 0000000..83bb081 --- /dev/null +++ b/beaver_agent/agents.py @@ -0,0 +1,138 @@ +"""Роли и их экземпляры: кто с каким промптом, скиллами, руками и границами.""" + +from __future__ import annotations + +from datetime import date +from pathlib import Path +from typing import TYPE_CHECKING + +from beaver_gateway.agents.claude import ClaudeAgent, ClaudeOptions +from beaver_gateway.agents.raycast import RaycastAgent, RemoteTool, UserPreferences + +from beaver_agent import hands, prompts, skills +from beaver_agent.policy import ( + DEEP_DISALLOWED, + DISPATCHER_DISALLOWED, + DISTILLER_DISALLOWED, + STRICT_ZONES, + TRIAGE_DISALLOWED, + VAULT_POLICY, + forbid_skills, + vault_zones, +) +from beaver_agent.vault import TZ, VAULT + +if TYPE_CHECKING: + from beaver_gateway.agents.base import BaseAgent + + +def dispatcher(name: str, model: str, effort: str) -> ClaudeAgent: + """Мастер и ветки: полный голос, все руки, vault - дом.""" + return ClaudeAgent( + name=name, + model=model, + cwd=VAULT, + prompts=prompts.DISPATCHER, + skill_sets=skills.DISPATCHER, + gateway_tools=("read_conversation", "spawn", "say", "schedule"), + options=ClaudeOptions(effort=effort, disallowed_tools=DISPATCHER_DISALLOWED), + policy=( + *VAULT_POLICY, + forbid_skills(skills.MASTER_FORBIDDEN, kinds=("master",)), + ), + expose_mcps=hands.DISPATCHER, + ) + + +def deep(name: str, model: str, effort: str) -> ClaudeAgent: + """Глубокий чат в файле vault; «ок, обсудили» - тулза close_chat.""" + return ClaudeAgent( + name=name, + model=model, + cwd=VAULT, + prompts=prompts.DEEP, + skill_sets=skills.DEEP, + gateway_tools=("close_chat",), + options=ClaudeOptions(effort=effort, disallowed_tools=DEEP_DISALLOWED), + policy=VAULT_POLICY, + expose_mcps=hands.CLAUDE, + ) + + +def distiller(name: str, model: str, effort: str) -> ClaudeAgent: + """Форк закрытого чата и недельная чистка состояния: Read/Write в мета/бобер.""" + return ClaudeAgent( + name=name, + model=model, + cwd=VAULT, + prompts=prompts.DISTILLER, + options=ClaudeOptions( + effort=effort, + tools=("Read", "Write"), + disallowed_tools=DISTILLER_DISALLOWED, + ), + policy=(vault_zones(STRICT_ZONES),), + ) + + +def curator(name: str, model: str, effort: str) -> ClaudeAgent: + """Куратор памяти: раскладывает новое по наблюдениям, будит мастера через inject.""" + return ClaudeAgent( + name=name, + model=model, + cwd=VAULT, + prompts=prompts.CURATOR, + gateway_tools=("inject",), + options=ClaudeOptions( + effort=effort, + tools=("Read", "Write", "Edit", "Grep", "Glob"), + disallowed_tools=DISTILLER_DISALLOWED, + ), + policy=(vault_zones(STRICT_ZONES),), + ) + + +def triage(name: str, model: str, effort: str) -> ClaudeAgent: + """Дешёвый крон-тёрн без vault: решает, будить ли мастера.""" + return ClaudeAgent( + name=name, + model=model, + cwd=Path("/tmp"), # noqa: S108 + prompts=prompts.TRIAGE, + gateway_tools=("say", "inject"), + options=ClaudeOptions( + effort=effort, tools=(), disallowed_tools=TRIAGE_DISALLOWED + ), + ) + + +def raycast(name: str, model: str, effort: str) -> RaycastAgent: + """Быстрые ответы через Raycast: веб и чтение страниц, vault через obsidian-fs.""" + return RaycastAgent( + name=name, + model=model, + system_prompt=prompts.QUICK, + reasoning_effort=effort, + available_native_tools=(RemoteTool.WEB_SEARCH, RemoteTool.READ_PAGE), + user_preferences=lambda: UserPreferences( + locale="ru-RU", timezone=TZ, current_date=date.today().isoformat() + ), + expose_mcps=hands.RAYCAST, + ) + + +agents: list[BaseAgent] = [ + dispatcher("beaver-dispatcher", "claude-opus-5", "high"), + deep("beaver-opus-high", "claude-opus-5", "high"), + deep("beaver-opus-medium", "claude-opus-5", "medium"), + deep("beaver-opus-xhigh", "claude-opus-5", "xhigh"), + deep("beaver-fable-high", "claude-fable-5-1", "high"), + deep("beaver-fable-medium", "claude-fable-5-1", "medium"), + distiller("beaver-distiller", "claude-opus-5", "medium"), + curator("beaver-curator", "claude-opus-5", "high"), + triage("beaver-triage", "claude-sonnet-5", "low"), + raycast("beaver-gemini-pro-high", "google-gemini-3.1-pro", "high"), + raycast("beaver-gemini-pro-low", "google-gemini-3.1-pro", "low"), + raycast("beaver-gemini-flash-high", "google-gemini-3.5-flash", "high"), + raycast("beaver-gemini-flash-low", "google-gemini-3.5-flash", "low"), +] diff --git a/beaver_agent/frontends.py b/beaver_agent/frontends.py new file mode 100644 index 0000000..459c602 --- /dev/null +++ b/beaver_agent/frontends.py @@ -0,0 +1,60 @@ +"""Окна: личка с ботом для мастера и веток, файлы vault для глубоких, API и админка.""" + +from __future__ import annotations + +import os +from datetime import date +from typing import TYPE_CHECKING + +from beaver_gateway.frontends.admin import AdminFrontend +from beaver_gateway.frontends.anthropic import AnthropicMessagesFrontend +from beaver_gateway.frontends.api import ApiFrontend +from beaver_gateway.frontends.markdown import MarkdownFrontend +from beaver_gateway.frontends.mcp_server import McpServerFrontend +from beaver_gateway.frontends.telegram import Attachments, TelegramFrontend +from beaver_gateway.frontends.turn_record import slugify + +from beaver_agent import texts +from beaver_agent.vault import ATTACHMENTS, BEAVER, CHATS, TZ + +if TYPE_CHECKING: + from pathlib import Path + + from beaver_gateway.frontends.base import Frontend + +DISPATCHER = "beaver-dispatcher" +DEEP = "beaver-opus-high" + + +def chat_path(title: str, agent: str, vault: Path) -> Path: # noqa: ARG001 + """Файл глубокого чата: помесячная папка, дата и тема.""" + today = date.today() + return ( + vault / f"{today:%Y-%m}" / f"{today:%Y-%m-%d} - {slugify(title, maxlen=60)}.md" + ) + + +telegram = ( + TelegramFrontend( + token=os.environ["TELEGRAM_BOT_TOKEN"], + user_id=int(os.environ["TELEGRAM_USER_ID"]), + master_agent=DISPATCHER, + branch_agent=DISPATCHER, + attachments=Attachments(mode="vault", dir=ATTACHMENTS, keep_days=30, tz=TZ), + texts=texts.TELEGRAM, + ) + if os.environ.get("TELEGRAM_BOT_TOKEN") + else None +) +"""Вложения - входящая папка по дням в зоне агента; нужное он переносит сам.""" + +frontends: list[Frontend] = [ + *([telegram] if telegram is not None else []), + AnthropicMessagesFrontend(), + McpServerFrontend(), + AdminFrontend(), + ApiFrontend(master_agent=DISPATCHER, branch_agent=DISPATCHER, memory_root=BEAVER), + MarkdownFrontend( + vault_path=CHATS, default_agent=DEEP, log_all_chats=True, chat_path=chat_path + ), +] diff --git a/beaver_agent/hands/__init__.py b/beaver_agent/hands/__init__.py new file mode 100644 index 0000000..1dd9848 --- /dev/null +++ b/beaver_agent/hands/__init__.py @@ -0,0 +1,142 @@ +"""Руки: какие MCP есть у сетапа и кому из агентов какая рука выдана. + +Секреты MCP - только через env подпроцесса, никогда argv: процесс модели +видит `ps` всего контейнера. Рука, чей env не задан, просто не появляется. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from beaver_gateway.agents.base import ExposedMcp +from beaver_gateway.mcp.types import McpServer, McpServerT + +from beaver_agent.hands.calendars import calendars +from beaver_agent.hands.homeassistant import HomeAssistant +from beaver_agent.hands.komodo import Komodo +from beaver_agent.hands.vibegram import Vibegram +from beaver_agent.vault import TZ + +env = os.environ.get + +obsidian = McpServer.stdio( + name="obsidian-fs", + command=["bunx", "-y", "@modelcontextprotocol/server-filesystem", "/vault"], + lenient=True, +) +"""Файловые тулзы для тех, у кого нет своих (Raycast); у Claude vault смонтирован.""" + +firefly = ( + McpServer.stdio( + name="firefly", + command=["bunx", "-y", "@firefly-iii-mcp/local", "--preset", "default"], + env={ + "FIREFLY_III_PAT": os.environ["FIREFLY_PAT"], + "FIREFLY_III_BASE_URL": os.environ["FIREFLY_BASE_URL"], + }, + lenient=True, + ) + if env("FIREFLY_PAT") + else None +) +firefly_exposed = (ExposedMcp(name="firefly", deny=("delete_*",)),) if firefly else () + +telegram = ( + McpServer.http(name="telegram", url=os.environ["BEAVERGRAM_MCP"]) + if env("BEAVERGRAM_MCP") + else None +) +"""beavergram: история чатов Бобра в Telegram.""" + +calendar_servers = calendars(env("CALENDAR_MCPS", "")) +calendar_exposed = tuple(ExposedMcp(name=m.name) for m in calendar_servers) + +KOMODO = ( + Komodo( + url=os.environ["KOMODO_URL"], + key=os.environ["KOMODO_KEY"], + secret=os.environ["KOMODO_SECRET"], + ) + if env("KOMODO_URL") + else None +) +"""Флот: перечислимые действия, ключ остаётся в gateway.""" +komodo = McpServer.python_tool(name="komodo", tools=[KOMODO.komodo]) if KOMODO else None +komodo_exposed = (ExposedMcp(name="komodo"),) if KOMODO else () + +HA = ( + HomeAssistant(url=os.environ["HA_URL"], token=os.environ["HA_TOKEN"]) + if env("HA_URL") and env("HA_TOKEN") + else None +) +"""Дом: HA живёт на хосте, из контейнера - host.docker.internal.""" +ha = McpServer.python_tool(name="ha", tools=[HA.ha]) if HA else None +ha_exposed = (ExposedMcp(name="ha"),) if HA else () + +VIBEGRAM_REPO = Path("/vibegram") / env("VIBEGRAM_REPO", "x/room").split("/")[-1] +VIBEGRAM = ( + Vibegram( + hub=env("VIBEGRAM_HUB", "https://vibegram.example.com"), + token=os.environ["VIBEGRAM_TOKEN"], + nick=env("VIBEGRAM_NICK", "beaver-test"), + repo=VIBEGRAM_REPO if VIBEGRAM_REPO.exists() else None, + tz=TZ, + ) + if env("VIBEGRAM_TOKEN") + else None +) +"""Комната агентов: диспетчер сидит в ней сам; клон репы комнаты - том `vibegram`.""" +vibegram = ( + McpServer.python_tool(name="vibegram", tools=[VIBEGRAM.vibegram]) + if VIBEGRAM + else None +) +vibegram_exposed = (ExposedMcp(name="vibegram"),) if VIBEGRAM else () + +t3code = ( + McpServer.http(name="t3code", url=env("T3CODE_MCP", "http://t3code-mcp:8000/mcp")) + if "t3" in env("COMPOSE_PROFILES", "").split(",") + else None +) +"""t3code-mcp - сосед по compose (профиль t3); машины и allowlist в его t3code.toml.""" +t3code_exposed = (ExposedMcp(name="t3code"),) if t3code else () + +msos = ( + McpServer.http(name="msos", url=os.environ["MSOS_MCP"]) if env("MSOS_MCP") else None +) +"""Таскер рабочих проектов (скилл `задачи`).""" +msos_exposed = (ExposedMcp(name="msos"),) if msos else () + +mcps: list[McpServerT] = [ + m + for m in ( + obsidian, + firefly, + telegram, + *calendar_servers, + komodo, + ha, + vibegram, + t3code, + msos, + ) + if m is not None +] + +CLAUDE = ( + *firefly_exposed, + ExposedMcp(name="telegram"), + *calendar_exposed, + *komodo_exposed, + *ha_exposed, +) +"""Руки любого Claude-агента: firefly без delete, телега, календари, флот, дом.""" +DISPATCHER = (*CLAUDE, *t3code_exposed, *vibegram_exposed, *msos_exposed) +"""Диспетчер ещё ходит в код, в комнату агентов и в таскер; глубоким это не даётся.""" +RAYCAST = ( + ExposedMcp(name="obsidian-fs"), + *firefly_exposed, + ExposedMcp(name="telegram"), + *calendar_exposed, +) diff --git a/beaver_agent/hands/calendars.py b/beaver_agent/hands/calendars.py new file mode 100644 index 0000000..b025ad2 --- /dev/null +++ b/beaver_agent/hands/calendars.py @@ -0,0 +1,19 @@ +"""Календари из `CALENDAR_MCPS=name=url,name=url`; в url - приватный фид с токеном.""" + +from __future__ import annotations + +from beaver_gateway.mcp.types import HttpMcp, McpServer + + +def calendars(raw: str) -> list[HttpMcp]: + servers: list[HttpMcp] = [] + for position, raw_entry in enumerate(raw.split(","), start=1): + entry = raw_entry.strip() + if not entry: + continue + name, sep, url = entry.partition("=") + if not sep or not url.strip(): + msg = f"CALENDAR_MCPS: запись #{position} не вида `name=url`" + raise ValueError(msg) + servers.append(McpServer.http(name=f"calendar-{name.strip()}", url=url.strip())) + return servers diff --git a/mcps/homeassistant.py b/beaver_agent/hands/homeassistant.py similarity index 98% rename from mcps/homeassistant.py rename to beaver_agent/hands/homeassistant.py index dbc9c6b..d85eada 100644 --- a/mcps/homeassistant.py +++ b/beaver_agent/hands/homeassistant.py @@ -1,4 +1,4 @@ -"""§4.4: Home Assistant как python_tool с перечислимыми действиями. +"""Home Assistant как python_tool с перечислимыми действиями. Токен HA живёт в gateway и в контекст модели не попадает. Действия: найти сущности, посмотреть состояние, вызвать сервис, перечислить сервисы diff --git a/mcps/komodo.py b/beaver_agent/hands/komodo.py similarity index 99% rename from mcps/komodo.py rename to beaver_agent/hands/komodo.py index d6dc6e4..9a044b8 100644 --- a/mcps/komodo.py +++ b/beaver_agent/hands/komodo.py @@ -1,4 +1,4 @@ -"""§3.7, §4.4: Komodo как python_tool с перечислимыми действиями. +"""Komodo как python_tool с перечислимыми действиями. Ключ живёт в gateway и в контекст модели не попадает. В перечислении нет `Prune*`, `Destroy*`, `DeleteServer` и терминала хоста; `exec` - это diff --git a/mcps/komodo_alerts.py b/beaver_agent/hands/komodo_alerts.py similarity index 98% rename from mcps/komodo_alerts.py rename to beaver_agent/hands/komodo_alerts.py index 74e2804..60fd096 100644 --- a/mcps/komodo_alerts.py +++ b/beaver_agent/hands/komodo_alerts.py @@ -1,4 +1,4 @@ -"""§4.5: алерты Komodo для диспетчера - что доехать до мастера, когда и как. +"""алерты Komodo для диспетчера - что доехать до мастера, когда и как. Вебхук `/hooks/komodo` получает всё, что шлёт Komodo (типы алертов у алертера не перечислены намеренно). Сюда мастеру нужна малая часть, и не diff --git a/mcps/vibegram.py b/beaver_agent/hands/vibegram.py similarity index 99% rename from mcps/vibegram.py rename to beaver_agent/hands/vibegram.py index 20346ed..9c6ae73 100644 --- a/mcps/vibegram.py +++ b/beaver_agent/hands/vibegram.py @@ -1,4 +1,4 @@ -"""§4.4, §8.5, §12.D1: вайбграм - комната агентов на хабе, как python_tool. +"""вайбграм - комната агентов на хабе, как python_tool. Хаб - https://vibegram.example.com, протокол - REST с bearer-токеном агента (выдан при `vibegram join`, лежит в env gateway и в контекст модели не diff --git a/beaver_agent/jobs/__init__.py b/beaver_agent/jobs/__init__.py new file mode 100644 index 0000000..36d71a8 --- /dev/null +++ b/beaver_agent/jobs/__init__.py @@ -0,0 +1,25 @@ +"""Джобы: кроны считаются в локальном времени, вебхуки приходят на /hooks/<имя>.""" + +from __future__ import annotations + +from beaver_gateway.jobs.scheduler import Job + +from beaver_agent.jobs.closing import close_idle +from beaver_agent.jobs.deploy import deploy +from beaver_agent.jobs.komodo import komodo_alert +from beaver_agent.jobs.memory import curate, memory +from beaver_agent.jobs.rotation import rotate +from beaver_agent.jobs.t3code import t3code_event +from beaver_agent.jobs.vibegram import vibegram + +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("куратор", curate, cron="0 2,8,12,16,20 * * *", critical=False), + Job("deploy", deploy, webhook=True), + Job("komodo", komodo_alert, webhook=True, dedupe=False), + Job("t3code", t3code_event, webhook=True, dedupe=False), +] +"""Алерты и события t3code идут пачками (тревога и отбой подряд) - их не схлопывают.""" diff --git a/beaver_agent/jobs/closing.py b/beaver_agent/jobs/closing.py new file mode 100644 index 0000000..9f3bc32 --- /dev/null +++ b/beaver_agent/jobs/closing.py @@ -0,0 +1,23 @@ +"""Ночью после ротации: глубокие чаты, тихие два дня, закрываются, не больше трёх.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from beaver_agent.vault import LAUNCH + +if TYPE_CHECKING: + from beaver_gateway.jobs.scheduler import JobRun + +_log = logging.getLogger(__name__) + + +async def close_idle(run: JobRun) -> None: + 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, + ) diff --git a/beaver_agent/jobs/deploy.py b/beaver_agent/jobs/deploy.py new file mode 100644 index 0000000..033e5cf --- /dev/null +++ b/beaver_agent/jobs/deploy.py @@ -0,0 +1,33 @@ +"""`POST /hooks/deploy`: дождаться тишины в мастере и попросить Komodo передеплоить.""" + +from __future__ import annotations + +import logging +import os +from datetime import timedelta +from typing import TYPE_CHECKING + +from beaver_agent.hands import KOMODO + +if TYPE_CHECKING: + from beaver_gateway.jobs.scheduler import JobRun + +_log = logging.getLogger(__name__) + +STACK = os.environ.get("KOMODO_STACK", "beaver-agent") + + +async def deploy(run: JobRun) -> None: + master = await run.master() + if master is not None and await run.conversations.busy(master): + await run.retry_in(timedelta(seconds=60)) + return + run.background(_deploy()) + + +async def _deploy() -> None: + if KOMODO is None: + _log.warning("deploy hook: KOMODO_URL/KEY/SECRET are not set, nothing deployed") + return + receipt = await KOMODO.execute("DeployStack", {"stack": STACK, "services": []}) + _log.info("deploy hook: komodo accepted %s", receipt.get("id")) diff --git a/beaver_agent/jobs/komodo.py b/beaver_agent/jobs/komodo.py new file mode 100644 index 0000000..f452a21 --- /dev/null +++ b/beaver_agent/jobs/komodo.py @@ -0,0 +1,72 @@ +"""`POST /hooks/komodo`: алерты флота - флапы выдерживаются, остальное едет в мастер.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from beaver_agent.hands import KOMODO +from beaver_agent.hands.komodo_alerts import ( + HOLD, + AlertMemory, + describe, + parse, + still_bad, +) + +if TYPE_CHECKING: + from beaver_gateway.jobs.scheduler import JobRun + +_log = logging.getLogger(__name__) + +ALERTS = AlertMemory() +HINT = ( + "Это флот Бобра; ему такие алерты приходят и так (healthbot), тебя будят, " + "когда через 5 минут само не встало. Посмотри komodo(status/logs), почини, " + "если очевидно (restart стека), иначе - коротко скажи Бобру через say, что " + "видишь. Не деплой и не чини «заодно»." +) + + +async def komodo_alert(run: JobRun) -> None: + payload = run.payload + if payload.get("held"): + alert = parse(payload["alert"]) + bad = await still_bad(KOMODO.read, alert) if KOMODO else True + verdict = ALERTS.after_hold(alert, still_bad=bad) + if verdict.action == "drop": + _log.info("komodo alert %s recovered during hold", alert.key) + return + ALERTS.mark_told(alert) + await run.inject_master( + f"Komodo: {describe(alert, held=HOLD)}\n{HINT}", + urgency=verdict.urgency, + origin="komodo", + ) + return + alert = parse(payload) + verdict = ALERTS.verdict(alert) + match verdict.action: + case "drop": + _log.info("komodo alert %s dropped (%s)", alert.key, alert.kind) + case "hold": + await run.scheduler.trigger( + run.job, + { + "held": True, + "alert": {k: v for k, v in payload.items() if k != "trigger"}, + }, + delay=HOLD, + trigger="hold", + ) + case "resolved": + await run.inject_master( + f"Komodo: отбой - {describe(alert)}", urgency="normal", origin="komodo" + ) + case "inject": + ALERTS.mark_told(alert) + await run.inject_master( + f"Komodo: {describe(alert)}\n{HINT}", + urgency=verdict.urgency, + origin="komodo", + ) diff --git a/beaver_agent/jobs/memory.py b/beaver_agent/jobs/memory.py new file mode 100644 index 0000000..24fd40f --- /dev/null +++ b/beaver_agent/jobs/memory.py @@ -0,0 +1,66 @@ +"""Память: воскресная перезапись состояния из хендаутов и куратор по расписанию.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from beaver_gateway.conversations.distill import LineCap + +from beaver_agent.memory.curator import briefing +from beaver_agent.memory.watch import CURATOR_WATCH +from beaver_agent.vault import ( + CURATOR_JOURNAL, + DAYS, + REPLIES, + STATE, + STATE_MAX_LINES, + TZ, + VAULT, + today, +) + +if TYPE_CHECKING: + from beaver_gateway.jobs.scheduler import JobRun + +_log = logging.getLogger(__name__) + +REWRITE_STATE = ( + "Воскресенье, {day}. Перепиши `{state}` из последних хендаутов: {handouts}. " + "`## сейчас` - только невыводимое из vault, `## мои хвосты` - capability " + "requests в работе и обещания длиннее дня. Меньше {max_lines} строк." +) + + +async def memory(run: JobRun) -> None: + """Потолок строк держит 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=REWRITE_STATE.format( + day=today(), + 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 curate(run: JobRun) -> None: + """Крон собирает «что нового», модель раскладывает; пусто - джоб не спавнится.""" + text = briefing( + vault=VAULT, + journal=CURATOR_JOURNAL, + replies_dir=REPLIES, + watched=CURATOR_WATCH, + tz=TZ, + ) + if text is None: + _log.info("curator: nothing new since the last run") + return + await run.spawn_job(agent="beaver-curator", title="куратор", text=text) diff --git a/beaver_agent/jobs/rotation.py b/beaver_agent/jobs/rotation.py new file mode 100644 index 0000000..aec38b5 --- /dev/null +++ b/beaver_agent/jobs/rotation.py @@ -0,0 +1,16 @@ +"""Раз в час: пора ли сменить мастера (ночь, возраст, размер контекста).""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from beaver_gateway.jobs.scheduler import JobRun + +_log = logging.getLogger(__name__) + + +async def rotate(run: JobRun) -> None: + for master in await run.rotate(): + _log.info("rotated master -> %s", master.external_id) diff --git a/beaver_agent/jobs/t3code.py b/beaver_agent/jobs/t3code.py new file mode 100644 index 0000000..d76140f --- /dev/null +++ b/beaver_agent/jobs/t3code.py @@ -0,0 +1,51 @@ +"""`POST /hooks/t3code`: тред завершился, упал или спрашивает - срочно в мастер.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from beaver_gateway.jobs.scheduler import JobRun + +QUESTION_HINT = ( + "Если ответ следует из задания - ответь сам: t3_answer(thread_id, " + "{id вопроса: label}). Не знаешь - спроси Бобра через say своими словами, " + "потом t3_answer. Тред стоит, пока не ответят." +) + + +async def t3code_event(run: JobRun) -> None: + await run.inject_master(describe(run.payload), urgency="urgent", origin="t3code") + + +def describe(p: dict[str, Any]) -> str: + head = ( + f"t3code: тред «{p.get('title')}» ({p.get('machine')}, {p.get('project')}, " + f"thread_id {p.get('thread_id')})" + ) + event = p.get("event") + if event in ("question", "approval"): + lines = [f"{head} - спрашивает."] + for q in (p.get("question") or {}).get("questions", []): + multi = " (можно несколько)" if q.get("multi_select") else "" + lines.append( + f"- [{q['id']}] {q.get('header') or ''}: {q['question']}{multi}" + ) + lines.extend( + f" · {o['label']}" + + (f" - {o['description']}" if o.get("description") else "") + for o in q.get("options", []) + ) + lines.append(QUESTION_HINT) + return "\n".join(lines) + if event == "completed": + lines = [f"{head} - завершён."] + elif event == "interrupted": + lines = [f"{head} - прерван."] + else: + lines = [f"{head} - упал: {p.get('error') or 'без текста ошибки'}."] + if p.get("text"): + lines.append(f"Ответ треда:\n{p['text']}") + if p.get("files"): + lines.append("Файлы: " + ", ".join(f["path"] for f in p["files"])) + return "\n".join(lines) diff --git a/beaver_agent/jobs/vibegram.py b/beaver_agent/jobs/vibegram.py new file mode 100644 index 0000000..8dcdc0b --- /dev/null +++ b/beaver_agent/jobs/vibegram.py @@ -0,0 +1,53 @@ +"""Раз в 10 минут: новое в комнате → триаж → мастер, не чаще раза в час.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING + +from beaver_agent.hands import VIBEGRAM, VIBEGRAM_REPO + +if TYPE_CHECKING: + from beaver_gateway.jobs.scheduler import JobRun + +BRIEF = ( + "Новое в вайбграме (комната {room}, ты там {nick}; остальные - чужие агенты):\n" + "{items}\n" + 'Будить мастера - inject(conversation="master", urgency="wake", ' + "text=резюме до 3 строк: кто, что, чего ждёт). Мастер прочитает подробности " + "через vibegram(read) и ответит через vibegram(send), если решит. " + "Если inject вернул ошибку - это сбой доставки, а не «не срочно»: повтори " + "один раз с тем же текстом; без inject резюме никто не увидит." +) + +_last_wake: datetime | None = None +_backlog: list[str] = [] + + +async def vibegram(run: JobRun) -> None: + global _last_wake # noqa: PLW0603 + if VIBEGRAM is None: + return + events = await VIBEGRAM.pending() + _backlog.extend(e.line() for e in events) + if not _backlog: + return + now = datetime.now(UTC) + addressed = VIBEGRAM.mentioned(events) + if ( + not addressed + and _last_wake is not None + and now - _last_wake < timedelta(hours=1) + ): + return + _last_wake = now + items, _backlog[:] = list(_backlog), [] + await run.spawn_job( + agent="beaver-triage", + title="вайбграм", + text=BRIEF.format( + room=VIBEGRAM_REPO.name, + nick=VIBEGRAM.nick, + items="\n".join(f"- {item}" for item in items[-40:]), + ), + ) diff --git a/beaver_agent/memory/__init__.py b/beaver_agent/memory/__init__.py new file mode 100644 index 0000000..31453ea --- /dev/null +++ b/beaver_agent/memory/__init__.py @@ -0,0 +1 @@ +"""Память Бобра в vault: справка, лог реплик, хендауты, вотчер, куратор.""" diff --git a/curator.py b/beaver_agent/memory/curator.py similarity index 98% rename from curator.py rename to beaver_agent/memory/curator.py index 013770e..89b6798 100644 --- a/curator.py +++ b/beaver_agent/memory/curator.py @@ -1,4 +1,4 @@ -"""Куратор памяти: что нового с прошлого прогона (архитектура §4.5, §6.0). +"""Куратор памяти: что нового с прошлого прогона . Крон дёргает детерминированный код, модель судит: здесь собирается брифинг для служебного тёрна `beaver-curator` - новые реплики Бобра целиком и имена diff --git a/beaver_agent/memory/handouts.py b/beaver_agent/memory/handouts.py new file mode 100644 index 0000000..7a9f18c --- /dev/null +++ b/beaver_agent/memory/handouts.py @@ -0,0 +1,117 @@ +"""Смена мастера и закрытие чата: утренний сид, хендаут, «новый день», дистиллятор.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from beaver_gateway.frontends.turn_record import slugify + +from beaver_agent.memory.recall import notes_head +from beaver_agent.vault import ( + DAYS, + DIARY, + DIGESTS, + GRANULES, + PORTRAIT, + STATE, + VAULT, + read, +) + +if TYPE_CHECKING: + from pathlib import Path + + from beaver_gateway.conversations.distill import DistillContext + from beaver_gateway.conversations.rotation import HandoutContext + from beaver_gateway.conversations.texts import NewDayContext, SeedContext + + +def latest_handout() -> Path | None: + """Последний хендаут, а не «вчерашний по календарю».""" + files = sorted(DAYS.glob("????-??-??.md")) if DAYS.exists() else [] + return files[-1] if files else None + + +def seed_body(ctx: SeedContext) -> str | None: + """Утренний сид: последний хендаут, шапка состояния, портрет Бобра.""" + if ctx.seed != "morning": + return None + handout = latest_handout() + if handout is None: + return "Хендаут не приехал." + parts = [ + f"Хендаут ({handout.stem}):\n\n{handout.read_text(encoding='utf-8').strip()}" + ] + signal = "\n".join((read(STATE) or "").splitlines()[:5]) + if signal: + parts.append( + f"Состояние (первые строки, полностью в {STATE.relative_to(VAULT)}):\n" + f"{signal}" + ) + portrait = notes_head(PORTRAIT, ("сейчас", "паттерны"), cap=40) + if portrait: + parts.append( + "Портрет Бобра (шапка " + f"`{PORTRAIT.relative_to(VAULT)}`, лента - там же):\n{portrait}" + ) + return "\n\n".join(parts) + + +DEFAULT_HANDOUT = ( + "Этот мастер закрывается ({reason}). Напиши хендаут за {day} в " + "`мета/бобер/дни/{day}.md`: справку на утро, не задание." +) + + +def handout_prompt(ctx: HandoutContext) -> str: + day = ctx.day.isoformat() + body = ( + (read(GRANULES / "хендаут.md") or DEFAULT_HANDOUT) + .replace("{day}", day) + .replace("{reason}", ctx.reason) + ) + if (DIARY / f"{day}.md").exists(): + return f"{body}\n\nДневник за {day} приехал: `📅 дни/{day}.md`, возьми из него." + return f"{body}\n\nДневник за {day} не приехал - так и напиши в разделе «дневник»." + + +NEW_DAY = ( + "Новый день ({day}). Ты - новый мастер того же треда: хендаут и сигнал состояния " + "в сиде выше. Скажи Бобру что-то через say, только если есть что сказать." +) +SAME_DAY = ( + "Мастер пересоздан ({why}), день тот же - {day}. Хендаут в сиде выше - справка от " + "предыдущего мастера за сегодня, не утро: продолжай с того места, ничего не " + "приветствуй. Скажи Бобру что-то через say, только если есть что сказать." +) +ROTATION_WHY = { + "age": "прошло больше 36 часов", + "context": "контекст стал слишком большим", +} + + +def new_day(ctx: NewDayContext) -> str: + """«Новый день» - только ночная ротация; остальные причины - тот же день.""" + day = ctx.day.isoformat() + if ctx.reason == "night": + return NEW_DAY.format(day=day) + return SAME_DAY.format(day=day, why=ROTATION_WHY.get(ctx.reason, ctx.reason)) + + +DISTILL = ( + "Глубокий чат [[{chat}]] закрыт ({reason}), сегодня {day}. Выжимка - `Write` в " + '`{path}`, фронтматтер `source: "[[{chat}]]"`, `date: {day}`; слив - текст ' + "ответа, до 5 строк, третье лицо." +) +DISTILL_NO_MEMORY = ( + "Глубокий чат [[{chat}]] закрыт ({reason}), сегодня {day}. Память для него " + "выключена: файл не пиши, только слив текстом ответа - до 5 строк, третье лицо." +) + + +def distill_prompt(ctx: DistillContext) -> str: + 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) diff --git a/recall.py b/beaver_agent/memory/recall.py similarity index 98% rename from recall.py rename to beaver_agent/memory/recall.py index e59ef89..81085ac 100644 --- a/recall.py +++ b/beaver_agent/memory/recall.py @@ -1,4 +1,4 @@ -"""Справка под сообщением Бобра и лог его реплик (архитектура §3.3, инвариант 8). +"""Справка под сообщением Бобра и лог его реплик . Gateway путей не знает; здесь - что искать по тексту сообщения и куда писать сказанное. ``Recall.block`` отдаёт указатели, не выводы: карточка упомянутого @@ -21,8 +21,8 @@ from zoneinfo import ZoneInfo if TYPE_CHECKING: from pathlib import Path - from beaver_gateway.core.conversations import UserSaid - from beaver_gateway.core.envelope import RecallContext + from beaver_gateway.conversations.envelope import RecallContext + from beaver_gateway.conversations.texts import UserSaid LABEL = "справка (указатели по сообщению, не выводы; открой, если относится к вопросу):" VOWELS = "аеёиоуыэюя" diff --git a/beaver_agent/memory/watch.py b/beaver_agent/memory/watch.py new file mode 100644 index 0000000..de03c05 --- /dev/null +++ b/beaver_agent/memory/watch.py @@ -0,0 +1,65 @@ +"""Что из vault попадает в конверт и за чем следит куратор.""" + +from __future__ import annotations + +from beaver_gateway.vault.watch import VaultWatch, WatchRules + +from beaver_agent.memory.curator import Watched +from beaver_agent.memory.recall import Recall, ReplyLog +from beaver_agent.vault import ( + BEAVER, + BOARDS, + DAYS, + DIARY, + DIGESTS, + NOTES, + PEOPLE, + REPLIES, + TZ, + VAULT, +) + +watch = VaultWatch( + VAULT, + WatchRules( + full=("📅 дни/{today}.md",), + names=( + "📅 дни/**", + "👤 люди/**", + "📆 планирование/**", + "📆 доски/**", + "💻 проекты/**", + "🧠 мысли/**", + "📶 ресерчи/**", + "мета/бобер/**", + ), + ignore=( + "💬 чаты/**", + "мета/**", + "мета/бобер/реплики/**", + "**/attachments/**", + ".obsidian/**", + ), + ), + tz=TZ, +) +"""Дневник за сегодня - строками, остальное именами; конкретный паттерн побеждает.""" + +recall = Recall( + vault=VAULT, + people_dir=PEOPLE, + notes_dir=NOTES, + diary_dir=DIARY, + boards_dir=BOARDS, + tz=TZ, +) +replies = ReplyLog(REPLIES, tz=TZ) + +CURATOR_WATCH = [ + Watched("хендауты", DAYS, "????-??-??.md"), + Watched("выжимки", DIGESTS, "*.md"), + Watched("наблюдения", NOTES, "*.md"), + Watched("состояние", BEAVER, "состояние.md"), + Watched("дневник", DIARY, "????-??-??.md"), + Watched("карточки", PEOPLE, "**/*.md"), +] diff --git a/policy.py b/beaver_agent/policy.py similarity index 84% rename from policy.py rename to beaver_agent/policy.py index 9d33675..0001460 100644 --- a/policy.py +++ b/beaver_agent/policy.py @@ -1,10 +1,8 @@ -"""§3.7: правила PreToolUse - граница без permission-промптов. +"""Граница без permission-промптов: правила PreToolUse и зоны vault. -Модель везде в `bypassPermissions`; что ей можно, решают маунты, -`disallowed_tools` и правила отсюда. Правило получает `ToolCall` и -возвращает `Deny` с причиной, которую модель читает как результат тулзы, -или `None`. Маунт `/vault:ro` - первая линия, хук - вторая: он ловит то, -что маунт не различает (зоны внутри одного тома, порядок «скилл - тулза»). +Модель везде в `bypassPermissions`; что ей можно, решают `disallowed_tools` +и правила отсюда. Правило получает `ToolCall` и возвращает `Deny` с +причиной, которую модель читает как результат тулзы, или `None`. """ from __future__ import annotations @@ -14,7 +12,9 @@ import shlex from dataclasses import dataclass from typing import TYPE_CHECKING -from beaver_gateway.core.policy import Deny, PolicyRule, ToolCall +from beaver_gateway.agents.policy import Deny, PolicyRule, ToolCall + +from beaver_agent.vault import BEAVER, CHATS, VAULT if TYPE_CHECKING: from collections.abc import Iterator @@ -24,7 +24,10 @@ __all__ = [ "DEEP_DISALLOWED", "DISPATCHER_DISALLOWED", "DISTILLER_DISALLOWED", + "STRICT_ZONES", "TRIAGE_DISALLOWED", + "VAULT_POLICY", + "ZONES", "Zones", "bash_zones", "forbid_skills", @@ -33,8 +36,6 @@ __all__ = [ "vault_zones", ] -# §3.7: у диспетчера AskUserQuestion остаётся, планов и ноутбуков нет; -# у глубоких вопросы текстом, сабагентов нет; дистиллятор и триаж - без веба. DISPATCHER_DISALLOWED = ("ExitPlanMode", "EnterPlanMode", "NotebookEdit") DEEP_DISALLOWED = ( "AskUserQuestion", @@ -81,9 +82,7 @@ class Zones: ``write`` - всё, включая удаление; ``create`` - только новые файлы; ``edit`` - создавать, править, переносить можно, удалять - нет; ``protected`` - не трогать. Приоритет: write > protected > create > edit; - путь в vault вне всех зон - отказ. Vault целиком в ``edit`` - это - «дом» диспетчера (решение h, 2026-08-30): заметки, задачи, люди - его; - удаление чужого и `.obsidian`/`мета` остаются за Бобром. + путь в vault вне всех зон - отказ. """ vault: Path @@ -240,7 +239,7 @@ def skill_tracker() -> PolicyRule: def forbid_skills(names: tuple[str, ...], kinds: tuple[str, ...]) -> PolicyRule: - """§4.3: скиллы из ``names`` (fnmatch, без учёта регистра) в ``kinds`` закрыты. + """Скиллы из ``names`` (fnmatch, без учёта регистра) в ``kinds`` закрыты. Мастер их не открывает - для этого есть ветка; правило отвечает так, чтобы модель сама её открыла. @@ -259,7 +258,7 @@ def forbid_skills(names: tuple[str, ...], kinds: tuple[str, ...]) -> PolicyRule: def requires_skill(skill: str, tools: tuple[str, ...]) -> PolicyRule: - """§4.3: жёсткий протокол - тулзы из ``tools`` только после `Skill(skill)`.""" + """Тулзы из ``tools`` - только после `Skill(skill)` в той же сессии.""" def rule(call: ToolCall) -> Deny | None: if not any(fnmatch.fnmatchcase(call.tool, pattern) for pattern in tools): @@ -271,3 +270,25 @@ def requires_skill(skill: str, tools: tuple[str, ...]) -> PolicyRule: ) return rule + + +ZONES = Zones( + vault=VAULT, + write=(BEAVER,), + create=(CHATS,), + edit=(VAULT,), + protected=(VAULT / ".obsidian", VAULT / "мета"), +) +"""Vault - дом диспетчера: создавать, править, переносить можно везде, кроме +`.obsidian` и `мета` вне `бобер`; удалять - только своё и новые чаты.""" + +STRICT_ZONES = Zones(vault=VAULT, write=(BEAVER,), create=(CHATS,)) +"""Дистиллятор и куратор: писать только в своей зоне.""" + +FIREFLY_WRITES = ("mcp__firefly__store_*", "mcp__firefly__update_*") +VAULT_POLICY = ( + skill_tracker(), + vault_zones(ZONES), + bash_zones(ZONES), + requires_skill("firefly", FIREFLY_WRITES), +) diff --git a/beaver_agent/prompts.py b/beaver_agent/prompts.py new file mode 100644 index 0000000..beb642c --- /dev/null +++ b/beaver_agent/prompts.py @@ -0,0 +1,60 @@ +"""Как собирается системный промпт каждой роли из гранул vault. + +Гранула - чистый markdown; xml-тег, в который она заворачивается, задаётся +здесь. Голос идёт первым и одинаков у всех, кто говорит с Бобром; окружение +зависит от вида окна (мастер, ветка, глубокий, джоб); оверлей - от роли. +""" + +from __future__ import annotations + +from beaver_gateway.agents.claude import Prompts +from beaver_gateway.agents.prompts import assemble + +from beaver_agent.vault import GRANULES, PROMPTS + +VOICE = tuple( + (tag, GRANULES / "голос" / f"{tag}.md") + for tag in ( + "role", + "philosophy", + "user_profile", + "operating_modes", + "how_you_operate", + "interaction_guidelines", + ) +) +PROFILE = ("profile", PROMPTS / "профиль.md") +CORRECTIONS = ("corrections", PROMPTS / "поправки.md") +VAULT_MAP = ("vault_map", GRANULES / "карта-vault.md") + + +def environment(window: str) -> tuple[str, str]: + return ("environment", str(GRANULES / "окружения" / f"{window}.md")) + + +def overlay(tag: str, name: str) -> tuple[str, str]: + return (tag, str(GRANULES / f"{name}.md")) + + +BASE = (*VOICE, PROFILE, CORRECTIONS, VAULT_MAP) +"""Голос, профиль Бобра, его поправки и карта vault - у всех, кто с ним говорит.""" + +DISTILL_AS_JOB = (*BASE, environment("джоб"), overlay("distiller", "дистиллятор")) +"""Форк ветки пишет слив: тот же голос, окружение джоба, оверлей дистиллятора.""" + +DISPATCHER = Prompts( + master=(*BASE, environment("мастер"), overlay("dispatcher", "диспетчер")), + branch=(*BASE, environment("ветка"), overlay("branch", "ветка")), + fork=DISTILL_AS_JOB, + job=DISTILL_AS_JOB, +) +DEEP = Prompts(deep=(*BASE, environment("глубокий"), overlay("deep", "глубокий"))) + +DISTILLER_ONLY = (PROFILE, environment("джоб"), overlay("distiller", "дистиллятор")) +"""Дистиллятор без голоса - иначе выжимки получаются с матом.""" +DISTILLER = Prompts(fork=DISTILLER_ONLY, job=DISTILLER_ONLY) +TRIAGE = Prompts(job=(PROFILE, environment("джоб"), overlay("triage", "триаж"))) +CURATOR = Prompts(job=(*BASE, environment("джоб"), overlay("curator", "куратор"))) + +QUICK = assemble((*VOICE, PROFILE, overlay("quick", "быстрый"))) +"""Raycast-агенты: голос и профиль одной строкой, без vault.""" diff --git a/beaver_agent/skills.py b/beaver_agent/skills.py new file mode 100644 index 0000000..41cbf82 --- /dev/null +++ b/beaver_agent/skills.py @@ -0,0 +1,30 @@ +"""Наборы скиллов по окнам: папка `мета/бобер/скиллы/<набор>` = плагин. + +Мастер не получает `vault/` (firefly, Дерден): для этого есть ветка и +глубокий чат. Форк и джоб диспетчера скиллов не грузят вовсе. +""" + +from __future__ import annotations + +from beaver_gateway.agents.claude import SkillSets + +from beaver_agent.vault import SKILLS + +COMMON = SKILLS / "общие" +DISPATCHER_ONLY = SKILLS / "диспетчер" +VAULT_ONLY = SKILLS / "vault" + +DISPATCHER = SkillSets( + master=(COMMON, DISPATCHER_ONLY), + branch=(COMMON, DISPATCHER_ONLY, VAULT_ONLY), + fork=(), + job=(), +) +DEEP = (COMMON, VAULT_ONLY) + +MASTER_FORBIDDEN = ( + tuple(p.name for p in sorted(VAULT_ONLY.iterdir()) if p.is_dir()) + if VAULT_ONLY.exists() + else () +) +"""Без набора скилл и так не найдётся; правило нужно ради внятного отказа.""" diff --git a/beaver_agent/texts.py b/beaver_agent/texts.py new file mode 100644 index 0000000..adaf2e5 --- /dev/null +++ b/beaver_agent/texts.py @@ -0,0 +1,185 @@ +"""Всё, что gateway говорит модели и Бобру, по-русски.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from beaver_gateway.conversations.texts import ConversationTexts, EnvelopeTexts +from beaver_gateway.frontends.telegram import TelegramTexts + +from beaver_agent.memory.handouts import ( + distill_prompt, + handout_prompt, + new_day, + seed_body, +) +from beaver_agent.vault import GRANULES, read + +if TYPE_CHECKING: + from beaver_gateway.conversations.injects import InjectContext + +BEAVER_ORIGINS = frozenset({"panel", "api"}) +"""Панель и API - это сам Бобёр; крон, watch, komodo, ротация - нет.""" + +ORIGINS = { + "merge": "слив", + "digest": "выжимка", + "rotation": "ротация", + "cap": "потолок", + "system": "система", + "agent": "агент", +} + +INTERRUPTED_TURN = ( + "[этот инжект прервал предыдущий тёрн: «Request interrupted» выше - " + "прерывание, не отказ от тулзы]" +) + + +def inject_header(ctx: InjectContext) -> str: + if ctx.origin in BEAVER_ORIGINS: + head = ( + "[инжект из панели: это Бобёр, ответ он видит в панели; " + "в телегу - только через say]" + ) + else: + origin = ORIGINS.get(ctx.origin, ctx.origin) + head = ( + f"[инжект: {origin} - это не Бобёр, отвечать не нужно, голос не обязателен]" + ) + return f"{head}\n{INTERRUPTED_TURN}" if ctx.interrupted_turn else head + + +ENVELOPE = EnvelopeTexts( + header=( + "[конверт - фоновый сигнал, не обращение; " + "реагируй, только если относится к вопросу]" + ), + time="время: {stamp} ({zone})", + changed="vault, изменено {since}: {names}", + since="с {time}", + since_start="со старта", + truncated="… (потолок конверта)", + file_header="--- {path}, только добавленное ---", + more_lines="+ … ещё {count}", +) + +MERGE_PROMPT = read(GRANULES / "слив.md") or ( + "Ветка закрывается. Напиши слив для мастера: что решили, что сделано, " + "что не сделано и почему, открытые вопросы. Идентификаторы и ссылки - " + "дословно. Коротко, прошедшее время." +) + +CONVERSATIONS = ConversationTexts( + merge_prompt=MERGE_PROMPT, + inject_header=inject_header, + bundle_header="[инжекты, накопившиеся с {since}; это не Бобёр]", + interrupted="прервано", + answered="Пользователь ответил: {answer}", + unanswered=( + "Пользователь не ответил за {minutes} мин. Вопрос ему показан текстом; " + "заверши тёрн сейчас, ответ придёт следующим сообщением." + ), + seed=seed_body, + seed_head="[сид: {seed}] {kind}{title}, {stamp}.", + seed_copy="История родителя скопирована ({scope}); продолжай в ней.", + seed_copy_window="последние {window} тёрнов", + seed_copy_all="вся история", + seed_morning_missing="Хендаут не приехал.", + handout=handout_prompt, + new_day=new_day, + moved_injects=" Инжектов переехало из старого мастера: {moved}.", + distill=distill_prompt, + closed="Закрыт глубокий чат [[{chat}]]{digest}.\n{text}", + closed_digest=", выжимка [[{digest}]]", + digest_missing="файл выжимки не появился", + too_long=( + "`{name}`: {lines} строк при потолке {max_lines}. Запись отбита, файл " + "возвращён к прежней версии. Сократи и перепиши." + ), + cut_by_restart="тёрн {turn_id} оборван рестартом gateway", + repaired_tools=( + "; {fixed} незакрытых тул-коллов получили tool_result «{interrupted}»" + ), + envelope=ENVELOPE, +) + +TELEGRAM = TelegramTexts( + commands=( + ("merge", "слить ветку в мастер"), + ("new", "новая ветка: в General - новый топик, в топике - заново на нём"), + ("chat", "открыть глубокий чат в vault: /chat тема"), + ("status", "что с этим разговором"), + ("help", "команды"), + ), + help_head="General - мастер, любой другой топик - ветка.", + branch="ветка", + waiting="⏳ думаю", + thinking="🤔 думаю", + writing="✍️ пишу", + tool_labels={ + "Read": "читаю vault", + "Glob": "ищу файлы", + "Grep": "ищу в vault", + "Edit": "правлю файл", + "Write": "пишу файл", + "MultiEdit": "правлю файлы", + "Bash": "выполняю команду", + "WebSearch": "ищу в сети", + "WebFetch": "читаю страницу", + "Task": "запустил сабагента", + "Agent": "запустил сабагента", + "AskUserQuestion": "спрашиваю", + "TodoWrite": "планирую", + "Skill": "открываю скилл", + "mcp__gateway__spawn": "открываю разговор", + "mcp__gateway__read_conversation": "читаю разговор", + "mcp__gateway__say": "говорю", + "mcp__gateway__schedule": "ставлю напоминание", + "mcp__gateway__inject": "передаю в другой разговор", + }, + attachment_sticker="[вложение: стикер - не поддерживается]", + attachment_animation="[вложение: анимация - не поддерживается]", + attachment_kinds={ + "photo": "фото", + "document": "файл", + "voice": "голосовое", + "audio": "аудио", + "video": "видео", + "video_note": "кружок", + }, + attachment_too_big=( + "[вложение: {kind} {name}, {mb} МБ - больше 20 МБ, Telegram не отдаёт ботам]" + ), + attachment_failed="[вложение: {kind} {name} - не скачалось: {error}]", + attachment_size=", {kb} КБ", + attachment="[вложение: {kind} {path}{size}]", + attachment_kept=( + "[вложение: {kind} {path}{size}; хранится {days} дн., " + "перенеси в vault, если нужно]" + ), + merge_nothing="сливать нечего: это не открытая ветка", + merging="🔀 сливаю в мастер", + merged="✅ слито в мастер", + merge_failed="⚠️ слив не удался: {error}", + new_branch_topic="🌿 ветка «{title}» - в новом топике", + new_branch_here="🌿 новая ветка на этом топике", + chat_usage="/chat <тема>", + chat_failed="не вышло: {error}", + chat_opened="💬 глубокий чат: {where}", + status_unbound="этот топик ни к чему не привязан - напиши, и откроется ветка", + status_line="сессия: {live} · тёрн: {turn} · в очереди: {queued}", + status_live="живая", + status_dead="нет", + status_running="идёт", + status_idle="нет", + status_question="❓ ждёт ответа на вопрос", + status_pool="пул: {sessions} сессий, rss {mb} МБ", + status_outbox="outbox: {pending} в очереди", + turn_failed="⚠️ тёрн упал, смотри логи gateway", + background_failed="⚠️ фоновый тёрн ({origin}) упал, смотри логи gateway", + question_closed="вопрос уже закрыт", + question_timeout="⌛ время вышло - ответь текстом", + done_button="✅ готово", + mirrored="📝 из панели:\n{text}", +) diff --git a/beaver_agent/vault.py b/beaver_agent/vault.py new file mode 100644 index 0000000..b4e9c83 --- /dev/null +++ b/beaver_agent/vault.py @@ -0,0 +1,44 @@ +"""Где что лежит в vault и в какой зоне живёт агент.""" + +from __future__ import annotations + +import os +from datetime import datetime +from pathlib import Path +from zoneinfo import ZoneInfo + +TZ = "Europe/Warsaw" +ZONE = ZoneInfo(TZ) +LAUNCH = datetime(2026, 8, 28, tzinfo=ZONE) +"""Ночное закрытие трогает только чаты с активностью после запуска системы.""" + +VAULT = Path(os.environ.get("BEAVER_VAULT", "/vault")) +"""`/vault` в контейнере; `BEAVER_VAULT` - чтобы собрать конфиг на маке.""" +CHATS = VAULT / "💬 чаты" +DIARY = VAULT / "📅 дни" +PEOPLE = VAULT / "👤 люди" +BOARDS = VAULT / "📆 доски" + +BEAVER = VAULT / "мета" / "бобер" +"""Дом агента: всё, что он ведёт сам, лежит здесь.""" +PROMPTS = BEAVER / "промпты" +GRANULES = PROMPTS / "гранулы" +SKILLS = BEAVER / "скиллы" +DAYS = BEAVER / "дни" +DIGESTS = BEAVER / "выжимки" +INDEX = BEAVER / "индекс.md" +NOTES = BEAVER / "наблюдения" +PORTRAIT = NOTES / "Бобёр - наблюдения.md" +REPLIES = BEAVER / "реплики" +CURATOR_JOURNAL = BEAVER / "куратор.md" +STATE = BEAVER / "состояние.md" +STATE_MAX_LINES = 60 +ATTACHMENTS = BEAVER / "вложения" + + +def today() -> str: + return datetime.now(ZONE).date().isoformat() + + +def read(path: Path) -> str | None: + return path.read_text(encoding="utf-8").strip() if path.exists() else None diff --git a/config.py b/config.py index 047e383..e0cee59 100644 --- a/config.py +++ b/config.py @@ -1,913 +1,46 @@ -import logging +"""Сборка gateway для Бобра. Что где: пакет `beaver_agent/`. + +- vault.py - пути и зона агента; prompts.py - как из гранул собирается промпт роли; +- skills.py - наборы скиллов по окнам; policy.py - что можно трогать в vault; +- hands/ - MCP-руки и кому какая выдана; agents.py - роли и их экземпляры; +- frontends.py - окна; texts.py - всё, что gateway говорит, по-русски; +- memory/ - справка, реплики, хендауты, вотчер, куратор; jobs/ - кроны и вебхуки. +""" + import os -from datetime import UTC, date, datetime, timedelta -from pathlib import Path -from typing import Any -from zoneinfo import ZoneInfo -from beaver_gateway.agents.base import BaseAgent, ExposedMcp -from beaver_gateway.agents.claude import ClaudeAgent, ClaudeOptions, Prompts, SkillSets -from beaver_gateway.agents.raycast import RaycastAgent, RemoteTool, UserPreferences -from beaver_gateway.core.conversations import ( - ConversationTexts, - NewDayContext, - SeedContext, -) -from beaver_gateway.core.distill import DistillContext, Distiller, LineCap -from beaver_gateway.core.injects import INTERRUPTED_TURN, InjectContext -from beaver_gateway.core.prompt import assemble -from beaver_gateway.core.registry import Gateway -from beaver_gateway.core.rotation import HandoutContext, RotationPolicy -from beaver_gateway.core.scheduler import Budget, Job, JobRun -from beaver_gateway.core.turn_record import slugify -from beaver_gateway.core.watch import VaultWatch, WatchRules -from beaver_gateway.frontends.admin import AdminFrontend -from beaver_gateway.frontends.anthropic import AnthropicMessagesFrontend -from beaver_gateway.frontends.api import ApiFrontend -from beaver_gateway.frontends.base import Frontend -from beaver_gateway.frontends.markdown import MarkdownFrontend -from beaver_gateway.frontends.mcp_server import McpServerFrontend -from beaver_gateway.frontends.telegram import Attachments, TelegramFrontend -from beaver_gateway.mcp.types import HttpMcp, McpServer, McpServerT - -from curator import Watched, briefing -from mcps.homeassistant import HomeAssistant -from mcps.komodo import Komodo -from mcps.komodo_alerts import HOLD, AlertMemory, describe, parse, still_bad -from mcps.vibegram import Vibegram -from policy import ( - DEEP_DISALLOWED, - DISPATCHER_DISALLOWED, - DISTILLER_DISALLOWED, - TRIAGE_DISALLOWED, - Zones, - bash_zones, - forbid_skills, - requires_skill, - skill_tracker, - vault_zones, -) -from recall import Recall, ReplyLog, notes_head - -TZ = "Europe/Warsaw" -VAULT = Path("/vault") -CHATS_DIR = VAULT / "💬 чаты" -DIARY = VAULT / "📅 дни" -BEAVER = VAULT / "мета" / "бобер" -PROMPTS = BEAVER / "промпты" -GRANULES = PROMPTS / "гранулы" -SKILLS = BEAVER / "скиллы" -DAYS = BEAVER / "дни" -DIGESTS = BEAVER / "выжимки" -INDEX = BEAVER / "индекс.md" -NOTES = BEAVER / "наблюдения" -REPLIES = BEAVER / "реплики" -CURATOR_JOURNAL = BEAVER / "куратор.md" -PORTRAIT = NOTES / "Бобёр - наблюдения.md" -PEOPLE = VAULT / "👤 люди" -BOARDS = VAULT / "📆 доски" -STATE = BEAVER / "состояние.md" -STATE_MAX_LINES = 60 -# §4.5: ночное закрытие трогает только чаты с активностью после запуска. -LAUNCH = datetime(2026, 8, 28, tzinfo=ZoneInfo(TZ)) - -_log = logging.getLogger("beaver_agent.config") - -# Гранулы - чистый markdown; xml-теги, в которые они заворачиваются, -# живут здесь. Голос - папка, файл на тег, в этом порядке. -VOICE = tuple( - (tag, GRANULES / "голос" / f"{tag}.md") - for tag in ( - "role", - "philosophy", - "user_profile", - "operating_modes", - "how_you_operate", - "interaction_guidelines", - ) -) -PROFILE = ("profile", PROMPTS / "профиль.md") -CORRECTIONS = ("corrections", PROMPTS / "поправки.md") -VAULT_MAP = ("vault_map", GRANULES / "карта-vault.md") - - -def environment(kind: str) -> tuple[str, Path]: - return ("environment", GRANULES / "окружения" / f"{kind}.md") - - -def overlay(tag: str, name: str) -> tuple[str, Path]: - return (tag, GRANULES / f"{name}.md") - - -# §4.2: сборки промптов. Окружение kind'а - константная гранула, экземплярное -# (файл, топик) едет первым сообщением. Какие kind агент обслуживает, следует -# из того, какие промпты у него заданы (§3.12). -_BASE = (*VOICE, PROFILE, CORRECTIONS, VAULT_MAP) -_DISTILL = (*_BASE, environment("джоб"), overlay("distiller", "дистиллятор")) - -DEEP_PROMPTS = Prompts( - deep=(*_BASE, environment("глубокий"), overlay("deep", "глубокий")) -) -# Диспетчер: тот же голос, окружение по kind, оверлей по роли. -# Форк ветки (слив) думает как дистиллятор в окружении джоба. -DISPATCHER_PROMPTS = Prompts( - master=(*_BASE, environment("мастер"), overlay("dispatcher", "диспетчер")), - branch=(*_BASE, environment("ветка"), overlay("branch", "ветка")), - 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", "триаж"))) -# Куратор памяти - тот же менеджер бобрения (голос и всё), в окружении джоба. -CURATOR_PROMPTS = Prompts( - job=(*_BASE, environment("джоб"), overlay("curator", "куратор")) -) -QUICK_PROMPT = assemble((*VOICE, PROFILE, overlay("quick", "быстрый"))) - -# §4.3: наборы скиллов = папки = плагины. Глубокие грузят общие + vault; -# мастер - без vault (firefly, beaver durden - только в ветке и в глубоком); -# форк и джоб диспетчера - без скиллов вовсе. -DEEP_SKILLS = (SKILLS / "общие", SKILLS / "vault") -DISPATCHER_SKILLS = SkillSets( - master=(SKILLS / "общие", SKILLS / "диспетчер"), - branch=(SKILLS / "общие", SKILLS / "диспетчер", SKILLS / "vault"), - fork=(), - job=(), -) -# Мастер не становится Дерденом: без набора скилл и так не найдётся, правило - -# ради внятного отказа («открой ветку»). -MASTER_FORBIDDEN_SKILLS = ( - tuple(p.name for p in sorted((SKILLS / "vault").iterdir()) if p.is_dir()) - if (SKILLS / "vault").exists() - else () -) - - -# §3.7 (решение h, 2026-08-30): vault - дом диспетчера. Создавать, править, -# переносить можно везде, кроме `.obsidian` и `мета` вне `бобер`; удалять - -# только своё (`мета/бобер`) и `💬 чаты`, существующие чаты не трогать. -# Дистиллятор остаётся в строгих зонах. firefly пишет только после скилла. -ZONES = Zones( - vault=VAULT, - write=(BEAVER,), - create=(CHATS_DIR,), - edit=(VAULT,), - protected=(VAULT / ".obsidian", VAULT / "мета"), -) -STRICT_ZONES = Zones(vault=VAULT, write=(BEAVER,), create=(CHATS_DIR,)) -FIREFLY_WRITES = ("mcp__firefly__store_*", "mcp__firefly__update_*") -VAULT_POLICY = ( - skill_tracker(), - vault_zones(ZONES), - bash_zones(ZONES), - requires_skill("firefly", FIREFLY_WRITES), -) - - -def chat_path(title: str, agent: str, vault: Path) -> Path: # noqa: ARG001 - """Новый файл чата в `💬 чаты/`: помесячная папка, дата и тема.""" - today = date.today() - return ( - vault / f"{today:%Y-%m}" / f"{today:%Y-%m-%d} - {slugify(title, maxlen=60)}.md" - ) - - -def _calendar_mcps() -> list[HttpMcp]: - raw = os.environ.get("CALENDAR_MCPS", "").strip() - servers: list[HttpMcp] = [] - for position, raw_entry in enumerate(raw.split(","), start=1): - entry = raw_entry.strip() - if not entry: - continue - name, sep, url = entry.partition("=") - if not sep or not url.strip(): - # Только номер: в url лежит приватный ical-фид с токеном в пути, - # а traceback уезжает в docker logs целиком. - msg = f"CALENDAR_MCPS: запись #{position} не вида `name=url`" - raise ValueError(msg) - servers.append(McpServer.http(name=f"calendar-{name.strip()}", url=url.strip())) - return servers - - -calendar_mcps = _calendar_mcps() -calendar_exposed = tuple(ExposedMcp(name=m.name) for m in calendar_mcps) - -# §4.4: komodo - python_tool с перечислимыми действиями, ключ остаётся в gateway. -KOMODO = ( - Komodo( - url=os.environ["KOMODO_URL"], - key=os.environ["KOMODO_KEY"], - secret=os.environ["KOMODO_SECRET"], - ) - if os.environ.get("KOMODO_URL") - else None -) -komodo_mcps = ( - [McpServer.python_tool(name="komodo", tools=[KOMODO.komodo])] if KOMODO else [] -) -komodo_exposed = (ExposedMcp(name="komodo"),) if KOMODO else () - -# §4.4: Home Assistant - python_tool, токен в gateway. HA живёт на хосте -# (network_mode: host), из контейнера - host.docker.internal. -HA = ( - HomeAssistant(url=os.environ["HA_URL"], token=os.environ["HA_TOKEN"]) - if os.environ.get("HA_URL") and os.environ.get("HA_TOKEN") - else None -) -ha_mcps = [McpServer.python_tool(name="ha", tools=[HA.ha])] if HA else [] -ha_exposed = (ExposedMcp(name="ha"),) if HA else () - -# §8.5, §12.D1: вайбграм - комната агентов; диспетчер сидит в ней сам, но -# через перечислимые действия, токен агента в gateway. Клон репы комнаты - -# `vibegram` volume, /vibegram/ ro, тянет сервис vibegram-repo. -VIBEGRAM_REPO = ( - Path("/vibegram") / os.environ.get("VIBEGRAM_REPO", "x/room").split("/")[-1] -) -VIBEGRAM = ( - Vibegram( - hub=os.environ.get("VIBEGRAM_HUB", "https://vibegram.example.com"), - token=os.environ["VIBEGRAM_TOKEN"], - nick=os.environ.get("VIBEGRAM_NICK", "beaver-test"), - repo=VIBEGRAM_REPO if VIBEGRAM_REPO.exists() else None, - tz=TZ, - ) - if os.environ.get("VIBEGRAM_TOKEN") - else None -) -vibegram_mcps = ( - [McpServer.python_tool(name="vibegram", tools=[VIBEGRAM.vibegram])] - if VIBEGRAM - else [] -) -vibegram_exposed = (ExposedMcp(name="vibegram"),) if VIBEGRAM else () - -# §4.4: msos - таскер рабочих проектов (скилл `задачи`), URL с токеном в env, -# только у диспетчера. -msos_mcps = ( - [McpServer.http(name="msos", url=os.environ["MSOS_MCP"])] - if os.environ.get("MSOS_MCP") - else [] -) -msos_exposed = (ExposedMcp(name="msos"),) if msos_mcps else () - -# §5: t3code-mcp - сосед по compose (профиль t3), машины и allowlist проектов -# в его t3code.toml; токены t3 живут в его env, gateway их не видит. -T3CODE_MCP = os.environ.get("T3CODE_MCP", "http://t3code-mcp:8000/mcp") -t3code_mcps = ( - [McpServer.http(name="t3code", url=T3CODE_MCP)] - if "t3" in os.environ.get("COMPOSE_PROFILES", "").split(",") - else [] -) -t3code_exposed = (ExposedMcp(name="t3code"),) if t3code_mcps else () - -# Секреты MCP - только через env подпроцесса (mcp stdio даёт ему белый список -# + это), никогда argv: процесс модели видит `ps` всего контейнера. -mcps: list[McpServerT] = [ - McpServer.stdio( - name="obsidian-fs", - command=["bunx", "-y", "@modelcontextprotocol/server-filesystem", "/vault"], - lenient=True, - ), - McpServer.stdio( - name="firefly", - command=["bunx", "-y", "@firefly-iii-mcp/local", "--preset", "default"], - env={ - "FIREFLY_III_PAT": os.environ["FIREFLY_PAT"], - "FIREFLY_III_BASE_URL": os.environ["FIREFLY_BASE_URL"], - }, - lenient=True, - ), - McpServer.http(name="telegram", url=os.environ["BEAVERGRAM_MCP"]), - *calendar_mcps, - *komodo_mcps, - *ha_mcps, - *vibegram_mcps, - *t3code_mcps, - *msos_mcps, -] - -# Руки глубокого (§4.4): firefly без delete_*, obsidian-fs не даётся - свои -# файловые тулзы есть, vault смонтирован в /vault. -CLAUDE_MCPS = ( - ExposedMcp(name="firefly", deny=("delete_*",)), - ExposedMcp(name="telegram"), - *calendar_exposed, - *komodo_exposed, - *ha_exposed, -) - - -UserPrefsRu = lambda: UserPreferences( # noqa: E731 - locale="ru-RU", timezone="Europe/Warsaw", current_date=date.today().isoformat() -) - - -def dispatcher(name: str, model: str, effort: str | None = None) -> ClaudeAgent: - return ClaudeAgent( - name=name, - model=model, - cwd=VAULT, - prompts=DISPATCHER_PROMPTS, - skill_sets=DISPATCHER_SKILLS, - gateway_tools=("read_conversation", "spawn", "say", "schedule"), - options=ClaudeOptions(effort=effort, disallowed_tools=DISPATCHER_DISALLOWED), - policy=( - *VAULT_POLICY, - forbid_skills(MASTER_FORBIDDEN_SKILLS, kinds=("master",)), - ), - # §4.4: t3code и вайбграм - руки диспетчера (мастер, ветки), глубоким - # не даются: наружу и в код ходит только он. - expose_mcps=(*CLAUDE_MCPS, *t3code_exposed, *vibegram_exposed, *msos_exposed), - ) - - -def deep(name: str, model: str, effort: str | None = None) -> ClaudeAgent: - return ClaudeAgent( - name=name, - model=model, - cwd=VAULT, - prompts=DEEP_PROMPTS, - skill_sets=DEEP_SKILLS, - # §8.4: «ок, обсудили» - тулза, закрытие после ответа. - gateway_tools=("close_chat",), - options=ClaudeOptions(effort=effort, disallowed_tools=DEEP_DISALLOWED), - policy=VAULT_POLICY, - expose_mcps=CLAUDE_MCPS, - ) - - -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=DISTILLER_DISALLOWED, - ), - policy=(vault_zones(STRICT_ZONES),), - ) - - -def curator(name: str, model: str, effort: str | None = None) -> ClaudeAgent: - """Куратор памяти: opus high, файлы в мета/бобер, без MCP, `inject` в мастер.""" - return ClaudeAgent( - name=name, - model=model, - cwd=VAULT, - prompts=CURATOR_PROMPTS, - gateway_tools=("inject",), - options=ClaudeOptions( - effort=effort, - tools=("Read", "Write", "Edit", "Grep", "Glob"), - disallowed_tools=DISTILLER_DISALLOWED, - ), - policy=(vault_zones(STRICT_ZONES),), - ) - - -def triage(name: str, model: str, effort: str | None = None) -> ClaudeAgent: - """§4.1: дешёвый крон-тёрн - sonnet low, без vault, только say/inject.""" - return ClaudeAgent( - name=name, - model=model, - cwd=Path("/tmp"), # noqa: S108 - не vault: у триажа нет файлов вообще - prompts=TRIAGE_PROMPTS, - gateway_tools=("say", "inject"), - options=ClaudeOptions( - effort=effort, tools=(), disallowed_tools=TRIAGE_DISALLOWED - ), - ) - - -def raycast(name: str, model: str, reasoning_effort: str | None = None) -> RaycastAgent: - return RaycastAgent( - name=name, - model=model, - system_prompt=QUICK_PROMPT, - reasoning_effort=reasoning_effort, - available_native_tools=(RemoteTool.WEB_SEARCH, RemoteTool.READ_PAGE), - user_preferences=UserPrefsRu, - expose_mcps=( - ExposedMcp(name="obsidian-fs"), - ExposedMcp(name="firefly", deny=("delete_*",)), - ExposedMcp(name="telegram"), - *calendar_exposed, - ), - ) - - -agents: list[BaseAgent] = [ - # §12.D4 пересмотрен 2026-09-01: диспетчер на high - бенч показал, что h - # голосует за находки и суждение, а не за тон; medium держал ~$10/день - # по API-прайсу (cost_usd в usage - кумулятив сессии, суммировать нельзя). - dispatcher("beaver-dispatcher", "claude-opus-5", effort="high"), - deep("beaver-opus-high", "claude-opus-5", effort="high"), - raycast("beaver-gemini-pro-high", "google-gemini-3.1-pro", reasoning_effort="high"), - deep("beaver-fable-high", "claude-fable-5-1", effort="high"), - deep("beaver-opus-medium", "claude-opus-5", effort="medium"), - deep("beaver-fable-medium", "claude-fable-5-1", effort="medium"), - deep("beaver-opus-xhigh", "claude-opus-5", effort="xhigh"), - distiller("beaver-distiller", "claude-opus-5", effort="medium"), - curator("beaver-curator", "claude-opus-5", effort="high"), - triage("beaver-triage", "claude-sonnet-5", effort="low"), - raycast("beaver-gemini-pro-low", "google-gemini-3.1-pro", reasoning_effort="low"), - raycast( - "beaver-gemini-flash-high", "google-gemini-3.5-flash", reasoning_effort="high" - ), - raycast( - "beaver-gemini-flash-low", "google-gemini-3.5-flash", reasoning_effort="low" - ), -] - -PUBLIC_BASE_URL = os.environ.get("PUBLIC_BASE_URL", "").rstrip("/") - -# §3.8: личка с ботом - домашний фронтенд мастера и веток (General и топики). -# Токен и id - секреты gateway, в env процесса модели они не попадают. -TELEGRAM = ( - TelegramFrontend( - token=os.environ["TELEGRAM_BOT_TOKEN"], - user_id=int(os.environ["TELEGRAM_USER_ID"]), - master_agent="beaver-dispatcher", - branch_agent="beaver-dispatcher", - # §3.8: вложения - входящая папка в зоне агента, по дням; что стоит - # хранить, он переносит к заметке сам, остальное сметается через месяц. - attachments=Attachments( - mode="vault", dir=BEAVER / "вложения", keep_days=30, tz=TZ - ), - ) - if os.environ.get("TELEGRAM_BOT_TOKEN") - else None -) - -# Один порт: каждый HTTP-фронтенд живёт под своим путём (/anthropic, /mcp, -# /admin, /api, /md), `/` ведёт в админку. Caddy ничего не срезает. -frontends: list[Frontend] = [ - *([TELEGRAM] if TELEGRAM is not None else []), - AnthropicMessagesFrontend(), - McpServerFrontend(), - AdminFrontend(), - # §3.9: /api показывает всё; дефолт master/branch здесь - запасной на случай - # gateway без телеграма, deep - у markdown. - ApiFrontend( - master_agent="beaver-dispatcher", - branch_agent="beaver-dispatcher", - memory_root=BEAVER, - ), - MarkdownFrontend( - vault_path=CHATS_DIR, - default_agent="beaver-opus-high", - log_all_chats=True, - chat_path=chat_path, - ), -] - - -def _read(path: Path) -> str | None: - return path.read_text(encoding="utf-8").strip() if path.exists() else None - - -def _today() -> date: - return datetime.now(UTC).astimezone(ZoneInfo(TZ)).date() - - -def latest_handout() -> Path | None: - """§4.5: сид morning берёт последний хендаут, не «вчерашний по календарю».""" - files = sorted(DAYS.glob("????-??-??.md")) if DAYS.exists() else [] - return files[-1] if files else None - - -def seed_body(ctx: SeedContext) -> str | None: - """§8.2: утренний сид - последний хендаут из `дни/`; остальные сиды - gateway.""" - if ctx.seed != "morning": - return None - handout = latest_handout() - if handout is None: - return "Хендаут не приехал." - state = _read(BEAVER / "состояние.md") or "" - signal = "\n".join(state.splitlines()[:5]) - parts = [ - f"Хендаут ({handout.stem}):\n\n{handout.read_text(encoding='utf-8').strip()}" - ] - if signal: - parts.append( - f"Состояние (первые строки, полностью в мета/бобер/состояние.md):\n{signal}" - ) - portrait = notes_head(PORTRAIT, ("сейчас", "паттерны"), cap=40) - if portrait: - parts.append( - "Портрет Бобра (шапка " - f"`{PORTRAIT.relative_to(VAULT)}`, лента - там же):\n{portrait}" - ) - return "\n\n".join(parts) - - -DEFAULT_HANDOUT = ( - "Этот мастер закрывается ({reason}). Напиши хендаут за {day} в " - "`мета/бобер/дни/{day}.md`: справку на утро, не задание." -) - - -def handout_prompt(ctx: HandoutContext) -> str: - """§6.3, §8.3: последний тёрн закрываемого мастера - гранула + факт про дневник.""" - day = ctx.day.isoformat() - body = ( - (_read(GRANULES / "хендаут.md") or DEFAULT_HANDOUT) - .replace("{day}", day) - .replace("{reason}", ctx.reason) - ) - diary = DIARY / f"{day}.md" - if diary.exists(): - return f"{body}\n\nДневник за {day} приехал: `📅 дни/{day}.md`, возьми из него." - return f"{body}\n\nДневник за {day} не приехал - так и напиши в разделе «дневник»." - - -NEW_DAY = ( - "Новый день ({day}). Ты - новый мастер того же треда: хендаут и сигнал состояния " - "в сиде выше. Скажи Бобру что-то через say, только если есть что сказать." -) -SAME_DAY = ( - "Мастер пересоздан ({why}), день тот же - {day}. Хендаут в сиде выше - справка от " - "предыдущего мастера за сегодня, не утро: продолжай с того места, ничего не " - "приветствуй. Скажи Бобру что-то через say, только если есть что сказать." -) -ROTATION_WHY = { - "возраст": "прошло больше 36 часов", - "транскрипт": "контекст стал слишком большим", -} - - -def new_day(ctx: NewDayContext) -> str: - """§4.5: «новый день» - только ночная ротация; остальные - тот же день.""" - day = ctx.day.isoformat() - if ctx.reason == "ночь": - return NEW_DAY.format(day=day) - return SAME_DAY.format(day=day, why=ROTATION_WHY.get(ctx.reason, ctx.reason)) - - -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) - - -# Панель и API - это сам Бобёр (single-user); остальные origin - крон, watch, -# komodo, ротация - не он. -BEAVER_ORIGINS = frozenset({"panel", "api"}) - - -def inject_header(ctx: InjectContext) -> str: - if ctx.origin in BEAVER_ORIGINS: - head = ( - "[инжект из панели: это Бобёр, ответ он видит в панели; " - "в телегу - только через say]" - ) - else: - head = ( - f"[инжект: {ctx.origin} - это не Бобёр, отвечать не нужно, " - "голос не обязателен]" - ) - return f"{head}\n{INTERRUPTED_TURN}" if ctx.interrupted_turn else head - - -texts = ConversationTexts( - inject_header=inject_header, - merge_prompt=_read(GRANULES / "слив.md") or ConversationTexts().merge_prompt, - seed=seed_body, - handout=handout_prompt, - new_day=new_day, - distill=distill_prompt, -) - -# §4.6: конверт - полный дифф только у дневника за сегодня, остальное именами; -# самый конкретный паттерн побеждает (мета/бобер видно, остальная мета - нет). -watch = VaultWatch( - VAULT, - WatchRules( - full=("📅 дни/{today}.md",), - names=( - "📅 дни/**", - "👤 люди/**", - "📆 планирование/**", - "📆 доски/**", - "💻 проекты/**", - "🧠 мысли/**", - "📶 ресерчи/**", - "мета/бобер/**", - ), - ignore=( - "💬 чаты/**", - "мета/**", - "мета/бобер/реплики/**", # пишет сам gateway на каждое сообщение - "**/attachments/**", - ".obsidian/**", - ), - ), - tz=TZ, -) - -# §3.3: справка под сообщением - карточка и записки агента по упомянутым людям, -# дни, где они встречались, раз в день сроки из досок; лог реплик Бобра - -# единственное grep-абельное место для сказанного в телеге (§6.0 «спящее»). -recall = Recall( - vault=VAULT, - people_dir=PEOPLE, - notes_dir=NOTES, - diary_dir=DIARY, - boards_dir=BOARDS, - tz=TZ, -) -replies = ReplyLog(REPLIES, tz=TZ) - - -# §4.5 джобы. Хендлер только ставит работу в очередь и выходит. -async def rotate(run: JobRun) -> None: - for master in await run.rotate(): - _log.info("rotated master -> %s", master.external_id) - - -_vibegram_last_wake: datetime | None = None -_vibegram_backlog: list[str] = [] - -VIBEGRAM_BRIEF = ( - "Новое в вайбграме (комната {room}, ты там {nick}; остальные - чужие агенты):\n" - "{items}\n" - 'Будить мастера - inject(conversation="master", urgency="wake", ' - "text=резюме до 3 строк: кто, что, чего ждёт). Мастер прочитает подробности " - "через vibegram(read) и ответит через vibegram(send), если решит. " - "Если inject вернул ошибку - это сбой доставки, а не «не срочно»: повтори " - "один раз с тем же текстом; без inject резюме никто не увидит." -) - - -async def vibegram(run: JobRun) -> None: - """§8.5: проверка → triage → в мастер ≤ 1/ч, если не ждут ответа.""" - global _vibegram_last_wake # noqa: PLW0603 - счётчик «≤ 1/ч», живёт до рестарта - if VIBEGRAM is None: - return - events = await VIBEGRAM.pending() - _vibegram_backlog.extend(e.line() for e in events) - if not _vibegram_backlog: - return - now = datetime.now(UTC) - addressed = VIBEGRAM.mentioned(events) - if ( - not addressed - and _vibegram_last_wake is not None - and now - _vibegram_last_wake < timedelta(hours=1) - ): - return - _vibegram_last_wake = now - items, _vibegram_backlog[:] = list(_vibegram_backlog), [] - await run.spawn_job( - agent="beaver-triage", - title="вайбграм", - text=VIBEGRAM_BRIEF.format( - room=VIBEGRAM_REPO.name, - nick=VIBEGRAM.nick, - items="\n".join(f"- {item}" for item in items[-40:]), - ), - ) - - -KOMODO_STACK = os.environ.get("KOMODO_STACK", "beaver-agent") - - -async def komodo_deploy() -> None: - if KOMODO is None: - _log.warning("deploy hook: KOMODO_URL/KEY/SECRET are not set, nothing deployed") - return - receipt = await KOMODO.execute( - "DeployStack", {"stack": KOMODO_STACK, "services": []} - ) - _log.info("deploy hook: komodo accepted %s", receipt.get("id")) - - -async def deploy(run: JobRun) -> None: - """§8.6: деплой ждёт conversation.idle(master) - иначе рестарт посреди тёрна.""" - master = await run.master() - if master is not None and await run.conversations.busy(master): - await run.retry_in(timedelta(seconds=60)) - return - 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), - ) - - -CURATOR_WATCH = [ - Watched("хендауты", DAYS, "????-??-??.md"), - Watched("выжимки", DIGESTS, "*.md"), - Watched("наблюдения", NOTES, "*.md"), - Watched("состояние", BEAVER, "состояние.md"), - Watched("дневник", DIARY, "????-??-??.md"), - Watched("карточки", PEOPLE, "**/*.md"), -] - - -async def curate(run: JobRun) -> None: - """§6.0: куратор памяти раз в несколько часов. - - Крон собирает «что нового», модель раскладывает по шапкам и лентам; - нечего раскладывать - джоб не спавнится. - """ - text = briefing( - vault=VAULT, - journal=CURATOR_JOURNAL, - replies_dir=REPLIES, - watched=CURATOR_WATCH, - tz=TZ, - ) - if text is None: - _log.info("curator: nothing new since the last run") - return - await run.spawn_job(agent="beaver-curator", title="куратор", text=text) - - -T3CODE_QUESTION_HINT = ( - "Если ответ следует из задания - ответь сам: t3_answer(thread_id, " - "{id вопроса: label}). Не знаешь - спроси Бобра через say своими словами, " - "потом t3_answer. Тред стоит, пока не ответят." -) - - -def t3code_text(p: dict[str, Any]) -> str: - head = ( - f"t3code: тред «{p.get('title')}» ({p.get('machine')}, {p.get('project')}, " - f"thread_id {p.get('thread_id')})" - ) - event = p.get("event") - if event in ("question", "approval"): - lines = [f"{head} - спрашивает."] - for q in (p.get("question") or {}).get("questions", []): - multi = " (можно несколько)" if q.get("multi_select") else "" - lines.append( - f"- [{q['id']}] {q.get('header') or ''}: {q['question']}{multi}" - ) - lines.extend( - f" · {o['label']}" - + (f" - {o['description']}" if o.get("description") else "") - for o in q.get("options", []) - ) - lines.append(T3CODE_QUESTION_HINT) - return "\n".join(lines) - if event == "completed": - lines = [f"{head} - завершён."] - elif event == "interrupted": - lines = [f"{head} - прерван."] - else: - lines = [f"{head} - упал: {p.get('error') or 'без текста ошибки'}."] - if p.get("text"): - lines.append(f"Ответ треда:\n{p['text']}") - if p.get("files"): - lines.append("Файлы: " + ", ".join(f["path"] for f in p["files"])) - return "\n".join(lines) - - -async def t3code_event(run: JobRun) -> None: - """§5, §8.6: t3code-mcp следит за тредами по WS и присылает сюда события.""" - await run.inject_master(t3code_text(run.payload), urgency="urgent", origin="t3code") - - -ALERTS = AlertMemory() -KOMODO_HINT = ( - "Это флот Бобра; ему такие алерты приходят и так (healthbot), тебя будят, " - "когда через 5 минут само не встало. Посмотри komodo(status/logs), почини, " - "если очевидно (restart стека), иначе - коротко скажи Бобру через say, что " - "видишь. Не деплой и не чини «заодно»." -) - - -async def komodo_alert(run: JobRun) -> None: - """§4.5 `/hooks/komodo`: флапы выдерживаются HOLD и перепроверяются.""" - payload = run.payload - if payload.get("held"): - alert = parse(payload["alert"]) - bad = await still_bad(KOMODO.read, alert) if KOMODO else True - verdict = ALERTS.after_hold(alert, still_bad=bad) - if verdict.action == "drop": - _log.info("komodo alert %s recovered during hold", alert.key) - return - ALERTS.mark_told(alert) - await run.inject_master( - f"Komodo: {describe(alert, held=HOLD)}\n{KOMODO_HINT}", - urgency=verdict.urgency, - origin="komodo", - ) - return - alert = parse(payload) - verdict = ALERTS.verdict(alert) - match verdict.action: - case "drop": - _log.info("komodo alert %s dropped (%s)", alert.key, alert.kind) - case "hold": - await run.scheduler.trigger( - run.job, - { - "held": True, - "alert": {k: v for k, v in payload.items() if k != "trigger"}, - }, - delay=HOLD, - trigger="hold", - ) - case "resolved": - await run.inject_master( - f"Komodo: отбой - {describe(alert)}", urgency="normal", origin="komodo" - ) - case "inject": - ALERTS.mark_told(alert) - await run.inject_master( - f"Komodo: {describe(alert)}\n{KOMODO_HINT}", - urgency=verdict.urgency, - origin="komodo", - ) - - -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), - # кроны считаются в Gateway.tz (Варшава), не в UTC контейнера; - # руками - POST /api/jobs/куратор/run - Job("куратор", curate, cron="0 2,8,12,16,20 * * *", critical=False), - Job("deploy", deploy, webhook=True), - # алерты идут пачками (тревога и отбой подряд) - схлопывать нельзя - Job("komodo", komodo_alert, webhook=True, dedupe=False), - # события идут подряд (вопрос и завершение) - схлопывать нельзя - Job("t3code", t3code_event, webhook=True, dedupe=False), -] +from beaver_gateway.config import Gateway +from beaver_gateway.conversations.distill import Distiller +from beaver_gateway.conversations.rotation import RotationPolicy +from beaver_gateway.jobs.scheduler import Budget + +from beaver_agent import texts +from beaver_agent.agents import agents +from beaver_agent.frontends import frontends +from beaver_agent.hands import mcps +from beaver_agent.jobs import jobs +from beaver_agent.memory.watch import recall, replies, watch +from beaver_agent.vault import DIGESTS, INDEX, TZ gateway = Gateway( agents=agents, mcps=mcps, frontends=frontends, - texts=texts, + texts=texts.CONVERSATIONS, jobs=jobs, - # Окно модели - 1M; context rot начинается около половины (эмпирика h по - # счётчику Claude Code), считаем как он - вход последнего API-вызова. rotation=RotationPolicy(tz=TZ, max_context_tokens=500_000), watch=watch, recall=recall.block, user_sink=replies.write, budget=Budget(threshold=0.7), - distiller=Distiller(agent="beaver-distiller", dir=DIGESTS, index=INDEX), + distiller=Distiller( + agent="beaver-distiller", + dir=DIGESTS, + index=INDEX, + type="выжимка", + index_header="# индекс\n\nстрока на выжимку: чат → его выжимка.\n", + ), tz=TZ, port=62990, - public_url=PUBLIC_BASE_URL or None, + public_url=os.environ.get("PUBLIC_BASE_URL", "").rstrip("/") or None, ) diff --git a/docker-compose.yml b/docker-compose.yml index 0e0c181..b0dff6b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,10 +59,7 @@ services: - "${PORT_GATEWAY:-62990}:62990" volumes: - ./config.py:/config/config.py:ro - - ./policy.py:/config/policy.py:ro - - ./recall.py:/config/recall.py:ro - - ./curator.py:/config/curator.py:ro - - ./mcps:/config/mcps:ro + - ./beaver_agent:/config/beaver_agent:ro - ./config.json:/config/config.json:ro # §3.7: vault - дом диспетчера, rw целиком; границы (`.obsidian`, # `мета` вне `бобер`, удаление чужого) держит policy.py (2026-08-30). diff --git a/mcps/__init__.py b/mcps/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/ruff.toml b/ruff.toml index cbaca42..b316d3b 100644 --- a/ruff.toml +++ b/ruff.toml @@ -26,7 +26,6 @@ unfixable = ["F401"] [lint.per-file-ignores] "config.py" = ["INP001"] -"policy.py" = ["INP001"] "tests/*" = ["ALL"] [lint.pydocstyle] diff --git a/scripts/seed_memory.py b/scripts/seed_memory.py index 5bcccf2..6fc9e65 100644 --- a/scripts/seed_memory.py +++ b/scripts/seed_memory.py @@ -35,9 +35,9 @@ from pathlib import Path HERE = Path(__file__).resolve().parent sys.path.insert(0, str(HERE.parent)) -from beaver_gateway.core.prompt import assemble # noqa: E402 +from beaver_gateway.agents.prompts import assemble # noqa: E402 -from recall import People # noqa: E402 +from beaver_agent.memory.recall import People # noqa: E402 VAULT = Path.home() / "Documents/obsidian/hh" BEAVER = VAULT / "мета/бобер" diff --git a/tests/test_curator.py b/tests/test_curator.py index be46156..387d416 100644 --- a/tests/test_curator.py +++ b/tests/test_curator.py @@ -1,7 +1,7 @@ from datetime import UTC, datetime, timedelta from pathlib import Path -from curator import Watched, briefing, last_run, new_replies +from beaver_agent.memory.curator import Watched, briefing, last_run, new_replies def test_last_run_reads_first_line_or_default(tmp_path: Path) -> None: diff --git a/tests/test_komodo.py b/tests/test_komodo.py index 6c260d7..b41dceb 100644 --- a/tests/test_komodo.py +++ b/tests/test_komodo.py @@ -4,7 +4,7 @@ from beaver_gateway.mcp.wrap import build_python_tool_server from fastmcp import Client from fastmcp.exceptions import ToolError -from mcps.komodo import Komodo +from beaver_agent.hands.komodo import Komodo @pytest.fixture diff --git a/tests/test_komodo_alerts.py b/tests/test_komodo_alerts.py index f442e66..085bc96 100644 --- a/tests/test_komodo_alerts.py +++ b/tests/test_komodo_alerts.py @@ -1,6 +1,12 @@ from datetime import timedelta -from mcps.komodo_alerts import HOLD, AlertMemory, describe, parse, still_bad +from beaver_agent.hands.komodo_alerts import ( + HOLD, + AlertMemory, + describe, + parse, + still_bad, +) def alert(kind, *, resolved=False, level="CRITICAL", **data): diff --git a/tests/test_policy.py b/tests/test_policy.py index 6ec6f0b..a2c22ea 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -2,9 +2,9 @@ import tempfile from pathlib import Path import pytest -from beaver_gateway.core.policy import Deny, ToolCall +from beaver_gateway.agents.policy import Deny, ToolCall -from policy import ( +from beaver_agent.policy import ( Zones, bash_zones, forbid_skills, diff --git a/tests/test_recall.py b/tests/test_recall.py index 0ce4ce7..7a4f757 100644 --- a/tests/test_recall.py +++ b/tests/test_recall.py @@ -1,10 +1,17 @@ from datetime import UTC, date, datetime from pathlib import Path -from beaver_gateway.core.conversations import UserSaid -from beaver_gateway.core.envelope import RecallContext +from beaver_gateway.conversations.texts import UserSaid +from beaver_gateway.conversations.envelope import RecallContext -from recall import LABEL, Recall, ReplyLog, frontmatter_aliases, mentions, notes_head +from beaver_agent.memory.recall import ( + LABEL, + Recall, + ReplyLog, + frontmatter_aliases, + mentions, + notes_head, +) def _card(path: Path, aliases: list[str] | None = None) -> None: diff --git a/tests/test_vibegram.py b/tests/test_vibegram.py index 7f2012c..2245068 100644 --- a/tests/test_vibegram.py +++ b/tests/test_vibegram.py @@ -4,7 +4,7 @@ from beaver_gateway.mcp.wrap import build_python_tool_server from fastmcp import Client from fastmcp.exceptions import ToolError -from mcps.vibegram import Vibegram, _cards, _event, _work +from beaver_agent.hands.vibegram import Vibegram, _cards, _event, _work def test_events_render_and_unknown_kinds_are_skipped():