import logging import os from datetime import UTC, date, datetime, timedelta from pathlib import Path from zoneinfo import ZoneInfo import aiohttp from beaver_gateway.agents.base import BaseAgent, ExposedMcp from beaver_gateway.agents.claude import ClaudeAgent, ClaudeOptions, Prompts from beaver_gateway.agents.raycast import RaycastAgent, RemoteTool, UserPreferences from beaver_gateway.core.conversations import ConversationTexts, SeedContext from beaver_gateway.core.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 TZ = "Europe/Warsaw" VAULT = Path("/vault") CHATS_DIR = VAULT / "💬 чаты" DIARY = VAULT / "📅 дни" BEAVER = VAULT / "мета" / "бобер" PROMPTS = BEAVER / "промпты" GRANULES = PROMPTS / "гранулы" SKILLS = BEAVER / "скиллы" DAYS = BEAVER / "дни" _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: триаж без голоса и без 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") 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) # Секреты 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, ] # Руки глубокого (§4.4): firefly без delete_*, obsidian-fs не даётся - свои # файловые тулзы есть, vault смонтирован в /vault. CLAUDE_MCPS = ( ExposedMcp(name="firefly", deny=("delete_*",)), ExposedMcp(name="telegram"), *calendar_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, # §3.7: у диспетчера AskUserQuestion остаётся, планов и ноутбуков нет. disallowed_tools=("ExitPlanMode", "EnterPlanMode", "NotebookEdit"), ), expose_mcps=CLAUDE_MCPS, ) 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, options=ClaudeOptions( effort=effort, # §3.7: у глубоких вопросы текстом, планов и сабагентов нет. disallowed_tools=( "AskUserQuestion", "ExitPlanMode", "EnterPlanMode", "NotebookEdit", "Task", ), ), expose_mcps=CLAUDE_MCPS, ) 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=("AskUserQuestion", "Task", "WebSearch", "WebFetch"), ), ) 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: диспетчер на medium; high - по руке или в ветке. dispatcher("beaver-dispatcher", "claude-opus-5", effort="medium"), 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"), 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, только если есть что сказать." ) texts = ConversationTexts( merge_prompt=_read(GRANULES / "слив.md") or ConversationTexts().merge_prompt, seed=seed_body, handout=handout_prompt, new_day=NEW_DAY, ) # §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 def vibegram_check() -> list[str]: """Заглушка до M7: детерминированная проверка вайбграма, пока ничего нового.""" return [] async def vibegram(run: JobRun) -> None: global _vibegram_last_wake # noqa: PLW0603 - счётчик «≤ 1/ч», живёт до рестарта new = vibegram_check() if not new: return now = datetime.now(UTC) if _vibegram_last_wake is not None and now - _vibegram_last_wake < timedelta( hours=1 ): return _vibegram_last_wake = now await run.spawn_job( agent="beaver-triage", title="вайбграм", text="Новое в вайбграме:\n" + "\n".join(f"- {item}" for item in new), ) KOMODO_STACK = os.environ.get("KOMODO_STACK", "beaver-agent") async def komodo_deploy() -> None: url, key, secret = ( os.environ.get(k, "") for k in ("KOMODO_URL", "KOMODO_KEY", "KOMODO_SECRET") ) if not (url and key and secret): _log.warning("deploy hook: KOMODO_URL/KEY/SECRET are not set, nothing deployed") return body = {"type": "DeployStack", "params": {"stack": KOMODO_STACK, "services": []}} headers = {"X-Api-Key": key, "X-Api-Secret": secret} async with ( aiohttp.ClientSession() as http, http.post( f"{url.rstrip('/')}/execute", json=body, headers=headers, timeout=aiohttp.ClientTimeout(total=30), ) as response, ): _log.info( "deploy hook: komodo %s %s", response.status, (await response.text())[:200] ) 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 komodo_alert(run: JobRun) -> None: payload = run.payload summary = payload.get("raw") or {k: v for k, v in payload.items() if k != "trigger"} await run.inject_master(f"Komodo: {summary}", urgency="urgent", origin="komodo") jobs = [ Job("ротация", rotate, cron="0 * * * *"), Job("вайбграм", vibegram, cron="*/10 * * * *", critical=False), Job("deploy", deploy, webhook=True), Job("komodo", komodo_alert, webhook=True), ] gateway = Gateway( agents=agents, mcps=mcps, frontends=frontends, texts=texts, jobs=jobs, rotation=RotationPolicy(tz=TZ), watch=watch, budget=Budget(threshold=0.7), tz=TZ, port=62990, public_url=PUBLIC_BASE_URL or None, )