import os from datetime import date, timedelta from pathlib import Path 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.turn_record import slugify 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 VAULT = Path("/vault") CHATS_DIR = VAULT / "πŸ’¬ Ρ‡Π°Ρ‚Ρ‹" BEAVER = VAULT / "ΠΌΠ΅Ρ‚Π°" / "Π±ΠΎΠ±Π΅Ρ€" PROMPTS = BEAVER / "ΠΏΡ€ΠΎΠΌΠΏΡ‚Ρ‹" GRANULES = PROMPTS / "Π³Ρ€Π°Π½ΡƒΠ»Ρ‹" SKILLS = BEAVER / "скиллы" # Π“Ρ€Π°Π½ΡƒΠ»Ρ‹ - чистый 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, ) 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 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"), 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("/") def _public(suffix: str) -> str | None: return f"{PUBLIC_BASE_URL}{suffix}" if PUBLIC_BASE_URL else None # Β§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 ) frontends: list[Frontend] = [ *([TELEGRAM] if TELEGRAM is not None else []), AnthropicMessagesFrontend( host="0.0.0.0", port=62990, public_base_url=_public("/anthropic") ), McpServerFrontend(host="0.0.0.0", port=62991, public_base_url=_public("/mcp")), AdminFrontend(host="0.0.0.0", port=62992, public_base_url=_public("/admin")), # Β§3.9: /api ΠΏΠΎΠΊΠ°Π·Ρ‹Π²Π°Π΅Ρ‚ всё; Π΄Π΅Ρ„ΠΎΠ»Ρ‚ master/branch здСсь - запасной Π½Π° случай # gateway Π±Π΅Π· Ρ‚Π΅Π»Π΅Π³Ρ€Π°ΠΌΠ°, deep - Ρƒ markdown. ApiFrontend( host="0.0.0.0", port=62994, public_base_url=_public("/api"), master_agent="beaver-dispatcher", branch_agent="beaver-dispatcher", ), MarkdownFrontend( host="0.0.0.0", port=62993, vault_path=CHATS_DIR, default_agent="beaver-opus-high", log_all_chats=True, chat_path=chat_path, public_base_url=_public("/md"), ), ] def _read(path: Path) -> str | None: return path.read_text(encoding="utf-8").strip() if path.exists() else None def seed_body(ctx: SeedContext) -> str | None: """Β§8.2: ΡƒΡ‚Ρ€Π΅Π½Π½ΠΈΠΉ сид - Π²Ρ‡Π΅Ρ€Π°ΡˆΠ½ΠΈΠΉ Ρ…Π΅Π½Π΄Π°ΡƒΡ‚ ΠΈΠ· `Π΄Π½ΠΈ/`; ΠΎΡΡ‚Π°Π»ΡŒΠ½Ρ‹Π΅ сиды - gateway.""" if ctx.seed != "morning": return None yesterday = date.today() - timedelta(days=1) handout = _read(BEAVER / "Π΄Π½ΠΈ" / f"{yesterday:%Y-%m-%d}.md") return handout or "Π₯Π΅Π½Π΄Π°ΡƒΡ‚ Π·Π° Π²Ρ‡Π΅Ρ€Π° Π½Π΅ ΠΏΡ€ΠΈΠ΅Ρ…Π°Π»." texts = ConversationTexts( merge_prompt=_read(GRANULES / "слив.md") or ConversationTexts().merge_prompt, seed=seed_body, ) gateway = Gateway(agents=agents, mcps=mcps, frontends=frontends, texts=texts)