790 lines
32 KiB
Python
790 lines
32 KiB
Python
import logging
|
||
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
|
||
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 TelegramFrontend
|
||
from beaver_gateway.mcp.types import HttpMcp, McpServer, McpServerT
|
||
|
||
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,
|
||
requires_skill,
|
||
skill_tracker,
|
||
vault_zones,
|
||
)
|
||
|
||
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"
|
||
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", "триаж")))
|
||
QUICK_PROMPT = assemble((*VOICE, PROFILE, overlay("quick", "быстрый")))
|
||
|
||
# §4.3: наборы скиллов = папки = плагины. Глубокие грузят общие + vault.
|
||
DEEP_SKILLS = (SKILLS / "общие", SKILLS / "vault")
|
||
DISPATCHER_SKILLS = (SKILLS / "общие", SKILLS / "диспетчер", SKILLS / "vault")
|
||
|
||
|
||
# §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 raw_entry in raw.split(","):
|
||
entry = raw_entry.strip()
|
||
if not entry:
|
||
continue
|
||
name, sep, url = entry.partition("=")
|
||
if not sep or not url.strip():
|
||
msg = f"CALENDAR_MCPS entry must be `name=url`, got: {entry!r}"
|
||
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/<repo> 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 ()
|
||
|
||
# §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,
|
||
]
|
||
|
||
# Руки глубокого (§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,
|
||
# §4.4: t3code и вайбграм - руки диспетчера (мастер, ветки), глубоким
|
||
# не даются: наружу и в код ходит только он.
|
||
expose_mcps=(*CLAUDE_MCPS, *t3code_exposed, *vibegram_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 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", effort="high"),
|
||
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(
|
||
"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",
|
||
)
|
||
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}"
|
||
)
|
||
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=("💬 чаты/**", "мета/**", "**/attachments/**", ".obsidian/**"),
|
||
),
|
||
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),
|
||
)
|
||
|
||
|
||
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),
|
||
Job("deploy", deploy, webhook=True),
|
||
# алерты идут пачками (тревога и отбой подряд) - схлопывать нельзя
|
||
Job("komodo", komodo_alert, webhook=True, dedupe=False),
|
||
# события идут подряд (вопрос и завершение) - схлопывать нельзя
|
||
Job("t3code", t3code_event, webhook=True, dedupe=False),
|
||
]
|
||
|
||
gateway = Gateway(
|
||
agents=agents,
|
||
mcps=mcps,
|
||
frontends=frontends,
|
||
texts=texts,
|
||
jobs=jobs,
|
||
# Окно модели - 1M; context rot начинается около половины (эмпирика h по
|
||
# счётчику Claude Code), считаем как он - вход последнего API-вызова.
|
||
rotation=RotationPolicy(tz=TZ, max_context_tokens=500_000),
|
||
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,
|
||
)
|