212 lines
7.4 KiB
Python
212 lines
7.4 KiB
Python
import os
|
|
from datetime import date
|
|
from pathlib import Path
|
|
|
|
from beaver_gateway.agents.base import BaseAgent, ExposedMcp
|
|
from beaver_gateway.agents.claude import ClaudeAgent, ClaudeOptions
|
|
from beaver_gateway.agents.raycast import RaycastAgent, RemoteTool, UserPreferences
|
|
from beaver_gateway.core.prompt import assemble
|
|
from beaver_gateway.core.registry import Gateway
|
|
from beaver_gateway.core.turn_record import TurnRecord, slugify
|
|
from beaver_gateway.frontends.admin import AdminFrontend
|
|
from beaver_gateway.frontends.anthropic import AnthropicMessagesFrontend
|
|
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.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'а - константная гранула, экземплярное
|
|
# (файл, топик) едет первым сообщением. Диспетчер/дистиллятор/триаж - M1b+.
|
|
DEEP_PROMPT = (
|
|
*VOICE,
|
|
PROFILE,
|
|
CORRECTIONS,
|
|
VAULT_MAP,
|
|
environment("глубокий"),
|
|
overlay("deep", "глубокий"),
|
|
)
|
|
QUICK_PROMPT = assemble((*VOICE, PROFILE, overlay("quick", "быстрый")))
|
|
|
|
# §4.3: наборы скиллов = папки = плагины. Глубокие грузят общие + vault.
|
|
DEEP_SKILLS = (SKILLS / "общие", SKILLS / "vault")
|
|
|
|
|
|
def chat_log_path(record: TurnRecord, vault: Path) -> Path:
|
|
today = date.today()
|
|
topic = slugify(record.first_user_text, maxlen=60)
|
|
rel = (
|
|
CHATS_DIR.relative_to(vault)
|
|
/ f"{today:%Y-%m}"
|
|
/ f"{today:%Y-%m-%d} - {topic}.md"
|
|
)
|
|
return vault / rel
|
|
|
|
|
|
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 deep(name: str, model: str, effort: str | None = None) -> ClaudeAgent:
|
|
return ClaudeAgent(
|
|
name=name,
|
|
model=model,
|
|
cwd=VAULT,
|
|
prompt_sources=DEEP_PROMPT,
|
|
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] = [
|
|
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
|
|
|
|
|
|
frontends: list[Frontend] = [
|
|
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")),
|
|
MarkdownFrontend(
|
|
host="0.0.0.0",
|
|
port=62993,
|
|
vault_path=CHATS_DIR,
|
|
default_agent="beaver-opus-high",
|
|
log_all_chats=True,
|
|
log_path=chat_log_path,
|
|
public_base_url=_public("/md"),
|
|
),
|
|
]
|
|
|
|
|
|
gateway = Gateway(agents=agents, mcps=mcps, frontends=frontends)
|