281 lines
11 KiB
Python
281 lines
11 KiB
Python
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",
|
||
memory_root=BEAVER,
|
||
),
|
||
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)
|