refactor: split config.py into the beaver_agent package (vault, prompts, skills, policy, hands, agents, frontends, texts, memory, jobs)
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
"""Сетап Бобра поверх beaver-gateway.
|
||||||
|
|
||||||
|
vault, промпты, скиллы, руки, агенты, фронтенды, память, джобы - по модулю на каждое.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""Роли и их экземпляры: кто с каким промптом, скиллами, руками и границами."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from beaver_gateway.agents.claude import ClaudeAgent, ClaudeOptions
|
||||||
|
from beaver_gateway.agents.raycast import RaycastAgent, RemoteTool, UserPreferences
|
||||||
|
|
||||||
|
from beaver_agent import hands, prompts, skills
|
||||||
|
from beaver_agent.policy import (
|
||||||
|
DEEP_DISALLOWED,
|
||||||
|
DISPATCHER_DISALLOWED,
|
||||||
|
DISTILLER_DISALLOWED,
|
||||||
|
STRICT_ZONES,
|
||||||
|
TRIAGE_DISALLOWED,
|
||||||
|
VAULT_POLICY,
|
||||||
|
forbid_skills,
|
||||||
|
vault_zones,
|
||||||
|
)
|
||||||
|
from beaver_agent.vault import TZ, VAULT
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from beaver_gateway.agents.base import BaseAgent
|
||||||
|
|
||||||
|
|
||||||
|
def dispatcher(name: str, model: str, effort: str) -> ClaudeAgent:
|
||||||
|
"""Мастер и ветки: полный голос, все руки, vault - дом."""
|
||||||
|
return ClaudeAgent(
|
||||||
|
name=name,
|
||||||
|
model=model,
|
||||||
|
cwd=VAULT,
|
||||||
|
prompts=prompts.DISPATCHER,
|
||||||
|
skill_sets=skills.DISPATCHER,
|
||||||
|
gateway_tools=("read_conversation", "spawn", "say", "schedule"),
|
||||||
|
options=ClaudeOptions(effort=effort, disallowed_tools=DISPATCHER_DISALLOWED),
|
||||||
|
policy=(
|
||||||
|
*VAULT_POLICY,
|
||||||
|
forbid_skills(skills.MASTER_FORBIDDEN, kinds=("master",)),
|
||||||
|
),
|
||||||
|
expose_mcps=hands.DISPATCHER,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def deep(name: str, model: str, effort: str) -> ClaudeAgent:
|
||||||
|
"""Глубокий чат в файле vault; «ок, обсудили» - тулза close_chat."""
|
||||||
|
return ClaudeAgent(
|
||||||
|
name=name,
|
||||||
|
model=model,
|
||||||
|
cwd=VAULT,
|
||||||
|
prompts=prompts.DEEP,
|
||||||
|
skill_sets=skills.DEEP,
|
||||||
|
gateway_tools=("close_chat",),
|
||||||
|
options=ClaudeOptions(effort=effort, disallowed_tools=DEEP_DISALLOWED),
|
||||||
|
policy=VAULT_POLICY,
|
||||||
|
expose_mcps=hands.CLAUDE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def distiller(name: str, model: str, effort: str) -> ClaudeAgent:
|
||||||
|
"""Форк закрытого чата и недельная чистка состояния: Read/Write в мета/бобер."""
|
||||||
|
return ClaudeAgent(
|
||||||
|
name=name,
|
||||||
|
model=model,
|
||||||
|
cwd=VAULT,
|
||||||
|
prompts=prompts.DISTILLER,
|
||||||
|
options=ClaudeOptions(
|
||||||
|
effort=effort,
|
||||||
|
tools=("Read", "Write"),
|
||||||
|
disallowed_tools=DISTILLER_DISALLOWED,
|
||||||
|
),
|
||||||
|
policy=(vault_zones(STRICT_ZONES),),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def curator(name: str, model: str, effort: str) -> ClaudeAgent:
|
||||||
|
"""Куратор памяти: раскладывает новое по наблюдениям, будит мастера через inject."""
|
||||||
|
return ClaudeAgent(
|
||||||
|
name=name,
|
||||||
|
model=model,
|
||||||
|
cwd=VAULT,
|
||||||
|
prompts=prompts.CURATOR,
|
||||||
|
gateway_tools=("inject",),
|
||||||
|
options=ClaudeOptions(
|
||||||
|
effort=effort,
|
||||||
|
tools=("Read", "Write", "Edit", "Grep", "Glob"),
|
||||||
|
disallowed_tools=DISTILLER_DISALLOWED,
|
||||||
|
),
|
||||||
|
policy=(vault_zones(STRICT_ZONES),),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def triage(name: str, model: str, effort: str) -> ClaudeAgent:
|
||||||
|
"""Дешёвый крон-тёрн без vault: решает, будить ли мастера."""
|
||||||
|
return ClaudeAgent(
|
||||||
|
name=name,
|
||||||
|
model=model,
|
||||||
|
cwd=Path("/tmp"), # noqa: S108
|
||||||
|
prompts=prompts.TRIAGE,
|
||||||
|
gateway_tools=("say", "inject"),
|
||||||
|
options=ClaudeOptions(
|
||||||
|
effort=effort, tools=(), disallowed_tools=TRIAGE_DISALLOWED
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def raycast(name: str, model: str, effort: str) -> RaycastAgent:
|
||||||
|
"""Быстрые ответы через Raycast: веб и чтение страниц, vault через obsidian-fs."""
|
||||||
|
return RaycastAgent(
|
||||||
|
name=name,
|
||||||
|
model=model,
|
||||||
|
system_prompt=prompts.QUICK,
|
||||||
|
reasoning_effort=effort,
|
||||||
|
available_native_tools=(RemoteTool.WEB_SEARCH, RemoteTool.READ_PAGE),
|
||||||
|
user_preferences=lambda: UserPreferences(
|
||||||
|
locale="ru-RU", timezone=TZ, current_date=date.today().isoformat()
|
||||||
|
),
|
||||||
|
expose_mcps=hands.RAYCAST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
agents: list[BaseAgent] = [
|
||||||
|
dispatcher("beaver-dispatcher", "claude-opus-5", "high"),
|
||||||
|
deep("beaver-opus-high", "claude-opus-5", "high"),
|
||||||
|
deep("beaver-opus-medium", "claude-opus-5", "medium"),
|
||||||
|
deep("beaver-opus-xhigh", "claude-opus-5", "xhigh"),
|
||||||
|
deep("beaver-fable-high", "claude-fable-5-1", "high"),
|
||||||
|
deep("beaver-fable-medium", "claude-fable-5-1", "medium"),
|
||||||
|
distiller("beaver-distiller", "claude-opus-5", "medium"),
|
||||||
|
curator("beaver-curator", "claude-opus-5", "high"),
|
||||||
|
triage("beaver-triage", "claude-sonnet-5", "low"),
|
||||||
|
raycast("beaver-gemini-pro-high", "google-gemini-3.1-pro", "high"),
|
||||||
|
raycast("beaver-gemini-pro-low", "google-gemini-3.1-pro", "low"),
|
||||||
|
raycast("beaver-gemini-flash-high", "google-gemini-3.5-flash", "high"),
|
||||||
|
raycast("beaver-gemini-flash-low", "google-gemini-3.5-flash", "low"),
|
||||||
|
]
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Окна: личка с ботом для мастера и веток, файлы vault для глубоких, API и админка."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import date
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
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.markdown import MarkdownFrontend
|
||||||
|
from beaver_gateway.frontends.mcp_server import McpServerFrontend
|
||||||
|
from beaver_gateway.frontends.telegram import Attachments, TelegramFrontend
|
||||||
|
from beaver_gateway.frontends.turn_record import slugify
|
||||||
|
|
||||||
|
from beaver_agent import texts
|
||||||
|
from beaver_agent.vault import ATTACHMENTS, BEAVER, CHATS, TZ
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from beaver_gateway.frontends.base import Frontend
|
||||||
|
|
||||||
|
DISPATCHER = "beaver-dispatcher"
|
||||||
|
DEEP = "beaver-opus-high"
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
telegram = (
|
||||||
|
TelegramFrontend(
|
||||||
|
token=os.environ["TELEGRAM_BOT_TOKEN"],
|
||||||
|
user_id=int(os.environ["TELEGRAM_USER_ID"]),
|
||||||
|
master_agent=DISPATCHER,
|
||||||
|
branch_agent=DISPATCHER,
|
||||||
|
attachments=Attachments(mode="vault", dir=ATTACHMENTS, keep_days=30, tz=TZ),
|
||||||
|
texts=texts.TELEGRAM,
|
||||||
|
)
|
||||||
|
if os.environ.get("TELEGRAM_BOT_TOKEN")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
"""Вложения - входящая папка по дням в зоне агента; нужное он переносит сам."""
|
||||||
|
|
||||||
|
frontends: list[Frontend] = [
|
||||||
|
*([telegram] if telegram is not None else []),
|
||||||
|
AnthropicMessagesFrontend(),
|
||||||
|
McpServerFrontend(),
|
||||||
|
AdminFrontend(),
|
||||||
|
ApiFrontend(master_agent=DISPATCHER, branch_agent=DISPATCHER, memory_root=BEAVER),
|
||||||
|
MarkdownFrontend(
|
||||||
|
vault_path=CHATS, default_agent=DEEP, log_all_chats=True, chat_path=chat_path
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""Руки: какие MCP есть у сетапа и кому из агентов какая рука выдана.
|
||||||
|
|
||||||
|
Секреты MCP - только через env подпроцесса, никогда argv: процесс модели
|
||||||
|
видит `ps` всего контейнера. Рука, чей env не задан, просто не появляется.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from beaver_gateway.agents.base import ExposedMcp
|
||||||
|
from beaver_gateway.mcp.types import McpServer, McpServerT
|
||||||
|
|
||||||
|
from beaver_agent.hands.calendars import calendars
|
||||||
|
from beaver_agent.hands.homeassistant import HomeAssistant
|
||||||
|
from beaver_agent.hands.komodo import Komodo
|
||||||
|
from beaver_agent.hands.vibegram import Vibegram
|
||||||
|
from beaver_agent.vault import TZ
|
||||||
|
|
||||||
|
env = os.environ.get
|
||||||
|
|
||||||
|
obsidian = McpServer.stdio(
|
||||||
|
name="obsidian-fs",
|
||||||
|
command=["bunx", "-y", "@modelcontextprotocol/server-filesystem", "/vault"],
|
||||||
|
lenient=True,
|
||||||
|
)
|
||||||
|
"""Файловые тулзы для тех, у кого нет своих (Raycast); у Claude vault смонтирован."""
|
||||||
|
|
||||||
|
firefly = (
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
if env("FIREFLY_PAT")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
firefly_exposed = (ExposedMcp(name="firefly", deny=("delete_*",)),) if firefly else ()
|
||||||
|
|
||||||
|
telegram = (
|
||||||
|
McpServer.http(name="telegram", url=os.environ["BEAVERGRAM_MCP"])
|
||||||
|
if env("BEAVERGRAM_MCP")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
"""beavergram: история чатов Бобра в Telegram."""
|
||||||
|
|
||||||
|
calendar_servers = calendars(env("CALENDAR_MCPS", ""))
|
||||||
|
calendar_exposed = tuple(ExposedMcp(name=m.name) for m in calendar_servers)
|
||||||
|
|
||||||
|
KOMODO = (
|
||||||
|
Komodo(
|
||||||
|
url=os.environ["KOMODO_URL"],
|
||||||
|
key=os.environ["KOMODO_KEY"],
|
||||||
|
secret=os.environ["KOMODO_SECRET"],
|
||||||
|
)
|
||||||
|
if env("KOMODO_URL")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
"""Флот: перечислимые действия, ключ остаётся в gateway."""
|
||||||
|
komodo = McpServer.python_tool(name="komodo", tools=[KOMODO.komodo]) if KOMODO else None
|
||||||
|
komodo_exposed = (ExposedMcp(name="komodo"),) if KOMODO else ()
|
||||||
|
|
||||||
|
HA = (
|
||||||
|
HomeAssistant(url=os.environ["HA_URL"], token=os.environ["HA_TOKEN"])
|
||||||
|
if env("HA_URL") and env("HA_TOKEN")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
"""Дом: HA живёт на хосте, из контейнера - host.docker.internal."""
|
||||||
|
ha = McpServer.python_tool(name="ha", tools=[HA.ha]) if HA else None
|
||||||
|
ha_exposed = (ExposedMcp(name="ha"),) if HA else ()
|
||||||
|
|
||||||
|
VIBEGRAM_REPO = Path("/vibegram") / env("VIBEGRAM_REPO", "x/room").split("/")[-1]
|
||||||
|
VIBEGRAM = (
|
||||||
|
Vibegram(
|
||||||
|
hub=env("VIBEGRAM_HUB", "https://vibegram.example.com"),
|
||||||
|
token=os.environ["VIBEGRAM_TOKEN"],
|
||||||
|
nick=env("VIBEGRAM_NICK", "beaver-test"),
|
||||||
|
repo=VIBEGRAM_REPO if VIBEGRAM_REPO.exists() else None,
|
||||||
|
tz=TZ,
|
||||||
|
)
|
||||||
|
if env("VIBEGRAM_TOKEN")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
"""Комната агентов: диспетчер сидит в ней сам; клон репы комнаты - том `vibegram`."""
|
||||||
|
vibegram = (
|
||||||
|
McpServer.python_tool(name="vibegram", tools=[VIBEGRAM.vibegram])
|
||||||
|
if VIBEGRAM
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
vibegram_exposed = (ExposedMcp(name="vibegram"),) if VIBEGRAM else ()
|
||||||
|
|
||||||
|
t3code = (
|
||||||
|
McpServer.http(name="t3code", url=env("T3CODE_MCP", "http://t3code-mcp:8000/mcp"))
|
||||||
|
if "t3" in env("COMPOSE_PROFILES", "").split(",")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
"""t3code-mcp - сосед по compose (профиль t3); машины и allowlist в его t3code.toml."""
|
||||||
|
t3code_exposed = (ExposedMcp(name="t3code"),) if t3code else ()
|
||||||
|
|
||||||
|
msos = (
|
||||||
|
McpServer.http(name="msos", url=os.environ["MSOS_MCP"]) if env("MSOS_MCP") else None
|
||||||
|
)
|
||||||
|
"""Таскер рабочих проектов (скилл `задачи`)."""
|
||||||
|
msos_exposed = (ExposedMcp(name="msos"),) if msos else ()
|
||||||
|
|
||||||
|
mcps: list[McpServerT] = [
|
||||||
|
m
|
||||||
|
for m in (
|
||||||
|
obsidian,
|
||||||
|
firefly,
|
||||||
|
telegram,
|
||||||
|
*calendar_servers,
|
||||||
|
komodo,
|
||||||
|
ha,
|
||||||
|
vibegram,
|
||||||
|
t3code,
|
||||||
|
msos,
|
||||||
|
)
|
||||||
|
if m is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
CLAUDE = (
|
||||||
|
*firefly_exposed,
|
||||||
|
ExposedMcp(name="telegram"),
|
||||||
|
*calendar_exposed,
|
||||||
|
*komodo_exposed,
|
||||||
|
*ha_exposed,
|
||||||
|
)
|
||||||
|
"""Руки любого Claude-агента: firefly без delete, телега, календари, флот, дом."""
|
||||||
|
DISPATCHER = (*CLAUDE, *t3code_exposed, *vibegram_exposed, *msos_exposed)
|
||||||
|
"""Диспетчер ещё ходит в код, в комнату агентов и в таскер; глубоким это не даётся."""
|
||||||
|
RAYCAST = (
|
||||||
|
ExposedMcp(name="obsidian-fs"),
|
||||||
|
*firefly_exposed,
|
||||||
|
ExposedMcp(name="telegram"),
|
||||||
|
*calendar_exposed,
|
||||||
|
)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Календари из `CALENDAR_MCPS=name=url,name=url`; в url - приватный фид с токеном."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from beaver_gateway.mcp.types import HttpMcp, McpServer
|
||||||
|
|
||||||
|
|
||||||
|
def calendars(raw: str) -> list[HttpMcp]:
|
||||||
|
servers: list[HttpMcp] = []
|
||||||
|
for position, raw_entry in enumerate(raw.split(","), start=1):
|
||||||
|
entry = raw_entry.strip()
|
||||||
|
if not entry:
|
||||||
|
continue
|
||||||
|
name, sep, url = entry.partition("=")
|
||||||
|
if not sep or not url.strip():
|
||||||
|
msg = f"CALENDAR_MCPS: запись #{position} не вида `name=url`"
|
||||||
|
raise ValueError(msg)
|
||||||
|
servers.append(McpServer.http(name=f"calendar-{name.strip()}", url=url.strip()))
|
||||||
|
return servers
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""§4.4: Home Assistant как python_tool с перечислимыми действиями.
|
"""Home Assistant как python_tool с перечислимыми действиями.
|
||||||
|
|
||||||
Токен HA живёт в gateway и в контекст модели не попадает. Действия:
|
Токен HA живёт в gateway и в контекст модели не попадает. Действия:
|
||||||
найти сущности, посмотреть состояние, вызвать сервис, перечислить сервисы
|
найти сущности, посмотреть состояние, вызвать сервис, перечислить сервисы
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""§3.7, §4.4: Komodo как python_tool с перечислимыми действиями.
|
"""Komodo как python_tool с перечислимыми действиями.
|
||||||
|
|
||||||
Ключ живёт в gateway и в контекст модели не попадает. В перечислении нет
|
Ключ живёт в gateway и в контекст модели не попадает. В перечислении нет
|
||||||
`Prune*`, `Destroy*`, `DeleteServer` и терминала хоста; `exec` - это
|
`Prune*`, `Destroy*`, `DeleteServer` и терминала хоста; `exec` - это
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""§4.5: алерты Komodo для диспетчера - что доехать до мастера, когда и как.
|
"""алерты Komodo для диспетчера - что доехать до мастера, когда и как.
|
||||||
|
|
||||||
Вебхук `/hooks/komodo` получает всё, что шлёт Komodo (типы алертов у
|
Вебхук `/hooks/komodo` получает всё, что шлёт Komodo (типы алертов у
|
||||||
алертера не перечислены намеренно). Сюда мастеру нужна малая часть, и не
|
алертера не перечислены намеренно). Сюда мастеру нужна малая часть, и не
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""§4.4, §8.5, §12.D1: вайбграм - комната агентов на хабе, как python_tool.
|
"""вайбграм - комната агентов на хабе, как python_tool.
|
||||||
|
|
||||||
Хаб - https://vibegram.example.com, протокол - REST с bearer-токеном агента
|
Хаб - https://vibegram.example.com, протокол - REST с bearer-токеном агента
|
||||||
(выдан при `vibegram join`, лежит в env gateway и в контекст модели не
|
(выдан при `vibegram join`, лежит в env gateway и в контекст модели не
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Джобы: кроны считаются в локальном времени, вебхуки приходят на /hooks/<имя>."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from beaver_gateway.jobs.scheduler import Job
|
||||||
|
|
||||||
|
from beaver_agent.jobs.closing import close_idle
|
||||||
|
from beaver_agent.jobs.deploy import deploy
|
||||||
|
from beaver_agent.jobs.komodo import komodo_alert
|
||||||
|
from beaver_agent.jobs.memory import curate, memory
|
||||||
|
from beaver_agent.jobs.rotation import rotate
|
||||||
|
from beaver_agent.jobs.t3code import t3code_event
|
||||||
|
from beaver_agent.jobs.vibegram import vibegram
|
||||||
|
|
||||||
|
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("куратор", curate, cron="0 2,8,12,16,20 * * *", critical=False),
|
||||||
|
Job("deploy", deploy, webhook=True),
|
||||||
|
Job("komodo", komodo_alert, webhook=True, dedupe=False),
|
||||||
|
Job("t3code", t3code_event, webhook=True, dedupe=False),
|
||||||
|
]
|
||||||
|
"""Алерты и события t3code идут пачками (тревога и отбой подряд) - их не схлопывают."""
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""Ночью после ротации: глубокие чаты, тихие два дня, закрываются, не больше трёх."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from beaver_agent.vault import LAUNCH
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from beaver_gateway.jobs.scheduler import JobRun
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def close_idle(run: JobRun) -> None:
|
||||||
|
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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""`POST /hooks/deploy`: дождаться тишины в мастере и попросить Komodo передеплоить."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from beaver_agent.hands import KOMODO
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from beaver_gateway.jobs.scheduler import JobRun
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STACK = os.environ.get("KOMODO_STACK", "beaver-agent")
|
||||||
|
|
||||||
|
|
||||||
|
async def deploy(run: JobRun) -> None:
|
||||||
|
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(_deploy())
|
||||||
|
|
||||||
|
|
||||||
|
async def _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": STACK, "services": []})
|
||||||
|
_log.info("deploy hook: komodo accepted %s", receipt.get("id"))
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""`POST /hooks/komodo`: алерты флота - флапы выдерживаются, остальное едет в мастер."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from beaver_agent.hands import KOMODO
|
||||||
|
from beaver_agent.hands.komodo_alerts import (
|
||||||
|
HOLD,
|
||||||
|
AlertMemory,
|
||||||
|
describe,
|
||||||
|
parse,
|
||||||
|
still_bad,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from beaver_gateway.jobs.scheduler import JobRun
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ALERTS = AlertMemory()
|
||||||
|
HINT = (
|
||||||
|
"Это флот Бобра; ему такие алерты приходят и так (healthbot), тебя будят, "
|
||||||
|
"когда через 5 минут само не встало. Посмотри komodo(status/logs), почини, "
|
||||||
|
"если очевидно (restart стека), иначе - коротко скажи Бобру через say, что "
|
||||||
|
"видишь. Не деплой и не чини «заодно»."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def komodo_alert(run: JobRun) -> None:
|
||||||
|
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{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{HINT}",
|
||||||
|
urgency=verdict.urgency,
|
||||||
|
origin="komodo",
|
||||||
|
)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Память: воскресная перезапись состояния из хендаутов и куратор по расписанию."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from beaver_gateway.conversations.distill import LineCap
|
||||||
|
|
||||||
|
from beaver_agent.memory.curator import briefing
|
||||||
|
from beaver_agent.memory.watch import CURATOR_WATCH
|
||||||
|
from beaver_agent.vault import (
|
||||||
|
CURATOR_JOURNAL,
|
||||||
|
DAYS,
|
||||||
|
REPLIES,
|
||||||
|
STATE,
|
||||||
|
STATE_MAX_LINES,
|
||||||
|
TZ,
|
||||||
|
VAULT,
|
||||||
|
today,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from beaver_gateway.jobs.scheduler import JobRun
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
REWRITE_STATE = (
|
||||||
|
"Воскресенье, {day}. Перепиши `{state}` из последних хендаутов: {handouts}. "
|
||||||
|
"`## сейчас` - только невыводимое из vault, `## мои хвосты` - capability "
|
||||||
|
"requests в работе и обещания длиннее дня. Меньше {max_lines} строк."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def memory(run: JobRun) -> None:
|
||||||
|
"""Потолок строк держит 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=REWRITE_STATE.format(
|
||||||
|
day=today(),
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def curate(run: JobRun) -> None:
|
||||||
|
"""Крон собирает «что нового», модель раскладывает; пусто - джоб не спавнится."""
|
||||||
|
text = briefing(
|
||||||
|
vault=VAULT,
|
||||||
|
journal=CURATOR_JOURNAL,
|
||||||
|
replies_dir=REPLIES,
|
||||||
|
watched=CURATOR_WATCH,
|
||||||
|
tz=TZ,
|
||||||
|
)
|
||||||
|
if text is None:
|
||||||
|
_log.info("curator: nothing new since the last run")
|
||||||
|
return
|
||||||
|
await run.spawn_job(agent="beaver-curator", title="куратор", text=text)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""Раз в час: пора ли сменить мастера (ночь, возраст, размер контекста)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from beaver_gateway.jobs.scheduler import JobRun
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def rotate(run: JobRun) -> None:
|
||||||
|
for master in await run.rotate():
|
||||||
|
_log.info("rotated master -> %s", master.external_id)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""`POST /hooks/t3code`: тред завершился, упал или спрашивает - срочно в мастер."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from beaver_gateway.jobs.scheduler import JobRun
|
||||||
|
|
||||||
|
QUESTION_HINT = (
|
||||||
|
"Если ответ следует из задания - ответь сам: t3_answer(thread_id, "
|
||||||
|
"{id вопроса: label}). Не знаешь - спроси Бобра через say своими словами, "
|
||||||
|
"потом t3_answer. Тред стоит, пока не ответят."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def t3code_event(run: JobRun) -> None:
|
||||||
|
await run.inject_master(describe(run.payload), urgency="urgent", origin="t3code")
|
||||||
|
|
||||||
|
|
||||||
|
def describe(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(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)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Раз в 10 минут: новое в комнате → триаж → мастер, не чаще раза в час."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from beaver_agent.hands import VIBEGRAM, VIBEGRAM_REPO
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from beaver_gateway.jobs.scheduler import JobRun
|
||||||
|
|
||||||
|
BRIEF = (
|
||||||
|
"Новое в вайбграме (комната {room}, ты там {nick}; остальные - чужие агенты):\n"
|
||||||
|
"{items}\n"
|
||||||
|
'Будить мастера - inject(conversation="master", urgency="wake", '
|
||||||
|
"text=резюме до 3 строк: кто, что, чего ждёт). Мастер прочитает подробности "
|
||||||
|
"через vibegram(read) и ответит через vibegram(send), если решит. "
|
||||||
|
"Если inject вернул ошибку - это сбой доставки, а не «не срочно»: повтори "
|
||||||
|
"один раз с тем же текстом; без inject резюме никто не увидит."
|
||||||
|
)
|
||||||
|
|
||||||
|
_last_wake: datetime | None = None
|
||||||
|
_backlog: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
async def vibegram(run: JobRun) -> None:
|
||||||
|
global _last_wake # noqa: PLW0603
|
||||||
|
if VIBEGRAM is None:
|
||||||
|
return
|
||||||
|
events = await VIBEGRAM.pending()
|
||||||
|
_backlog.extend(e.line() for e in events)
|
||||||
|
if not _backlog:
|
||||||
|
return
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
addressed = VIBEGRAM.mentioned(events)
|
||||||
|
if (
|
||||||
|
not addressed
|
||||||
|
and _last_wake is not None
|
||||||
|
and now - _last_wake < timedelta(hours=1)
|
||||||
|
):
|
||||||
|
return
|
||||||
|
_last_wake = now
|
||||||
|
items, _backlog[:] = list(_backlog), []
|
||||||
|
await run.spawn_job(
|
||||||
|
agent="beaver-triage",
|
||||||
|
title="вайбграм",
|
||||||
|
text=BRIEF.format(
|
||||||
|
room=VIBEGRAM_REPO.name,
|
||||||
|
nick=VIBEGRAM.nick,
|
||||||
|
items="\n".join(f"- {item}" for item in items[-40:]),
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Память Бобра в vault: справка, лог реплик, хендауты, вотчер, куратор."""
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Куратор памяти: что нового с прошлого прогона (архитектура §4.5, §6.0).
|
"""Куратор памяти: что нового с прошлого прогона .
|
||||||
|
|
||||||
Крон дёргает детерминированный код, модель судит: здесь собирается брифинг
|
Крон дёргает детерминированный код, модель судит: здесь собирается брифинг
|
||||||
для служебного тёрна `beaver-curator` - новые реплики Бобра целиком и имена
|
для служебного тёрна `beaver-curator` - новые реплики Бобра целиком и имена
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""Смена мастера и закрытие чата: утренний сид, хендаут, «новый день», дистиллятор."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from beaver_gateway.frontends.turn_record import slugify
|
||||||
|
|
||||||
|
from beaver_agent.memory.recall import notes_head
|
||||||
|
from beaver_agent.vault import (
|
||||||
|
DAYS,
|
||||||
|
DIARY,
|
||||||
|
DIGESTS,
|
||||||
|
GRANULES,
|
||||||
|
PORTRAIT,
|
||||||
|
STATE,
|
||||||
|
VAULT,
|
||||||
|
read,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from beaver_gateway.conversations.distill import DistillContext
|
||||||
|
from beaver_gateway.conversations.rotation import HandoutContext
|
||||||
|
from beaver_gateway.conversations.texts import NewDayContext, SeedContext
|
||||||
|
|
||||||
|
|
||||||
|
def latest_handout() -> Path | None:
|
||||||
|
"""Последний хендаут, а не «вчерашний по календарю»."""
|
||||||
|
files = sorted(DAYS.glob("????-??-??.md")) if DAYS.exists() else []
|
||||||
|
return files[-1] if files else None
|
||||||
|
|
||||||
|
|
||||||
|
def seed_body(ctx: SeedContext) -> str | None:
|
||||||
|
"""Утренний сид: последний хендаут, шапка состояния, портрет Бобра."""
|
||||||
|
if ctx.seed != "morning":
|
||||||
|
return None
|
||||||
|
handout = latest_handout()
|
||||||
|
if handout is None:
|
||||||
|
return "Хендаут не приехал."
|
||||||
|
parts = [
|
||||||
|
f"Хендаут ({handout.stem}):\n\n{handout.read_text(encoding='utf-8').strip()}"
|
||||||
|
]
|
||||||
|
signal = "\n".join((read(STATE) or "").splitlines()[:5])
|
||||||
|
if signal:
|
||||||
|
parts.append(
|
||||||
|
f"Состояние (первые строки, полностью в {STATE.relative_to(VAULT)}):\n"
|
||||||
|
f"{signal}"
|
||||||
|
)
|
||||||
|
portrait = notes_head(PORTRAIT, ("сейчас", "паттерны"), cap=40)
|
||||||
|
if portrait:
|
||||||
|
parts.append(
|
||||||
|
"Портрет Бобра (шапка "
|
||||||
|
f"`{PORTRAIT.relative_to(VAULT)}`, лента - там же):\n{portrait}"
|
||||||
|
)
|
||||||
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_HANDOUT = (
|
||||||
|
"Этот мастер закрывается ({reason}). Напиши хендаут за {day} в "
|
||||||
|
"`мета/бобер/дни/{day}.md`: справку на утро, не задание."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def handout_prompt(ctx: HandoutContext) -> str:
|
||||||
|
day = ctx.day.isoformat()
|
||||||
|
body = (
|
||||||
|
(read(GRANULES / "хендаут.md") or DEFAULT_HANDOUT)
|
||||||
|
.replace("{day}", day)
|
||||||
|
.replace("{reason}", ctx.reason)
|
||||||
|
)
|
||||||
|
if (DIARY / f"{day}.md").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 = {
|
||||||
|
"age": "прошло больше 36 часов",
|
||||||
|
"context": "контекст стал слишком большим",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def new_day(ctx: NewDayContext) -> str:
|
||||||
|
"""«Новый день» - только ночная ротация; остальные причины - тот же день."""
|
||||||
|
day = ctx.day.isoformat()
|
||||||
|
if ctx.reason == "night":
|
||||||
|
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:
|
||||||
|
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)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Справка под сообщением Бобра и лог его реплик (архитектура §3.3, инвариант 8).
|
"""Справка под сообщением Бобра и лог его реплик .
|
||||||
|
|
||||||
Gateway путей не знает; здесь - что искать по тексту сообщения и куда писать
|
Gateway путей не знает; здесь - что искать по тексту сообщения и куда писать
|
||||||
сказанное. ``Recall.block`` отдаёт указатели, не выводы: карточка упомянутого
|
сказанное. ``Recall.block`` отдаёт указатели, не выводы: карточка упомянутого
|
||||||
@@ -21,8 +21,8 @@ from zoneinfo import ZoneInfo
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from beaver_gateway.core.conversations import UserSaid
|
from beaver_gateway.conversations.envelope import RecallContext
|
||||||
from beaver_gateway.core.envelope import RecallContext
|
from beaver_gateway.conversations.texts import UserSaid
|
||||||
|
|
||||||
LABEL = "справка (указатели по сообщению, не выводы; открой, если относится к вопросу):"
|
LABEL = "справка (указатели по сообщению, не выводы; открой, если относится к вопросу):"
|
||||||
VOWELS = "аеёиоуыэюя"
|
VOWELS = "аеёиоуыэюя"
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Что из vault попадает в конверт и за чем следит куратор."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from beaver_gateway.vault.watch import VaultWatch, WatchRules
|
||||||
|
|
||||||
|
from beaver_agent.memory.curator import Watched
|
||||||
|
from beaver_agent.memory.recall import Recall, ReplyLog
|
||||||
|
from beaver_agent.vault import (
|
||||||
|
BEAVER,
|
||||||
|
BOARDS,
|
||||||
|
DAYS,
|
||||||
|
DIARY,
|
||||||
|
DIGESTS,
|
||||||
|
NOTES,
|
||||||
|
PEOPLE,
|
||||||
|
REPLIES,
|
||||||
|
TZ,
|
||||||
|
VAULT,
|
||||||
|
)
|
||||||
|
|
||||||
|
watch = VaultWatch(
|
||||||
|
VAULT,
|
||||||
|
WatchRules(
|
||||||
|
full=("📅 дни/{today}.md",),
|
||||||
|
names=(
|
||||||
|
"📅 дни/**",
|
||||||
|
"👤 люди/**",
|
||||||
|
"📆 планирование/**",
|
||||||
|
"📆 доски/**",
|
||||||
|
"💻 проекты/**",
|
||||||
|
"🧠 мысли/**",
|
||||||
|
"📶 ресерчи/**",
|
||||||
|
"мета/бобер/**",
|
||||||
|
),
|
||||||
|
ignore=(
|
||||||
|
"💬 чаты/**",
|
||||||
|
"мета/**",
|
||||||
|
"мета/бобер/реплики/**",
|
||||||
|
"**/attachments/**",
|
||||||
|
".obsidian/**",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
tz=TZ,
|
||||||
|
)
|
||||||
|
"""Дневник за сегодня - строками, остальное именами; конкретный паттерн побеждает."""
|
||||||
|
|
||||||
|
recall = Recall(
|
||||||
|
vault=VAULT,
|
||||||
|
people_dir=PEOPLE,
|
||||||
|
notes_dir=NOTES,
|
||||||
|
diary_dir=DIARY,
|
||||||
|
boards_dir=BOARDS,
|
||||||
|
tz=TZ,
|
||||||
|
)
|
||||||
|
replies = ReplyLog(REPLIES, tz=TZ)
|
||||||
|
|
||||||
|
CURATOR_WATCH = [
|
||||||
|
Watched("хендауты", DAYS, "????-??-??.md"),
|
||||||
|
Watched("выжимки", DIGESTS, "*.md"),
|
||||||
|
Watched("наблюдения", NOTES, "*.md"),
|
||||||
|
Watched("состояние", BEAVER, "состояние.md"),
|
||||||
|
Watched("дневник", DIARY, "????-??-??.md"),
|
||||||
|
Watched("карточки", PEOPLE, "**/*.md"),
|
||||||
|
]
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
"""§3.7: правила PreToolUse - граница без permission-промптов.
|
"""Граница без permission-промптов: правила PreToolUse и зоны vault.
|
||||||
|
|
||||||
Модель везде в `bypassPermissions`; что ей можно, решают маунты,
|
Модель везде в `bypassPermissions`; что ей можно, решают `disallowed_tools`
|
||||||
`disallowed_tools` и правила отсюда. Правило получает `ToolCall` и
|
и правила отсюда. Правило получает `ToolCall` и возвращает `Deny` с
|
||||||
возвращает `Deny` с причиной, которую модель читает как результат тулзы,
|
причиной, которую модель читает как результат тулзы, или `None`.
|
||||||
или `None`. Маунт `/vault:ro` - первая линия, хук - вторая: он ловит то,
|
|
||||||
что маунт не различает (зоны внутри одного тома, порядок «скилл - тулза»).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -14,7 +12,9 @@ import shlex
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from beaver_gateway.core.policy import Deny, PolicyRule, ToolCall
|
from beaver_gateway.agents.policy import Deny, PolicyRule, ToolCall
|
||||||
|
|
||||||
|
from beaver_agent.vault import BEAVER, CHATS, VAULT
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
@@ -24,7 +24,10 @@ __all__ = [
|
|||||||
"DEEP_DISALLOWED",
|
"DEEP_DISALLOWED",
|
||||||
"DISPATCHER_DISALLOWED",
|
"DISPATCHER_DISALLOWED",
|
||||||
"DISTILLER_DISALLOWED",
|
"DISTILLER_DISALLOWED",
|
||||||
|
"STRICT_ZONES",
|
||||||
"TRIAGE_DISALLOWED",
|
"TRIAGE_DISALLOWED",
|
||||||
|
"VAULT_POLICY",
|
||||||
|
"ZONES",
|
||||||
"Zones",
|
"Zones",
|
||||||
"bash_zones",
|
"bash_zones",
|
||||||
"forbid_skills",
|
"forbid_skills",
|
||||||
@@ -33,8 +36,6 @@ __all__ = [
|
|||||||
"vault_zones",
|
"vault_zones",
|
||||||
]
|
]
|
||||||
|
|
||||||
# §3.7: у диспетчера AskUserQuestion остаётся, планов и ноутбуков нет;
|
|
||||||
# у глубоких вопросы текстом, сабагентов нет; дистиллятор и триаж - без веба.
|
|
||||||
DISPATCHER_DISALLOWED = ("ExitPlanMode", "EnterPlanMode", "NotebookEdit")
|
DISPATCHER_DISALLOWED = ("ExitPlanMode", "EnterPlanMode", "NotebookEdit")
|
||||||
DEEP_DISALLOWED = (
|
DEEP_DISALLOWED = (
|
||||||
"AskUserQuestion",
|
"AskUserQuestion",
|
||||||
@@ -81,9 +82,7 @@ class Zones:
|
|||||||
``write`` - всё, включая удаление; ``create`` - только новые файлы;
|
``write`` - всё, включая удаление; ``create`` - только новые файлы;
|
||||||
``edit`` - создавать, править, переносить можно, удалять - нет;
|
``edit`` - создавать, править, переносить можно, удалять - нет;
|
||||||
``protected`` - не трогать. Приоритет: write > protected > create > edit;
|
``protected`` - не трогать. Приоритет: write > protected > create > edit;
|
||||||
путь в vault вне всех зон - отказ. Vault целиком в ``edit`` - это
|
путь в vault вне всех зон - отказ.
|
||||||
«дом» диспетчера (решение h, 2026-08-30): заметки, задачи, люди - его;
|
|
||||||
удаление чужого и `.obsidian`/`мета` остаются за Бобром.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
vault: Path
|
vault: Path
|
||||||
@@ -240,7 +239,7 @@ def skill_tracker() -> PolicyRule:
|
|||||||
|
|
||||||
|
|
||||||
def forbid_skills(names: tuple[str, ...], kinds: tuple[str, ...]) -> PolicyRule:
|
def forbid_skills(names: tuple[str, ...], kinds: tuple[str, ...]) -> PolicyRule:
|
||||||
"""§4.3: скиллы из ``names`` (fnmatch, без учёта регистра) в ``kinds`` закрыты.
|
"""Скиллы из ``names`` (fnmatch, без учёта регистра) в ``kinds`` закрыты.
|
||||||
|
|
||||||
Мастер их не открывает - для этого есть ветка; правило отвечает так,
|
Мастер их не открывает - для этого есть ветка; правило отвечает так,
|
||||||
чтобы модель сама её открыла.
|
чтобы модель сама её открыла.
|
||||||
@@ -259,7 +258,7 @@ def forbid_skills(names: tuple[str, ...], kinds: tuple[str, ...]) -> PolicyRule:
|
|||||||
|
|
||||||
|
|
||||||
def requires_skill(skill: str, tools: tuple[str, ...]) -> PolicyRule:
|
def requires_skill(skill: str, tools: tuple[str, ...]) -> PolicyRule:
|
||||||
"""§4.3: жёсткий протокол - тулзы из ``tools`` только после `Skill(skill)`."""
|
"""Тулзы из ``tools`` - только после `Skill(skill)` в той же сессии."""
|
||||||
|
|
||||||
def rule(call: ToolCall) -> Deny | None:
|
def rule(call: ToolCall) -> Deny | None:
|
||||||
if not any(fnmatch.fnmatchcase(call.tool, pattern) for pattern in tools):
|
if not any(fnmatch.fnmatchcase(call.tool, pattern) for pattern in tools):
|
||||||
@@ -271,3 +270,25 @@ def requires_skill(skill: str, tools: tuple[str, ...]) -> PolicyRule:
|
|||||||
)
|
)
|
||||||
|
|
||||||
return rule
|
return rule
|
||||||
|
|
||||||
|
|
||||||
|
ZONES = Zones(
|
||||||
|
vault=VAULT,
|
||||||
|
write=(BEAVER,),
|
||||||
|
create=(CHATS,),
|
||||||
|
edit=(VAULT,),
|
||||||
|
protected=(VAULT / ".obsidian", VAULT / "мета"),
|
||||||
|
)
|
||||||
|
"""Vault - дом диспетчера: создавать, править, переносить можно везде, кроме
|
||||||
|
`.obsidian` и `мета` вне `бобер`; удалять - только своё и новые чаты."""
|
||||||
|
|
||||||
|
STRICT_ZONES = Zones(vault=VAULT, write=(BEAVER,), create=(CHATS,))
|
||||||
|
"""Дистиллятор и куратор: писать только в своей зоне."""
|
||||||
|
|
||||||
|
FIREFLY_WRITES = ("mcp__firefly__store_*", "mcp__firefly__update_*")
|
||||||
|
VAULT_POLICY = (
|
||||||
|
skill_tracker(),
|
||||||
|
vault_zones(ZONES),
|
||||||
|
bash_zones(ZONES),
|
||||||
|
requires_skill("firefly", FIREFLY_WRITES),
|
||||||
|
)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Как собирается системный промпт каждой роли из гранул vault.
|
||||||
|
|
||||||
|
Гранула - чистый markdown; xml-тег, в который она заворачивается, задаётся
|
||||||
|
здесь. Голос идёт первым и одинаков у всех, кто говорит с Бобром; окружение
|
||||||
|
зависит от вида окна (мастер, ветка, глубокий, джоб); оверлей - от роли.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from beaver_gateway.agents.claude import Prompts
|
||||||
|
from beaver_gateway.agents.prompts import assemble
|
||||||
|
|
||||||
|
from beaver_agent.vault import GRANULES, PROMPTS
|
||||||
|
|
||||||
|
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(window: str) -> tuple[str, str]:
|
||||||
|
return ("environment", str(GRANULES / "окружения" / f"{window}.md"))
|
||||||
|
|
||||||
|
|
||||||
|
def overlay(tag: str, name: str) -> tuple[str, str]:
|
||||||
|
return (tag, str(GRANULES / f"{name}.md"))
|
||||||
|
|
||||||
|
|
||||||
|
BASE = (*VOICE, PROFILE, CORRECTIONS, VAULT_MAP)
|
||||||
|
"""Голос, профиль Бобра, его поправки и карта vault - у всех, кто с ним говорит."""
|
||||||
|
|
||||||
|
DISTILL_AS_JOB = (*BASE, environment("джоб"), overlay("distiller", "дистиллятор"))
|
||||||
|
"""Форк ветки пишет слив: тот же голос, окружение джоба, оверлей дистиллятора."""
|
||||||
|
|
||||||
|
DISPATCHER = Prompts(
|
||||||
|
master=(*BASE, environment("мастер"), overlay("dispatcher", "диспетчер")),
|
||||||
|
branch=(*BASE, environment("ветка"), overlay("branch", "ветка")),
|
||||||
|
fork=DISTILL_AS_JOB,
|
||||||
|
job=DISTILL_AS_JOB,
|
||||||
|
)
|
||||||
|
DEEP = Prompts(deep=(*BASE, environment("глубокий"), overlay("deep", "глубокий")))
|
||||||
|
|
||||||
|
DISTILLER_ONLY = (PROFILE, environment("джоб"), overlay("distiller", "дистиллятор"))
|
||||||
|
"""Дистиллятор без голоса - иначе выжимки получаются с матом."""
|
||||||
|
DISTILLER = Prompts(fork=DISTILLER_ONLY, job=DISTILLER_ONLY)
|
||||||
|
TRIAGE = Prompts(job=(PROFILE, environment("джоб"), overlay("triage", "триаж")))
|
||||||
|
CURATOR = Prompts(job=(*BASE, environment("джоб"), overlay("curator", "куратор")))
|
||||||
|
|
||||||
|
QUICK = assemble((*VOICE, PROFILE, overlay("quick", "быстрый")))
|
||||||
|
"""Raycast-агенты: голос и профиль одной строкой, без vault."""
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Наборы скиллов по окнам: папка `мета/бобер/скиллы/<набор>` = плагин.
|
||||||
|
|
||||||
|
Мастер не получает `vault/` (firefly, Дерден): для этого есть ветка и
|
||||||
|
глубокий чат. Форк и джоб диспетчера скиллов не грузят вовсе.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from beaver_gateway.agents.claude import SkillSets
|
||||||
|
|
||||||
|
from beaver_agent.vault import SKILLS
|
||||||
|
|
||||||
|
COMMON = SKILLS / "общие"
|
||||||
|
DISPATCHER_ONLY = SKILLS / "диспетчер"
|
||||||
|
VAULT_ONLY = SKILLS / "vault"
|
||||||
|
|
||||||
|
DISPATCHER = SkillSets(
|
||||||
|
master=(COMMON, DISPATCHER_ONLY),
|
||||||
|
branch=(COMMON, DISPATCHER_ONLY, VAULT_ONLY),
|
||||||
|
fork=(),
|
||||||
|
job=(),
|
||||||
|
)
|
||||||
|
DEEP = (COMMON, VAULT_ONLY)
|
||||||
|
|
||||||
|
MASTER_FORBIDDEN = (
|
||||||
|
tuple(p.name for p in sorted(VAULT_ONLY.iterdir()) if p.is_dir())
|
||||||
|
if VAULT_ONLY.exists()
|
||||||
|
else ()
|
||||||
|
)
|
||||||
|
"""Без набора скилл и так не найдётся; правило нужно ради внятного отказа."""
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""Всё, что gateway говорит модели и Бобру, по-русски."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from beaver_gateway.conversations.texts import ConversationTexts, EnvelopeTexts
|
||||||
|
from beaver_gateway.frontends.telegram import TelegramTexts
|
||||||
|
|
||||||
|
from beaver_agent.memory.handouts import (
|
||||||
|
distill_prompt,
|
||||||
|
handout_prompt,
|
||||||
|
new_day,
|
||||||
|
seed_body,
|
||||||
|
)
|
||||||
|
from beaver_agent.vault import GRANULES, read
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from beaver_gateway.conversations.injects import InjectContext
|
||||||
|
|
||||||
|
BEAVER_ORIGINS = frozenset({"panel", "api"})
|
||||||
|
"""Панель и API - это сам Бобёр; крон, watch, komodo, ротация - нет."""
|
||||||
|
|
||||||
|
ORIGINS = {
|
||||||
|
"merge": "слив",
|
||||||
|
"digest": "выжимка",
|
||||||
|
"rotation": "ротация",
|
||||||
|
"cap": "потолок",
|
||||||
|
"system": "система",
|
||||||
|
"agent": "агент",
|
||||||
|
}
|
||||||
|
|
||||||
|
INTERRUPTED_TURN = (
|
||||||
|
"[этот инжект прервал предыдущий тёрн: «Request interrupted» выше - "
|
||||||
|
"прерывание, не отказ от тулзы]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def inject_header(ctx: InjectContext) -> str:
|
||||||
|
if ctx.origin in BEAVER_ORIGINS:
|
||||||
|
head = (
|
||||||
|
"[инжект из панели: это Бобёр, ответ он видит в панели; "
|
||||||
|
"в телегу - только через say]"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
origin = ORIGINS.get(ctx.origin, ctx.origin)
|
||||||
|
head = (
|
||||||
|
f"[инжект: {origin} - это не Бобёр, отвечать не нужно, голос не обязателен]"
|
||||||
|
)
|
||||||
|
return f"{head}\n{INTERRUPTED_TURN}" if ctx.interrupted_turn else head
|
||||||
|
|
||||||
|
|
||||||
|
ENVELOPE = EnvelopeTexts(
|
||||||
|
header=(
|
||||||
|
"[конверт - фоновый сигнал, не обращение; "
|
||||||
|
"реагируй, только если относится к вопросу]"
|
||||||
|
),
|
||||||
|
time="время: {stamp} ({zone})",
|
||||||
|
changed="vault, изменено {since}: {names}",
|
||||||
|
since="с {time}",
|
||||||
|
since_start="со старта",
|
||||||
|
truncated="… (потолок конверта)",
|
||||||
|
file_header="--- {path}, только добавленное ---",
|
||||||
|
more_lines="+ … ещё {count}",
|
||||||
|
)
|
||||||
|
|
||||||
|
MERGE_PROMPT = read(GRANULES / "слив.md") or (
|
||||||
|
"Ветка закрывается. Напиши слив для мастера: что решили, что сделано, "
|
||||||
|
"что не сделано и почему, открытые вопросы. Идентификаторы и ссылки - "
|
||||||
|
"дословно. Коротко, прошедшее время."
|
||||||
|
)
|
||||||
|
|
||||||
|
CONVERSATIONS = ConversationTexts(
|
||||||
|
merge_prompt=MERGE_PROMPT,
|
||||||
|
inject_header=inject_header,
|
||||||
|
bundle_header="[инжекты, накопившиеся с {since}; это не Бобёр]",
|
||||||
|
interrupted="прервано",
|
||||||
|
answered="Пользователь ответил: {answer}",
|
||||||
|
unanswered=(
|
||||||
|
"Пользователь не ответил за {minutes} мин. Вопрос ему показан текстом; "
|
||||||
|
"заверши тёрн сейчас, ответ придёт следующим сообщением."
|
||||||
|
),
|
||||||
|
seed=seed_body,
|
||||||
|
seed_head="[сид: {seed}] {kind}{title}, {stamp}.",
|
||||||
|
seed_copy="История родителя скопирована ({scope}); продолжай в ней.",
|
||||||
|
seed_copy_window="последние {window} тёрнов",
|
||||||
|
seed_copy_all="вся история",
|
||||||
|
seed_morning_missing="Хендаут не приехал.",
|
||||||
|
handout=handout_prompt,
|
||||||
|
new_day=new_day,
|
||||||
|
moved_injects=" Инжектов переехало из старого мастера: {moved}.",
|
||||||
|
distill=distill_prompt,
|
||||||
|
closed="Закрыт глубокий чат [[{chat}]]{digest}.\n{text}",
|
||||||
|
closed_digest=", выжимка [[{digest}]]",
|
||||||
|
digest_missing="файл выжимки не появился",
|
||||||
|
too_long=(
|
||||||
|
"`{name}`: {lines} строк при потолке {max_lines}. Запись отбита, файл "
|
||||||
|
"возвращён к прежней версии. Сократи и перепиши."
|
||||||
|
),
|
||||||
|
cut_by_restart="тёрн {turn_id} оборван рестартом gateway",
|
||||||
|
repaired_tools=(
|
||||||
|
"; {fixed} незакрытых тул-коллов получили tool_result «{interrupted}»"
|
||||||
|
),
|
||||||
|
envelope=ENVELOPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
TELEGRAM = TelegramTexts(
|
||||||
|
commands=(
|
||||||
|
("merge", "слить ветку в мастер"),
|
||||||
|
("new", "новая ветка: в General - новый топик, в топике - заново на нём"),
|
||||||
|
("chat", "открыть глубокий чат в vault: /chat тема"),
|
||||||
|
("status", "что с этим разговором"),
|
||||||
|
("help", "команды"),
|
||||||
|
),
|
||||||
|
help_head="General - мастер, любой другой топик - ветка.",
|
||||||
|
branch="ветка",
|
||||||
|
waiting="⏳ думаю",
|
||||||
|
thinking="🤔 думаю",
|
||||||
|
writing="✍️ пишу",
|
||||||
|
tool_labels={
|
||||||
|
"Read": "читаю vault",
|
||||||
|
"Glob": "ищу файлы",
|
||||||
|
"Grep": "ищу в vault",
|
||||||
|
"Edit": "правлю файл",
|
||||||
|
"Write": "пишу файл",
|
||||||
|
"MultiEdit": "правлю файлы",
|
||||||
|
"Bash": "выполняю команду",
|
||||||
|
"WebSearch": "ищу в сети",
|
||||||
|
"WebFetch": "читаю страницу",
|
||||||
|
"Task": "запустил сабагента",
|
||||||
|
"Agent": "запустил сабагента",
|
||||||
|
"AskUserQuestion": "спрашиваю",
|
||||||
|
"TodoWrite": "планирую",
|
||||||
|
"Skill": "открываю скилл",
|
||||||
|
"mcp__gateway__spawn": "открываю разговор",
|
||||||
|
"mcp__gateway__read_conversation": "читаю разговор",
|
||||||
|
"mcp__gateway__say": "говорю",
|
||||||
|
"mcp__gateway__schedule": "ставлю напоминание",
|
||||||
|
"mcp__gateway__inject": "передаю в другой разговор",
|
||||||
|
},
|
||||||
|
attachment_sticker="[вложение: стикер - не поддерживается]",
|
||||||
|
attachment_animation="[вложение: анимация - не поддерживается]",
|
||||||
|
attachment_kinds={
|
||||||
|
"photo": "фото",
|
||||||
|
"document": "файл",
|
||||||
|
"voice": "голосовое",
|
||||||
|
"audio": "аудио",
|
||||||
|
"video": "видео",
|
||||||
|
"video_note": "кружок",
|
||||||
|
},
|
||||||
|
attachment_too_big=(
|
||||||
|
"[вложение: {kind} {name}, {mb} МБ - больше 20 МБ, Telegram не отдаёт ботам]"
|
||||||
|
),
|
||||||
|
attachment_failed="[вложение: {kind} {name} - не скачалось: {error}]",
|
||||||
|
attachment_size=", {kb} КБ",
|
||||||
|
attachment="[вложение: {kind} {path}{size}]",
|
||||||
|
attachment_kept=(
|
||||||
|
"[вложение: {kind} {path}{size}; хранится {days} дн., "
|
||||||
|
"перенеси в vault, если нужно]"
|
||||||
|
),
|
||||||
|
merge_nothing="сливать нечего: это не открытая ветка",
|
||||||
|
merging="🔀 сливаю в мастер",
|
||||||
|
merged="✅ слито в мастер",
|
||||||
|
merge_failed="⚠️ слив не удался: {error}",
|
||||||
|
new_branch_topic="🌿 ветка «{title}» - в новом топике",
|
||||||
|
new_branch_here="🌿 новая ветка на этом топике",
|
||||||
|
chat_usage="/chat <тема>",
|
||||||
|
chat_failed="не вышло: {error}",
|
||||||
|
chat_opened="💬 глубокий чат: {where}",
|
||||||
|
status_unbound="этот топик ни к чему не привязан - напиши, и откроется ветка",
|
||||||
|
status_line="сессия: {live} · тёрн: {turn} · в очереди: {queued}",
|
||||||
|
status_live="живая",
|
||||||
|
status_dead="нет",
|
||||||
|
status_running="идёт",
|
||||||
|
status_idle="нет",
|
||||||
|
status_question="❓ ждёт ответа на вопрос",
|
||||||
|
status_pool="пул: {sessions} сессий, rss {mb} МБ",
|
||||||
|
status_outbox="outbox: {pending} в очереди",
|
||||||
|
turn_failed="⚠️ тёрн упал, смотри логи gateway",
|
||||||
|
background_failed="⚠️ фоновый тёрн ({origin}) упал, смотри логи gateway",
|
||||||
|
question_closed="вопрос уже закрыт",
|
||||||
|
question_timeout="⌛ время вышло - ответь текстом",
|
||||||
|
done_button="✅ готово",
|
||||||
|
mirrored="📝 из панели:\n{text}",
|
||||||
|
)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Где что лежит в vault и в какой зоне живёт агент."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
TZ = "Europe/Warsaw"
|
||||||
|
ZONE = ZoneInfo(TZ)
|
||||||
|
LAUNCH = datetime(2026, 8, 28, tzinfo=ZONE)
|
||||||
|
"""Ночное закрытие трогает только чаты с активностью после запуска системы."""
|
||||||
|
|
||||||
|
VAULT = Path(os.environ.get("BEAVER_VAULT", "/vault"))
|
||||||
|
"""`/vault` в контейнере; `BEAVER_VAULT` - чтобы собрать конфиг на маке."""
|
||||||
|
CHATS = VAULT / "💬 чаты"
|
||||||
|
DIARY = VAULT / "📅 дни"
|
||||||
|
PEOPLE = VAULT / "👤 люди"
|
||||||
|
BOARDS = VAULT / "📆 доски"
|
||||||
|
|
||||||
|
BEAVER = VAULT / "мета" / "бобер"
|
||||||
|
"""Дом агента: всё, что он ведёт сам, лежит здесь."""
|
||||||
|
PROMPTS = BEAVER / "промпты"
|
||||||
|
GRANULES = PROMPTS / "гранулы"
|
||||||
|
SKILLS = BEAVER / "скиллы"
|
||||||
|
DAYS = BEAVER / "дни"
|
||||||
|
DIGESTS = BEAVER / "выжимки"
|
||||||
|
INDEX = BEAVER / "индекс.md"
|
||||||
|
NOTES = BEAVER / "наблюдения"
|
||||||
|
PORTRAIT = NOTES / "Бобёр - наблюдения.md"
|
||||||
|
REPLIES = BEAVER / "реплики"
|
||||||
|
CURATOR_JOURNAL = BEAVER / "куратор.md"
|
||||||
|
STATE = BEAVER / "состояние.md"
|
||||||
|
STATE_MAX_LINES = 60
|
||||||
|
ATTACHMENTS = BEAVER / "вложения"
|
||||||
|
|
||||||
|
|
||||||
|
def today() -> str:
|
||||||
|
return datetime.now(ZONE).date().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def read(path: Path) -> str | None:
|
||||||
|
return path.read_text(encoding="utf-8").strip() if path.exists() else None
|
||||||
@@ -1,913 +1,46 @@
|
|||||||
import logging
|
"""Сборка gateway для Бобра. Что где: пакет `beaver_agent/`.
|
||||||
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
|
- vault.py - пути и зона агента; prompts.py - как из гранул собирается промпт роли;
|
||||||
from beaver_gateway.agents.claude import ClaudeAgent, ClaudeOptions, Prompts, SkillSets
|
- skills.py - наборы скиллов по окнам; policy.py - что можно трогать в vault;
|
||||||
from beaver_gateway.agents.raycast import RaycastAgent, RemoteTool, UserPreferences
|
- hands/ - MCP-руки и кому какая выдана; agents.py - роли и их экземпляры;
|
||||||
from beaver_gateway.core.conversations import (
|
- frontends.py - окна; texts.py - всё, что gateway говорит, по-русски;
|
||||||
ConversationTexts,
|
- memory/ - справка, реплики, хендауты, вотчер, куратор; jobs/ - кроны и вебхуки.
|
||||||
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 Attachments, TelegramFrontend
|
|
||||||
from beaver_gateway.mcp.types import HttpMcp, McpServer, McpServerT
|
|
||||||
|
|
||||||
from curator import Watched, briefing
|
|
||||||
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,
|
|
||||||
forbid_skills,
|
|
||||||
requires_skill,
|
|
||||||
skill_tracker,
|
|
||||||
vault_zones,
|
|
||||||
)
|
|
||||||
from recall import Recall, ReplyLog, notes_head
|
|
||||||
|
|
||||||
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"
|
|
||||||
NOTES = BEAVER / "наблюдения"
|
|
||||||
REPLIES = BEAVER / "реплики"
|
|
||||||
CURATOR_JOURNAL = BEAVER / "куратор.md"
|
|
||||||
PORTRAIT = NOTES / "Бобёр - наблюдения.md"
|
|
||||||
PEOPLE = VAULT / "👤 люди"
|
|
||||||
BOARDS = VAULT / "📆 доски"
|
|
||||||
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", "триаж")))
|
|
||||||
# Куратор памяти - тот же менеджер бобрения (голос и всё), в окружении джоба.
|
|
||||||
CURATOR_PROMPTS = Prompts(
|
|
||||||
job=(*_BASE, environment("джоб"), overlay("curator", "куратор"))
|
|
||||||
)
|
|
||||||
QUICK_PROMPT = assemble((*VOICE, PROFILE, overlay("quick", "быстрый")))
|
|
||||||
|
|
||||||
# §4.3: наборы скиллов = папки = плагины. Глубокие грузят общие + vault;
|
|
||||||
# мастер - без vault (firefly, beaver durden - только в ветке и в глубоком);
|
|
||||||
# форк и джоб диспетчера - без скиллов вовсе.
|
|
||||||
DEEP_SKILLS = (SKILLS / "общие", SKILLS / "vault")
|
|
||||||
DISPATCHER_SKILLS = SkillSets(
|
|
||||||
master=(SKILLS / "общие", SKILLS / "диспетчер"),
|
|
||||||
branch=(SKILLS / "общие", SKILLS / "диспетчер", SKILLS / "vault"),
|
|
||||||
fork=(),
|
|
||||||
job=(),
|
|
||||||
)
|
|
||||||
# Мастер не становится Дерденом: без набора скилл и так не найдётся, правило -
|
|
||||||
# ради внятного отказа («открой ветку»).
|
|
||||||
MASTER_FORBIDDEN_SKILLS = (
|
|
||||||
tuple(p.name for p in sorted((SKILLS / "vault").iterdir()) if p.is_dir())
|
|
||||||
if (SKILLS / "vault").exists()
|
|
||||||
else ()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# §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 position, raw_entry in enumerate(raw.split(","), start=1):
|
|
||||||
entry = raw_entry.strip()
|
|
||||||
if not entry:
|
|
||||||
continue
|
|
||||||
name, sep, url = entry.partition("=")
|
|
||||||
if not sep or not url.strip():
|
|
||||||
# Только номер: в url лежит приватный ical-фид с токеном в пути,
|
|
||||||
# а traceback уезжает в docker logs целиком.
|
|
||||||
msg = f"CALENDAR_MCPS: запись #{position} не вида `name=url`"
|
|
||||||
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 ()
|
|
||||||
|
|
||||||
# §4.4: msos - таскер рабочих проектов (скилл `задачи`), URL с токеном в env,
|
|
||||||
# только у диспетчера.
|
|
||||||
msos_mcps = (
|
|
||||||
[McpServer.http(name="msos", url=os.environ["MSOS_MCP"])]
|
|
||||||
if os.environ.get("MSOS_MCP")
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
msos_exposed = (ExposedMcp(name="msos"),) if msos_mcps 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,
|
|
||||||
*msos_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,
|
|
||||||
forbid_skills(MASTER_FORBIDDEN_SKILLS, kinds=("master",)),
|
|
||||||
),
|
|
||||||
# §4.4: t3code и вайбграм - руки диспетчера (мастер, ветки), глубоким
|
|
||||||
# не даются: наружу и в код ходит только он.
|
|
||||||
expose_mcps=(*CLAUDE_MCPS, *t3code_exposed, *vibegram_exposed, *msos_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 curator(name: str, model: str, effort: str | None = None) -> ClaudeAgent:
|
|
||||||
"""Куратор памяти: opus high, файлы в мета/бобер, без MCP, `inject` в мастер."""
|
|
||||||
return ClaudeAgent(
|
|
||||||
name=name,
|
|
||||||
model=model,
|
|
||||||
cwd=VAULT,
|
|
||||||
prompts=CURATOR_PROMPTS,
|
|
||||||
gateway_tools=("inject",),
|
|
||||||
options=ClaudeOptions(
|
|
||||||
effort=effort,
|
|
||||||
tools=("Read", "Write", "Edit", "Grep", "Glob"),
|
|
||||||
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-1", effort="high"),
|
|
||||||
deep("beaver-opus-medium", "claude-opus-5", effort="medium"),
|
|
||||||
deep("beaver-fable-medium", "claude-fable-5-1", effort="medium"),
|
|
||||||
deep("beaver-opus-xhigh", "claude-opus-5", effort="xhigh"),
|
|
||||||
distiller("beaver-distiller", "claude-opus-5", effort="medium"),
|
|
||||||
curator("beaver-curator", "claude-opus-5", effort="high"),
|
|
||||||
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",
|
|
||||||
# §3.8: вложения - входящая папка в зоне агента, по дням; что стоит
|
|
||||||
# хранить, он переносит к заметке сам, остальное сметается через месяц.
|
|
||||||
attachments=Attachments(
|
|
||||||
mode="vault", dir=BEAVER / "вложения", keep_days=30, tz=TZ
|
|
||||||
),
|
|
||||||
)
|
|
||||||
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}"
|
|
||||||
)
|
|
||||||
portrait = notes_head(PORTRAIT, ("сейчас", "паттерны"), cap=40)
|
|
||||||
if portrait:
|
|
||||||
parts.append(
|
|
||||||
"Портрет Бобра (шапка "
|
|
||||||
f"`{PORTRAIT.relative_to(VAULT)}`, лента - там же):\n{portrait}"
|
|
||||||
)
|
|
||||||
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=(
|
|
||||||
"💬 чаты/**",
|
|
||||||
"мета/**",
|
|
||||||
"мета/бобер/реплики/**", # пишет сам gateway на каждое сообщение
|
|
||||||
"**/attachments/**",
|
|
||||||
".obsidian/**",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
tz=TZ,
|
|
||||||
)
|
|
||||||
|
|
||||||
# §3.3: справка под сообщением - карточка и записки агента по упомянутым людям,
|
|
||||||
# дни, где они встречались, раз в день сроки из досок; лог реплик Бобра -
|
|
||||||
# единственное grep-абельное место для сказанного в телеге (§6.0 «спящее»).
|
|
||||||
recall = Recall(
|
|
||||||
vault=VAULT,
|
|
||||||
people_dir=PEOPLE,
|
|
||||||
notes_dir=NOTES,
|
|
||||||
diary_dir=DIARY,
|
|
||||||
boards_dir=BOARDS,
|
|
||||||
tz=TZ,
|
|
||||||
)
|
|
||||||
replies = ReplyLog(REPLIES, 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),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
CURATOR_WATCH = [
|
|
||||||
Watched("хендауты", DAYS, "????-??-??.md"),
|
|
||||||
Watched("выжимки", DIGESTS, "*.md"),
|
|
||||||
Watched("наблюдения", NOTES, "*.md"),
|
|
||||||
Watched("состояние", BEAVER, "состояние.md"),
|
|
||||||
Watched("дневник", DIARY, "????-??-??.md"),
|
|
||||||
Watched("карточки", PEOPLE, "**/*.md"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def curate(run: JobRun) -> None:
|
|
||||||
"""§6.0: куратор памяти раз в несколько часов.
|
|
||||||
|
|
||||||
Крон собирает «что нового», модель раскладывает по шапкам и лентам;
|
|
||||||
нечего раскладывать - джоб не спавнится.
|
|
||||||
"""
|
"""
|
||||||
text = briefing(
|
|
||||||
vault=VAULT,
|
|
||||||
journal=CURATOR_JOURNAL,
|
|
||||||
replies_dir=REPLIES,
|
|
||||||
watched=CURATOR_WATCH,
|
|
||||||
tz=TZ,
|
|
||||||
)
|
|
||||||
if text is None:
|
|
||||||
_log.info("curator: nothing new since the last run")
|
|
||||||
return
|
|
||||||
await run.spawn_job(agent="beaver-curator", title="куратор", text=text)
|
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
T3CODE_QUESTION_HINT = (
|
from beaver_gateway.config import Gateway
|
||||||
"Если ответ следует из задания - ответь сам: t3_answer(thread_id, "
|
from beaver_gateway.conversations.distill import Distiller
|
||||||
"{id вопроса: label}). Не знаешь - спроси Бобра через say своими словами, "
|
from beaver_gateway.conversations.rotation import RotationPolicy
|
||||||
"потом t3_answer. Тред стоит, пока не ответят."
|
from beaver_gateway.jobs.scheduler import Budget
|
||||||
)
|
|
||||||
|
|
||||||
|
from beaver_agent import texts
|
||||||
def t3code_text(p: dict[str, Any]) -> str:
|
from beaver_agent.agents import agents
|
||||||
head = (
|
from beaver_agent.frontends import frontends
|
||||||
f"t3code: тред «{p.get('title')}» ({p.get('machine')}, {p.get('project')}, "
|
from beaver_agent.hands import mcps
|
||||||
f"thread_id {p.get('thread_id')})"
|
from beaver_agent.jobs import jobs
|
||||||
)
|
from beaver_agent.memory.watch import recall, replies, watch
|
||||||
event = p.get("event")
|
from beaver_agent.vault import DIGESTS, INDEX, TZ
|
||||||
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),
|
|
||||||
# кроны считаются в Gateway.tz (Варшава), не в UTC контейнера;
|
|
||||||
# руками - POST /api/jobs/куратор/run
|
|
||||||
Job("куратор", curate, cron="0 2,8,12,16,20 * * *", 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(
|
gateway = Gateway(
|
||||||
agents=agents,
|
agents=agents,
|
||||||
mcps=mcps,
|
mcps=mcps,
|
||||||
frontends=frontends,
|
frontends=frontends,
|
||||||
texts=texts,
|
texts=texts.CONVERSATIONS,
|
||||||
jobs=jobs,
|
jobs=jobs,
|
||||||
# Окно модели - 1M; context rot начинается около половины (эмпирика h по
|
|
||||||
# счётчику Claude Code), считаем как он - вход последнего API-вызова.
|
|
||||||
rotation=RotationPolicy(tz=TZ, max_context_tokens=500_000),
|
rotation=RotationPolicy(tz=TZ, max_context_tokens=500_000),
|
||||||
watch=watch,
|
watch=watch,
|
||||||
recall=recall.block,
|
recall=recall.block,
|
||||||
user_sink=replies.write,
|
user_sink=replies.write,
|
||||||
budget=Budget(threshold=0.7),
|
budget=Budget(threshold=0.7),
|
||||||
distiller=Distiller(agent="beaver-distiller", dir=DIGESTS, index=INDEX),
|
distiller=Distiller(
|
||||||
|
agent="beaver-distiller",
|
||||||
|
dir=DIGESTS,
|
||||||
|
index=INDEX,
|
||||||
|
type="выжимка",
|
||||||
|
index_header="# индекс\n\nстрока на выжимку: чат → его выжимка.\n",
|
||||||
|
),
|
||||||
tz=TZ,
|
tz=TZ,
|
||||||
port=62990,
|
port=62990,
|
||||||
public_url=PUBLIC_BASE_URL or None,
|
public_url=os.environ.get("PUBLIC_BASE_URL", "").rstrip("/") or None,
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-4
@@ -59,10 +59,7 @@ services:
|
|||||||
- "${PORT_GATEWAY:-62990}:62990"
|
- "${PORT_GATEWAY:-62990}:62990"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.py:/config/config.py:ro
|
- ./config.py:/config/config.py:ro
|
||||||
- ./policy.py:/config/policy.py:ro
|
- ./beaver_agent:/config/beaver_agent:ro
|
||||||
- ./recall.py:/config/recall.py:ro
|
|
||||||
- ./curator.py:/config/curator.py:ro
|
|
||||||
- ./mcps:/config/mcps:ro
|
|
||||||
- ./config.json:/config/config.json:ro
|
- ./config.json:/config/config.json:ro
|
||||||
# §3.7: vault - дом диспетчера, rw целиком; границы (`.obsidian`,
|
# §3.7: vault - дом диспетчера, rw целиком; границы (`.obsidian`,
|
||||||
# `мета` вне `бобер`, удаление чужого) держит policy.py (2026-08-30).
|
# `мета` вне `бобер`, удаление чужого) держит policy.py (2026-08-30).
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ unfixable = ["F401"]
|
|||||||
|
|
||||||
[lint.per-file-ignores]
|
[lint.per-file-ignores]
|
||||||
"config.py" = ["INP001"]
|
"config.py" = ["INP001"]
|
||||||
"policy.py" = ["INP001"]
|
|
||||||
"tests/*" = ["ALL"]
|
"tests/*" = ["ALL"]
|
||||||
|
|
||||||
[lint.pydocstyle]
|
[lint.pydocstyle]
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ from pathlib import Path
|
|||||||
|
|
||||||
HERE = Path(__file__).resolve().parent
|
HERE = Path(__file__).resolve().parent
|
||||||
sys.path.insert(0, str(HERE.parent))
|
sys.path.insert(0, str(HERE.parent))
|
||||||
from beaver_gateway.core.prompt import assemble # noqa: E402
|
from beaver_gateway.agents.prompts import assemble # noqa: E402
|
||||||
|
|
||||||
from recall import People # noqa: E402
|
from beaver_agent.memory.recall import People # noqa: E402
|
||||||
|
|
||||||
VAULT = Path.home() / "Documents/obsidian/hh"
|
VAULT = Path.home() / "Documents/obsidian/hh"
|
||||||
BEAVER = VAULT / "мета/бобер"
|
BEAVER = VAULT / "мета/бобер"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from curator import Watched, briefing, last_run, new_replies
|
from beaver_agent.memory.curator import Watched, briefing, last_run, new_replies
|
||||||
|
|
||||||
|
|
||||||
def test_last_run_reads_first_line_or_default(tmp_path: Path) -> None:
|
def test_last_run_reads_first_line_or_default(tmp_path: Path) -> None:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from beaver_gateway.mcp.wrap import build_python_tool_server
|
|||||||
from fastmcp import Client
|
from fastmcp import Client
|
||||||
from fastmcp.exceptions import ToolError
|
from fastmcp.exceptions import ToolError
|
||||||
|
|
||||||
from mcps.komodo import Komodo
|
from beaver_agent.hands.komodo import Komodo
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from mcps.komodo_alerts import HOLD, AlertMemory, describe, parse, still_bad
|
from beaver_agent.hands.komodo_alerts import (
|
||||||
|
HOLD,
|
||||||
|
AlertMemory,
|
||||||
|
describe,
|
||||||
|
parse,
|
||||||
|
still_bad,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def alert(kind, *, resolved=False, level="CRITICAL", **data):
|
def alert(kind, *, resolved=False, level="CRITICAL", **data):
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import tempfile
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from beaver_gateway.core.policy import Deny, ToolCall
|
from beaver_gateway.agents.policy import Deny, ToolCall
|
||||||
|
|
||||||
from policy import (
|
from beaver_agent.policy import (
|
||||||
Zones,
|
Zones,
|
||||||
bash_zones,
|
bash_zones,
|
||||||
forbid_skills,
|
forbid_skills,
|
||||||
|
|||||||
+10
-3
@@ -1,10 +1,17 @@
|
|||||||
from datetime import UTC, date, datetime
|
from datetime import UTC, date, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from beaver_gateway.core.conversations import UserSaid
|
from beaver_gateway.conversations.texts import UserSaid
|
||||||
from beaver_gateway.core.envelope import RecallContext
|
from beaver_gateway.conversations.envelope import RecallContext
|
||||||
|
|
||||||
from recall import LABEL, Recall, ReplyLog, frontmatter_aliases, mentions, notes_head
|
from beaver_agent.memory.recall import (
|
||||||
|
LABEL,
|
||||||
|
Recall,
|
||||||
|
ReplyLog,
|
||||||
|
frontmatter_aliases,
|
||||||
|
mentions,
|
||||||
|
notes_head,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _card(path: Path, aliases: list[str] | None = None) -> None:
|
def _card(path: Path, aliases: list[str] | None = None) -> None:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from beaver_gateway.mcp.wrap import build_python_tool_server
|
|||||||
from fastmcp import Client
|
from fastmcp import Client
|
||||||
from fastmcp.exceptions import ToolError
|
from fastmcp.exceptions import ToolError
|
||||||
|
|
||||||
from mcps.vibegram import Vibegram, _cards, _event, _work
|
from beaver_agent.hands.vibegram import Vibegram, _cards, _event, _work
|
||||||
|
|
||||||
|
|
||||||
def test_events_render_and_unknown_kinds_are_skipped():
|
def test_events_render_and_unknown_kinds_are_skipped():
|
||||||
|
|||||||
Reference in New Issue
Block a user