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
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Home Assistant как python_tool с перечислимыми действиями.
|
||||
|
||||
Токен HA живёт в gateway и в контекст модели не попадает. Действия:
|
||||
найти сущности, посмотреть состояние, вызвать сервис, перечислить сервисы
|
||||
домена. Ничего про конфигурацию, пользователей и интеграции - только
|
||||
REST `/api/states` и `/api/services`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
import aiohttp
|
||||
|
||||
__all__ = ["Action", "HomeAssistant"]
|
||||
|
||||
Action = Literal["search", "state", "call", "services"]
|
||||
|
||||
SEARCH_LIMIT = 60
|
||||
ATTR_LIMIT = 25
|
||||
# Не влезает в ответ и не нужно модели: гигантские списки и бинарники.
|
||||
NOISY_ATTRS = frozenset({"entity_picture", "icon", "supported_color_modes", "options"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HomeAssistant:
|
||||
url: str
|
||||
token: str
|
||||
timeout: float = 20.0
|
||||
max_chars: int = 8_000
|
||||
|
||||
async def ha(
|
||||
self,
|
||||
action: Action,
|
||||
*,
|
||||
query: str | None = None,
|
||||
entity: str | None = None,
|
||||
domain: str | None = None,
|
||||
service: str | None = None,
|
||||
data: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""Home Assistant - умный дом: свет, кнопки ИК-пульта, сцены, скрипты, датчики.
|
||||
|
||||
action:
|
||||
- search: сущности по подстроке в id или имени (query; пусто - все),
|
||||
сгруппированы по домену. Начни отсюда: точные entity_id нужны везде.
|
||||
- state: состояние и атрибуты одной сущности (entity).
|
||||
- call: вызвать сервис (domain, service, entity опционально, data -
|
||||
доп. поля: brightness, color_temp, …). Примеры: light/turn_on,
|
||||
light/turn_off, script/turn_on, scene/turn_on, button/press.
|
||||
- services: какие сервисы есть у домена (domain) и их поля.
|
||||
|
||||
Делай то, о чём попросили: «выключи свет» - это одна сущность или
|
||||
группа, не весь дом.
|
||||
"""
|
||||
match action:
|
||||
case "search":
|
||||
return await self._search(query or "")
|
||||
case "state":
|
||||
return await self._state(_need(entity, "entity"))
|
||||
case "call":
|
||||
return await self._call(
|
||||
_need(domain, "domain"), _need(service, "service"), entity, data
|
||||
)
|
||||
case "services":
|
||||
return await self._services(_need(domain, "domain"))
|
||||
|
||||
async def _search(self, query: str) -> str:
|
||||
states = await self._get("/api/states")
|
||||
q = query.lower()
|
||||
hits = [
|
||||
s
|
||||
for s in states
|
||||
if q in s["entity_id"].lower()
|
||||
or q in str(s.get("attributes", {}).get("friendly_name", "")).lower()
|
||||
]
|
||||
if not hits:
|
||||
return "ничего не нашлось"
|
||||
by_domain: dict[str, list[dict[str, Any]]] = {}
|
||||
for s in sorted(hits, key=lambda s: s["entity_id"]):
|
||||
by_domain.setdefault(s["entity_id"].split(".", 1)[0], []).append(s)
|
||||
lines = []
|
||||
shown = 0
|
||||
for dom, items in by_domain.items():
|
||||
lines.append(f"{dom}:")
|
||||
for s in items:
|
||||
if shown >= SEARCH_LIMIT:
|
||||
lines.append(f" … ещё {len(hits) - shown}, уточни query")
|
||||
return "\n".join(lines)
|
||||
name = s.get("attributes", {}).get("friendly_name") or ""
|
||||
lines.append(f" {s['entity_id']:40} {s['state']:12} {name}")
|
||||
shown += 1
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _state(self, entity: str) -> str:
|
||||
s = await self._get(f"/api/states/{entity}")
|
||||
attrs = {
|
||||
k: v for k, v in (s.get("attributes") or {}).items() if k not in NOISY_ATTRS
|
||||
}
|
||||
lines = [f"{s['entity_id']}: {s['state']} (с {s.get('last_changed', '?')})"]
|
||||
for k, v in list(attrs.items())[:ATTR_LIMIT]:
|
||||
lines.append(f" {k}: {json.dumps(v, ensure_ascii=False)[:200]}")
|
||||
return self._clip("\n".join(lines))
|
||||
|
||||
async def _call(
|
||||
self, domain: str, service: str, entity: str | None, data: dict[str, Any] | None
|
||||
) -> str:
|
||||
body: dict[str, Any] = dict(data or {})
|
||||
if entity:
|
||||
body["entity_id"] = entity
|
||||
changed = await self._post(f"/api/services/{domain}/{service}", body)
|
||||
if not changed:
|
||||
return f"{domain}.{service}: выполнено, состояния не менялись"
|
||||
lines = [f"{domain}.{service}: выполнено, изменилось:"]
|
||||
lines.extend(f" {s['entity_id']}: {s['state']}" for s in changed[:20])
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _services(self, domain: str) -> str:
|
||||
for block in await self._get("/api/services"):
|
||||
if block.get("domain") == domain:
|
||||
lines = [f"{domain}:"]
|
||||
for name, spec in sorted((block.get("services") or {}).items()):
|
||||
fields = ", ".join((spec.get("fields") or {}).keys())
|
||||
lines.append(
|
||||
f" {name}: {spec.get('description') or ''}".rstrip()
|
||||
+ (f" [{fields}]" if fields else "")
|
||||
)
|
||||
return self._clip("\n".join(lines))
|
||||
return f"домена {domain} нет"
|
||||
|
||||
async def _get(self, path: str) -> Any:
|
||||
return await self._request("GET", path, None)
|
||||
|
||||
async def _post(self, path: str, body: dict[str, Any]) -> Any:
|
||||
return await self._request("POST", path, body)
|
||||
|
||||
async def _request(
|
||||
self, method: str, path: str, body: dict[str, Any] | None
|
||||
) -> Any:
|
||||
headers = {"Authorization": f"Bearer {self.token}"}
|
||||
async with (
|
||||
aiohttp.ClientSession() as http,
|
||||
http.request(
|
||||
method,
|
||||
f"{self.url.rstrip('/')}{path}",
|
||||
json=body,
|
||||
headers=headers,
|
||||
timeout=aiohttp.ClientTimeout(total=self.timeout),
|
||||
) as response,
|
||||
):
|
||||
text = await response.text()
|
||||
if response.status >= 400:
|
||||
msg = f"Home Assistant {method} {path}: {response.status} {text[:300]}"
|
||||
raise RuntimeError(msg)
|
||||
return json.loads(text) if text else None
|
||||
|
||||
def _clip(self, text: str) -> str:
|
||||
if len(text) <= self.max_chars:
|
||||
return text
|
||||
return text[: self.max_chars] + "\n…[обрезано]"
|
||||
|
||||
|
||||
def _need(value: str | None, name: str) -> str:
|
||||
if not value:
|
||||
msg = f"для этого действия нужен параметр {name}"
|
||||
raise ValueError(msg)
|
||||
return value
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Komodo как python_tool с перечислимыми действиями.
|
||||
|
||||
Ключ живёт в gateway и в контекст модели не попадает. В перечислении нет
|
||||
`Prune*`, `Destroy*`, `DeleteServer` и терминала хоста; `exec` - это
|
||||
`docker exec` в контейнер, не шелл на сервере, и контейнеры из
|
||||
``exec_deny`` (periphery с docker.sock - это и есть хост) ему не даются.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import fnmatch
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
import aiohttp
|
||||
|
||||
__all__ = ["Action", "Komodo"]
|
||||
|
||||
Action = Literal[
|
||||
"status",
|
||||
"stacks",
|
||||
"containers",
|
||||
"logs",
|
||||
"search_logs",
|
||||
"updates",
|
||||
"update",
|
||||
"deploy",
|
||||
"restart",
|
||||
"exec",
|
||||
]
|
||||
|
||||
ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
|
||||
EXIT_MARK = "__KOMODO_EXIT_CODE:"
|
||||
NOT_RUNNING_LIMIT = 15
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Komodo:
|
||||
url: str
|
||||
key: str
|
||||
secret: str
|
||||
exec_deny: tuple[str, ...] = ("*periphery*", "*komodo*")
|
||||
terminal: str = "beaver"
|
||||
shell: str = "env PAGER=cat GIT_PAGER=cat TERM=dumb sh"
|
||||
"""Terminal is a tty: without ``PAGER=cat`` psql/git open a pager and
|
||||
wait for a key forever, wedging the terminal."""
|
||||
timeout: float = 120.0
|
||||
wait: float = 180.0
|
||||
max_chars: int = 12_000
|
||||
|
||||
async def komodo(
|
||||
self,
|
||||
action: Action,
|
||||
*,
|
||||
stack: str | None = None,
|
||||
service: str | None = None,
|
||||
container: str | None = None,
|
||||
command: str | None = None,
|
||||
terms: str | None = None,
|
||||
tail: int = 100,
|
||||
update: str | None = None,
|
||||
server: str | None = None,
|
||||
) -> str:
|
||||
"""Прод через Komodo: посмотреть, а по просьбе Бобра - подействовать.
|
||||
|
||||
action:
|
||||
- status: серверы и стеки не в running.
|
||||
- stacks: все стеки - сервер, состояние, есть ли незадеплоенные коммиты.
|
||||
- containers: контейнеры сервера (server; без него - все серверы).
|
||||
- logs: логи стека (stack, service опционально, tail строк).
|
||||
- search_logs: поиск по логам стека (stack, terms - слова через пробел).
|
||||
- updates: последние операции - кто, что, статус.
|
||||
- update: подробности одной операции по id (update).
|
||||
- deploy / restart: стек целиком или один service; ждёт исхода до 3 мин.
|
||||
- exec: команда внутри контейнера (container, command; server, если имя
|
||||
контейнера есть на нескольких серверах). Это `docker exec` от
|
||||
пользователя контейнера, свежий шелл на каждый вызов, без tty-пейджера.
|
||||
Лимит ~2 мин: долгое запускай в фон с выводом в файл и читай файл.
|
||||
|
||||
Деплой и рестарт - только когда об этом попросили, не «заодно».
|
||||
"""
|
||||
match action:
|
||||
case "status":
|
||||
return await self._status()
|
||||
case "stacks":
|
||||
return await self._stacks()
|
||||
case "containers":
|
||||
return await self._containers(server)
|
||||
case "logs":
|
||||
return await self._logs(_need(stack, "stack"), service, tail)
|
||||
case "search_logs":
|
||||
return await self._search(
|
||||
_need(stack, "stack"), _need(terms, "terms"), service
|
||||
)
|
||||
case "updates":
|
||||
return await self._updates()
|
||||
case "update":
|
||||
return await self._update(_need(update, "update"))
|
||||
case "deploy":
|
||||
return await self._run("DeployStack", _need(stack, "stack"), service)
|
||||
case "restart":
|
||||
return await self._run("RestartStack", _need(stack, "stack"), service)
|
||||
case "exec":
|
||||
return await self._exec(
|
||||
_need(container, "container"), _need(command, "command"), server
|
||||
)
|
||||
|
||||
async def execute(self, operation: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
return await self._json("execute", {"type": operation, "params": params})
|
||||
|
||||
async def read(self, request: str, params: dict[str, Any]) -> Any:
|
||||
return await self._json("read", {"type": request, "params": params})
|
||||
|
||||
async def _status(self) -> str:
|
||||
servers, stacks = await asyncio.gather(
|
||||
self.read("ListServers", {}), self.read("ListStacks", {})
|
||||
)
|
||||
lines = [f"{s['name']:24} {s['info']['state']}" for s in servers]
|
||||
bad = [s for s in stacks if s["info"]["state"] != "running"]
|
||||
lines.append(f"стеков: {len(stacks)}, не running: {len(bad)}")
|
||||
lines.extend(
|
||||
f" {s['info']['state']:12} {s['name']} @{s['info'].get('server_name')}"
|
||||
for s in bad[:NOT_RUNNING_LIMIT]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _stacks(self) -> str:
|
||||
stacks = await self.read("ListStacks", {})
|
||||
lines = []
|
||||
for s in sorted(stacks, key=lambda s: s["name"]):
|
||||
info = s["info"]
|
||||
behind = info.get("latest_hash") and info.get("deployed_hash") != info.get(
|
||||
"latest_hash"
|
||||
)
|
||||
lines.append(
|
||||
f"{info['state']:12} {s['name']:28} @{info.get('server_name')}"
|
||||
+ (" (есть незадеплоенные коммиты)" if behind else "")
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _containers(self, server: str | None) -> str:
|
||||
names = (
|
||||
[server]
|
||||
if server
|
||||
else [s["name"] for s in await self.read("ListServers", {})]
|
||||
)
|
||||
lines = []
|
||||
for name in names:
|
||||
containers = await self.read("ListDockerContainers", {"server": name})
|
||||
lines.append(f"{name}:")
|
||||
lines.extend(
|
||||
f" {c['state']:10} {c['name']:36} {c.get('status', '')}"
|
||||
for c in sorted(containers, key=lambda c: c["name"])
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _logs(self, stack: str, service: str | None, tail: int) -> str:
|
||||
log = await self.read(
|
||||
"GetStackLog",
|
||||
{"stack": stack, "services": [service] if service else [], "tail": tail},
|
||||
)
|
||||
return self._clip(_log_text(log))
|
||||
|
||||
async def _search(self, stack: str, terms: str, service: str | None) -> str:
|
||||
log = await self.read(
|
||||
"SearchStackLog",
|
||||
{
|
||||
"stack": stack,
|
||||
"services": [service] if service else [],
|
||||
"terms": terms.split(),
|
||||
},
|
||||
)
|
||||
return self._clip(_log_text(log))
|
||||
|
||||
async def _updates(self) -> str:
|
||||
updates = (await self.read("ListUpdates", {}))["updates"][:15]
|
||||
return "\n".join(_update_line(u) for u in updates) or "операций нет"
|
||||
|
||||
async def _update(self, update_id: str) -> str:
|
||||
return self._clip(
|
||||
_update_detail(await self.read("GetUpdate", {"id": update_id}))
|
||||
)
|
||||
|
||||
async def _run(self, operation: str, stack: str, service: str | None) -> str:
|
||||
receipt = await self.execute(
|
||||
operation, {"stack": stack, "services": [service] if service else []}
|
||||
)
|
||||
update_id = receipt.get("id") or receipt.get("_id", {}).get("$oid")
|
||||
if not update_id:
|
||||
return f"{operation} {stack}: Komodo вернул {receipt!r}"
|
||||
deadline = time.monotonic() + self.wait
|
||||
while time.monotonic() < deadline:
|
||||
await asyncio.sleep(5)
|
||||
current = await self.read("GetUpdate", {"id": update_id})
|
||||
if current.get("status") == "Complete":
|
||||
return self._clip(_update_detail(current))
|
||||
return f"{operation} {stack}: ещё идёт, update={update_id} - проверь позже"
|
||||
|
||||
async def _exec(self, container: str, command: str, server: str | None) -> str:
|
||||
if any(fnmatch.fnmatchcase(container, p) for p in self.exec_deny):
|
||||
return f"exec в {container} запрещён: это контейнер инфраструктуры"
|
||||
server = server or await self._server_of(container)
|
||||
if server is None:
|
||||
return f"контейнер {container} не найден ни на одном сервере"
|
||||
body = {
|
||||
"target": {
|
||||
"type": "Container",
|
||||
"params": {"server": server, "container": container},
|
||||
},
|
||||
"terminal": self.terminal,
|
||||
"command": command,
|
||||
"init": {"command": self.shell, "recreate": "Always"},
|
||||
}
|
||||
status, text = await self._post("terminal/execute", body)
|
||||
if status != 200:
|
||||
return f"exec {container}: Komodo {status}: {text[:500]}"
|
||||
output, _, code = ANSI.sub("", text).rpartition(EXIT_MARK)
|
||||
header = f"[{container}@{server}] exit {code.strip() or '?'}"
|
||||
return f"{header}\n{self._clip(output.strip())}"
|
||||
|
||||
async def _server_of(self, container: str) -> str | None:
|
||||
for s in await self.read("ListServers", {}):
|
||||
containers = await self.read("ListDockerContainers", {"server": s["name"]})
|
||||
if any(c["name"] == container for c in containers):
|
||||
return s["name"]
|
||||
return None
|
||||
|
||||
async def _json(self, route: str, body: dict[str, Any]) -> Any:
|
||||
status, text = await self._post(route, body)
|
||||
if status != 200:
|
||||
msg = f"Komodo {route} {body.get('type')}: {status} {text[:500]}"
|
||||
raise RuntimeError(msg)
|
||||
return json.loads(text)
|
||||
|
||||
async def _post(self, route: str, body: dict[str, Any]) -> tuple[int, str]:
|
||||
headers = {"X-Api-Key": self.key, "X-Api-Secret": self.secret}
|
||||
async with (
|
||||
aiohttp.ClientSession() as http,
|
||||
http.post(
|
||||
f"{self.url.rstrip('/')}/{route}",
|
||||
json=body,
|
||||
headers=headers,
|
||||
timeout=aiohttp.ClientTimeout(total=self.timeout),
|
||||
) as response,
|
||||
):
|
||||
return response.status, await response.text()
|
||||
|
||||
def _clip(self, text: str) -> str:
|
||||
if len(text) <= self.max_chars:
|
||||
return text
|
||||
head, tail = self.max_chars * 2 // 3, self.max_chars // 3
|
||||
cut = len(text) - head - tail
|
||||
return f"{text[:head]}\n…[обрезано {cut} символов]…\n{text[-tail:]}"
|
||||
|
||||
|
||||
def _need(value: str | None, name: str) -> str:
|
||||
if not value:
|
||||
msg = f"для этого действия нужен параметр {name}"
|
||||
raise ValueError(msg)
|
||||
return value
|
||||
|
||||
|
||||
def _log_text(log: dict[str, Any]) -> str:
|
||||
if "error" in log:
|
||||
return f"Komodo: {log['error']}"
|
||||
return ANSI.sub("", (log.get("stdout") or "") + (log.get("stderr") or "")).strip()
|
||||
|
||||
|
||||
def _ts(ms: int) -> str:
|
||||
return datetime.fromtimestamp(ms / 1000, tz=UTC).strftime("%m-%d %H:%M")
|
||||
|
||||
|
||||
def _update_line(u: dict[str, Any]) -> str:
|
||||
ok = "ok" if u.get("success") else "FAIL"
|
||||
head = f"{_ts(u['start_ts'])} {u['operation']:20} {u['status']:10} {ok}"
|
||||
return f"{head} user={u.get('username')} id={u.get('id')}"
|
||||
|
||||
|
||||
def _update_detail(u: dict[str, Any]) -> str:
|
||||
lines = [f"{u['operation']} {u['status']} {'ok' if u.get('success') else 'FAIL'}"]
|
||||
for stage in u.get("logs", []):
|
||||
out = ANSI.sub(
|
||||
"", (stage.get("stdout") or "") + " " + (stage.get("stderr") or "")
|
||||
)
|
||||
lines.append(f"-- {stage['stage']} {'ok' if stage['success'] else 'FAIL'}")
|
||||
lines.append(re.sub("<[^>]+>", "", out).strip()[:1500])
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,229 @@
|
||||
"""алерты Komodo для диспетчера - что доехать до мастера, когда и как.
|
||||
|
||||
Вебхук `/hooks/komodo` получает всё, что шлёт Komodo (типы алертов у
|
||||
алертера не перечислены намеренно). Сюда мастеру нужна малая часть, и не
|
||||
сразу: `ServerUnreachable` и смены состояния стеков - флап, который через
|
||||
минуту сам отбивается («отбой» от Komodo приходит следом), а срочный
|
||||
инжект прерывает тёрн и стоит токенов. Поэтому такие алерты выдерживаются
|
||||
(`HOLD`), и по истечении выдержки состояние перепроверяется по API: если
|
||||
всё уже running - алерт молча выбрасывается; если нет - мастер получает
|
||||
срочный инжект. Информационные типы (обновления образов, расписания,
|
||||
дрейф синка) не доезжают вообще - их читает Бобёр в healthbot.
|
||||
|
||||
Komodo создаёт `StackStateChange`/`ContainerStateChange` сразу с
|
||||
`resolved=true` (`resolved_ts == ts`): это факт «состояние сменилось», а
|
||||
не тревога, которую снимают. У них смотрим на `to`, не на `resolved`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any, Literal
|
||||
|
||||
__all__ = ["HOLD", "Alert", "AlertMemory", "Verdict", "describe", "parse", "still_bad"]
|
||||
|
||||
HOLD = timedelta(minutes=5)
|
||||
|
||||
# Не для диспетчера: Бобёр видит их в healthbot, действия по ним - его.
|
||||
INFORMATIONAL = frozenset(
|
||||
{
|
||||
"StackImageUpdateAvailable",
|
||||
"DeploymentImageUpdateAvailable",
|
||||
"StackAutoUpdated",
|
||||
"DeploymentAutoUpdated",
|
||||
"ResourceSyncPendingUpdates",
|
||||
"ScheduleRun",
|
||||
"Test",
|
||||
"Custom",
|
||||
}
|
||||
)
|
||||
# Флапает: ждать HOLD и перепроверить, прежде чем будить мастера.
|
||||
FLAPPY = frozenset(
|
||||
{"ServerUnreachable", "StackStateChange", "ContainerStateChange", "ServerCpu"}
|
||||
)
|
||||
STATE_CHANGE = frozenset({"StackStateChange", "ContainerStateChange"})
|
||||
HEALTHY_STATES = frozenset({"running", "healthy"})
|
||||
FAILURE = frozenset(
|
||||
{"ProcedureFailed", "ActionFailed", "BuildFailed", "RepoBuildFailed"}
|
||||
)
|
||||
|
||||
Action = Literal["drop", "hold", "inject", "resolved"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Alert:
|
||||
kind: str
|
||||
level: str
|
||||
resolved: bool
|
||||
name: str
|
||||
server: str | None
|
||||
data: dict[str, Any]
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
return f"{self.kind}:{self.server or '-'}:{self.name}"
|
||||
|
||||
@property
|
||||
def bad(self) -> bool:
|
||||
"""Тревога, а не снятие.
|
||||
|
||||
У смен состояния - по `to`, у прочих - по `resolved`.
|
||||
"""
|
||||
if self.kind in STATE_CHANGE:
|
||||
return str(self.data.get("to", "")).lower() not in HEALTHY_STATES
|
||||
return not self.resolved
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Verdict:
|
||||
action: Action
|
||||
urgency: Literal["urgent", "normal"] = "normal"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AlertMemory:
|
||||
"""Что выдерживается и о чём мастеру уже сказали.
|
||||
|
||||
Повторы во время выдержки не дублируются, а «отбой» доезжает только
|
||||
после тревоги, которую мастер видел.
|
||||
"""
|
||||
|
||||
holding: set[str] = field(default_factory=set)
|
||||
told: dict[str, datetime] = field(default_factory=dict)
|
||||
|
||||
def verdict(self, alert: Alert) -> Verdict:
|
||||
if alert.kind in INFORMATIONAL:
|
||||
return Verdict("drop")
|
||||
if not alert.bad:
|
||||
if alert.key in self.holding:
|
||||
# Снялось, пока ждали: ни тревоги, ни отбоя.
|
||||
self.holding.discard(alert.key)
|
||||
return Verdict("drop")
|
||||
if self.told.pop(alert.key, None) is not None:
|
||||
return Verdict("resolved")
|
||||
return Verdict("drop")
|
||||
if alert.kind in FLAPPY:
|
||||
if alert.key in self.holding:
|
||||
return Verdict("drop")
|
||||
self.holding.add(alert.key)
|
||||
return Verdict("hold")
|
||||
return Verdict("inject", "normal")
|
||||
|
||||
def after_hold(self, alert: Alert, *, still_bad: bool) -> Verdict:
|
||||
self.holding.discard(alert.key)
|
||||
if not still_bad:
|
||||
return Verdict("drop")
|
||||
return Verdict("inject", "urgent")
|
||||
|
||||
def mark_told(self, alert: Alert) -> None:
|
||||
self.told[alert.key] = datetime.now(UTC)
|
||||
|
||||
|
||||
def parse(payload: dict[str, Any]) -> Alert:
|
||||
data = payload.get("data") or {}
|
||||
inner = data.get("data") if isinstance(data, dict) else None
|
||||
inner = inner if isinstance(inner, dict) else {}
|
||||
return Alert(
|
||||
kind=str(data.get("type") or "Unknown")
|
||||
if isinstance(data, dict)
|
||||
else "Unknown",
|
||||
level=str(payload.get("level") or "").upper(),
|
||||
resolved=bool(payload.get("resolved")),
|
||||
name=str(inner.get("name") or inner.get("id") or "?"),
|
||||
server=inner.get("server_name"),
|
||||
data=inner,
|
||||
)
|
||||
|
||||
|
||||
def _gb(value: Any) -> str:
|
||||
try:
|
||||
return f"{float(value):.1f} ГБ"
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
|
||||
|
||||
def _pct(value: Any) -> str:
|
||||
try:
|
||||
return f"{float(value):.0f}%"
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
|
||||
|
||||
def _err(value: Any) -> str:
|
||||
if isinstance(value, dict):
|
||||
return str(value.get("error") or value)
|
||||
return str(value or "без деталей")
|
||||
|
||||
|
||||
def describe(alert: Alert, *, held: timedelta | None = None) -> str:
|
||||
"""Одна-две строки для инжекта: что, где, с каких пор."""
|
||||
d = alert.data
|
||||
name, server = alert.name, alert.server
|
||||
where = f" на {server}" if server else ""
|
||||
since = f" уже {int(held.total_seconds() // 60)} мин" if held else ""
|
||||
match alert.kind:
|
||||
case "ServerUnreachable":
|
||||
return f"сервер {name} не отвечает{since}: {_err(d.get('err'))}"
|
||||
case "StackStateChange":
|
||||
return f"стек {name}{where}: {d.get('from')} → {d.get('to')}{since}"
|
||||
case "ContainerStateChange":
|
||||
return f"контейнер {name}{where}: {d.get('from')} → {d.get('to')}{since}"
|
||||
case "ServerCpu":
|
||||
return f"CPU на {name}: {_pct(d.get('percentage'))}{since}"
|
||||
case "ServerMem":
|
||||
return (
|
||||
f"память на {name}: {_gb(d.get('used_gb'))} из {_gb(d.get('total_gb'))}"
|
||||
)
|
||||
case "ServerDisk":
|
||||
return (
|
||||
f"диск на {name} ({d.get('path', '/')}): "
|
||||
f"{_gb(d.get('used_gb'))} из {_gb(d.get('total_gb'))}"
|
||||
)
|
||||
case "ServerVersionMismatch":
|
||||
return (
|
||||
f"версии разъехались на {name}: periphery {d.get('version')}, "
|
||||
f"core {d.get('core_version')} - чинится только по ssh, это к Бобру"
|
||||
)
|
||||
case k if k in FAILURE:
|
||||
what = {"ProcedureFailed": "процедура", "ActionFailed": "действие"}.get(
|
||||
k, "сборка"
|
||||
)
|
||||
return f"{what} {name} упала - подробности в komodo(updates)"
|
||||
case _:
|
||||
rest = ", ".join(
|
||||
f"{k}={v}" for k, v in d.items() if k not in {"id", "server_id"}
|
||||
)
|
||||
return f"{alert.kind} {name}: {rest}"[:400]
|
||||
|
||||
|
||||
async def still_bad(read: Any, alert: Alert) -> bool:
|
||||
"""Перепроверка по API после выдержки; при ошибке считаем, что тревога в силе."""
|
||||
try:
|
||||
match alert.kind:
|
||||
case "ServerUnreachable" | "ServerCpu":
|
||||
servers = await read("ListServers", {})
|
||||
for s in servers:
|
||||
if s["name"] == alert.name:
|
||||
return s["info"]["state"] != "Ok"
|
||||
return True
|
||||
case "StackStateChange":
|
||||
stacks = await read("ListStacks", {})
|
||||
for s in stacks:
|
||||
if s["name"] == alert.name:
|
||||
return s["info"]["state"] not in HEALTHY_STATES
|
||||
return True
|
||||
case "ContainerStateChange":
|
||||
if not alert.server:
|
||||
return True
|
||||
containers = await read(
|
||||
"ListDockerContainers", {"server": alert.server}
|
||||
)
|
||||
for c in containers:
|
||||
if c["name"] == alert.name:
|
||||
return str(c.get("state", "")).lower() not in HEALTHY_STATES
|
||||
return True
|
||||
case _:
|
||||
return True
|
||||
except Exception: # noqa: BLE001 - недоступный Komodo сам по себе тревога
|
||||
return True
|
||||
@@ -0,0 +1,345 @@
|
||||
"""вайбграм - комната агентов на хабе, как python_tool.
|
||||
|
||||
Хаб - https://vibegram.example.com, протокол - REST с bearer-токеном агента
|
||||
(выдан при `vibegram join`, лежит в env gateway и в контекст модели не
|
||||
попадает). Диспетчер сидит в комнате сам (D1: внутренний контур с соседями),
|
||||
но через перечислимые действия: прочитать новое, написать, кто в комнате,
|
||||
что занято, план, claim/release, карточка. Один и тот же клиент кормит
|
||||
крон-проверку (`pending`) и тулзу (`vibegram`).
|
||||
|
||||
`/api/pending` - курсор на стороне хаба: прочитанное второй раз не
|
||||
приходит. Поэтому клиент помнит последние события сам: тулза `read`
|
||||
показывает их, если новых нет, - мастер увидит то, что уже разобрал триаж.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path # noqa: TC003 - в аннотации dataclass
|
||||
from typing import Any, Literal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import aiohttp
|
||||
|
||||
__all__ = ["Action", "Event", "Vibegram"]
|
||||
|
||||
Action = Literal["read", "send", "who", "work", "plan", "claim", "release", "card"]
|
||||
|
||||
USER_AGENT = "beaver-agent/0.1 (+vibegram)"
|
||||
REMEMBER = 40
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Event:
|
||||
id: int
|
||||
nick: str
|
||||
kind: str
|
||||
text: str
|
||||
at: str
|
||||
|
||||
def line(self) -> str:
|
||||
return f"[{self.at}] {self.text}"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Vibegram:
|
||||
hub: str
|
||||
token: str
|
||||
nick: str
|
||||
repo: Path | None = None
|
||||
tz: str = "UTC"
|
||||
"""Время в строках событий - в этой зоне (хаб отдаёт UTC)."""
|
||||
timeout: float = 15.0
|
||||
max_chars: int = 8_000
|
||||
_cursor: int = 0
|
||||
_recent: deque[Event] = field(default_factory=lambda: deque(maxlen=REMEMBER))
|
||||
|
||||
async def vibegram(
|
||||
self,
|
||||
action: Action,
|
||||
*,
|
||||
text: str | None = None,
|
||||
paths: list[str] | None = None,
|
||||
note: str | None = None,
|
||||
about: str | None = None,
|
||||
skills: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Вайбграм - комната агентов (чужие клоды и ты) вокруг общей репы.
|
||||
|
||||
action:
|
||||
- read: что нового с прошлого раза (сообщения, claim'ы, план); если
|
||||
нового нет - последние уже виденные события.
|
||||
- send: написать в комнату (text). Это доска объявлений, не чат:
|
||||
пиши, когда добавляешь информацию; адресовать - @ник.
|
||||
- who: кто в комнате, что умеет, что держит.
|
||||
- work: свободные пункты плана, занятые файлы, кто что делает.
|
||||
- plan: общий план комнаты.
|
||||
- claim / release: занять / отпустить файлы репы (paths относительно
|
||||
корня репы, note - что делаешь). Перед правкой - claim, после - release.
|
||||
- card: рассказать о себе (about, skills).
|
||||
|
||||
Наружу личного нет: имена из `👤 люди/`, содержимое `мета/бобер/` и
|
||||
`📅 дни/` в комнату не уходят - только рабочее.
|
||||
"""
|
||||
match action:
|
||||
case "read":
|
||||
return await self._read()
|
||||
case "send":
|
||||
return await self._send(_need(text, "text"))
|
||||
case "who":
|
||||
return _cards((await self._get("/api/cards"))["cards"], self.nick)
|
||||
case "work":
|
||||
return _work(await self._get("/api/work"), self.nick)
|
||||
case "plan":
|
||||
return _plan(await self._get("/api/plan"))
|
||||
case "claim":
|
||||
result = await self._post(
|
||||
"/api/claims", {"resources": _need_list(paths), "note": note}
|
||||
)
|
||||
if result.get("ok"):
|
||||
held = ", ".join(c["resource"] for c in result.get("claims", []))
|
||||
return f"занято: {held}"
|
||||
return "\n".join(
|
||||
f"{c['resource']} держит {c['heldBy']}"
|
||||
+ (f" ({c['note']})" if c.get("note") else "")
|
||||
+ " - напиши ему через send, не обходи"
|
||||
for c in result.get("conflicts", [])
|
||||
)
|
||||
case "release":
|
||||
body = {"resources": paths} if paths else {}
|
||||
result = await self._post("/api/claims/release", body)
|
||||
released = result.get("released") or []
|
||||
return (
|
||||
"отпущено: " + ", ".join(released)
|
||||
if released
|
||||
else "нечего отпускать"
|
||||
)
|
||||
case "card":
|
||||
patch: dict[str, Any] = {}
|
||||
if about:
|
||||
patch["description"] = about
|
||||
if skills:
|
||||
patch["skills"] = skills
|
||||
result = await self._post("/api/card", patch)
|
||||
return _cards([result["card"]], self.nick)
|
||||
|
||||
async def pending(self, limit: int = 50) -> list[Event]:
|
||||
"""Новые события с курсора хаба (для крона); запоминает их."""
|
||||
data = await self._get(f"/api/pending?limit={limit}")
|
||||
events = [
|
||||
e
|
||||
for e in (_event(raw, self.tz) for raw in data.get("events", []))
|
||||
if e is not None
|
||||
]
|
||||
fresh = [e for e in events if e.id > self._cursor]
|
||||
for e in fresh:
|
||||
self._recent.append(e)
|
||||
self._cursor = max(self._cursor, e.id)
|
||||
ack = data.get("planAckNeeded")
|
||||
if ack is not None:
|
||||
fresh.append(
|
||||
Event(
|
||||
id=self._cursor,
|
||||
nick="system",
|
||||
kind="plan",
|
||||
text=f"план изменился (ревизия {ack}), ты его не подтвердил",
|
||||
at=_now(self.tz),
|
||||
)
|
||||
)
|
||||
return fresh
|
||||
|
||||
def mentioned(self, events: list[Event]) -> bool:
|
||||
needle = f"@{self.nick}".lower()
|
||||
return any(needle in e.text.lower() for e in events)
|
||||
|
||||
async def _read(self) -> str:
|
||||
fresh = await self.pending()
|
||||
if fresh:
|
||||
return self._clip("\n".join(e.line() for e in fresh))
|
||||
if self._recent:
|
||||
tail = "\n".join(e.line() for e in list(self._recent)[-10:])
|
||||
return f"нового нет; последнее, что было:\n{tail}"
|
||||
return "нового нет"
|
||||
|
||||
async def _send(self, text: str) -> str:
|
||||
try:
|
||||
await self._post("/api/messages", {"body": text})
|
||||
except HubError as exc:
|
||||
if exc.code == "rate_limited":
|
||||
return f"хаб притормозил: {exc}"
|
||||
raise
|
||||
return "отправлено"
|
||||
|
||||
async def _get(self, path: str) -> Any:
|
||||
return await self._call("GET", path, None)
|
||||
|
||||
async def _post(self, path: str, body: dict[str, Any]) -> Any:
|
||||
return await self._call("POST", path, body)
|
||||
|
||||
async def _call(self, method: str, path: str, body: dict[str, Any] | None) -> Any:
|
||||
headers = {"authorization": f"Bearer {self.token}", "user-agent": USER_AGENT}
|
||||
async with (
|
||||
aiohttp.ClientSession() as http,
|
||||
http.request(
|
||||
method,
|
||||
f"{self.hub.rstrip('/')}{path}",
|
||||
json=body,
|
||||
headers=headers,
|
||||
timeout=aiohttp.ClientTimeout(total=self.timeout),
|
||||
) as response,
|
||||
):
|
||||
raw = await response.text()
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except ValueError:
|
||||
data = {"error": "bad_json", "message": raw[:200]}
|
||||
if response.status >= 400:
|
||||
raise HubError(
|
||||
response.status,
|
||||
str(data.get("error") or "error"),
|
||||
str(data.get("message") or data.get("error") or raw[:200]),
|
||||
)
|
||||
return data
|
||||
|
||||
def _clip(self, text: str) -> str:
|
||||
if len(text) <= self.max_chars:
|
||||
return text
|
||||
return text[: self.max_chars] + "\n…[обрезано]"
|
||||
|
||||
|
||||
class HubError(RuntimeError):
|
||||
def __init__(self, status: int, code: str, message: str) -> None:
|
||||
super().__init__(f"vibegram {status} {code}: {message}")
|
||||
self.status = status
|
||||
self.code = code
|
||||
|
||||
|
||||
def _now(tz: str = "UTC") -> str:
|
||||
return datetime.now(UTC).astimezone(ZoneInfo(tz)).strftime("%m-%d %H:%M")
|
||||
|
||||
|
||||
def _when(iso: Any, tz: str = "UTC") -> str:
|
||||
try:
|
||||
when = datetime.fromisoformat(str(iso))
|
||||
except ValueError:
|
||||
return _now(tz)
|
||||
if when.tzinfo is None:
|
||||
when = when.replace(tzinfo=UTC)
|
||||
return when.astimezone(ZoneInfo(tz)).strftime("%m-%d %H:%M")
|
||||
|
||||
|
||||
def _event(raw: dict[str, Any], tz: str = "UTC") -> Event | None:
|
||||
who = raw.get("nick") or "system"
|
||||
kind = str(raw.get("kind") or "")
|
||||
p = raw.get("payload") or {}
|
||||
match kind:
|
||||
case "message":
|
||||
text = f"{who}: {p.get('body', '')}"
|
||||
case "claim":
|
||||
text = f"{who} занял: {', '.join(p.get('resources', []))}"
|
||||
case "release":
|
||||
text = f"{who} отпустил: {', '.join(p.get('resources', []))}"
|
||||
case "claim_denied":
|
||||
text = f"{who} пытался занять то, что держишь ты - возможно, ждёт тебя"
|
||||
case "violation":
|
||||
text = f"{who} полез в файл, который держишь ты"
|
||||
case "plan_change":
|
||||
text = f"{who}: план - {p.get('summary', '')}"
|
||||
case "agent_join":
|
||||
text = f"{who} вошёл"
|
||||
case "agent_leave":
|
||||
text = f"{who} вышел"
|
||||
case _:
|
||||
return None
|
||||
return Event(
|
||||
id=int(raw.get("id") or 0),
|
||||
nick=str(who),
|
||||
kind=kind,
|
||||
text=text,
|
||||
at=_when(raw.get("createdAt"), tz),
|
||||
)
|
||||
|
||||
|
||||
def _cards(cards: list[dict[str, Any]], me: str) -> str:
|
||||
if not cards:
|
||||
return "в комнате никого"
|
||||
lines = []
|
||||
for c in cards:
|
||||
mark = "●" if c.get("status") == "online" else "○"
|
||||
head = f"{mark} {c['nick']}" + (" (это ты)" if c["nick"] == me else "")
|
||||
about = " · ".join(
|
||||
x for x in (c.get("description"), ", ".join(c.get("skills") or [])) if x
|
||||
)
|
||||
lines.append(f"{head} - {about}" if about else head)
|
||||
focus = c.get("focus") or {}
|
||||
if focus.get("planItem"):
|
||||
lines.append(f" делает: {focus['planItem'].get('text')}")
|
||||
if focus.get("holding"):
|
||||
lines.append(f" держит: {', '.join(focus['holding'])}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _work(snapshot: dict[str, Any], me: str) -> str:
|
||||
parts = []
|
||||
free = snapshot.get("free") or []
|
||||
taken = snapshot.get("taken") or []
|
||||
busy = snapshot.get("busy") or []
|
||||
if free:
|
||||
parts.append(
|
||||
"свободные пункты плана:\n"
|
||||
+ "\n".join(f" {i + 1}. {it['text']}" for i, it in enumerate(free))
|
||||
)
|
||||
elif not snapshot.get("planRevision"):
|
||||
parts.append("плана в комнате пока нет")
|
||||
else:
|
||||
parts.append("свободных пунктов плана нет")
|
||||
if taken:
|
||||
parts.append(
|
||||
"занято:\n"
|
||||
+ "\n".join(f" - {it['text']} ({it.get('ownerNick')})" for it in taken)
|
||||
)
|
||||
if busy:
|
||||
parts.append(
|
||||
"файлы:\n"
|
||||
+ "\n".join(
|
||||
f" - {b['resource']} у {b['nick']}"
|
||||
+ (f" ({b['note']})" if b.get("note") else "")
|
||||
for b in busy
|
||||
)
|
||||
)
|
||||
cards = [c for c in snapshot.get("cards") or [] if c["nick"] != me]
|
||||
if cards:
|
||||
parts.append("кто что делает:\n" + _cards(cards, me))
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def _plan(plan: dict[str, Any]) -> str:
|
||||
items = plan.get("items") or []
|
||||
if not items:
|
||||
return "плана нет"
|
||||
lines = [
|
||||
f"{i + 1}. [{it.get('status')}] {it['text']}"
|
||||
+ (f" - {it['ownerNick']}" if it.get("ownerNick") else "")
|
||||
for i, it in enumerate(items)
|
||||
]
|
||||
notes = (plan.get("notes") or {}).get("body")
|
||||
if notes:
|
||||
lines.append(f"заметки: {notes}")
|
||||
return f"план v{plan.get('revision')}:\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def _need(value: str | None, name: str) -> str:
|
||||
if not value:
|
||||
msg = f"для этого действия нужен параметр {name}"
|
||||
raise ValueError(msg)
|
||||
return value
|
||||
|
||||
|
||||
def _need_list(value: list[str] | None) -> list[str]:
|
||||
if not value:
|
||||
msg = "для этого действия нужен параметр paths"
|
||||
raise ValueError(msg)
|
||||
return value
|
||||
@@ -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: справка, лог реплик, хендауты, вотчер, куратор."""
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Куратор памяти: что нового с прошлого прогона .
|
||||
|
||||
Крон дёргает детерминированный код, модель судит: здесь собирается брифинг
|
||||
для служебного тёрна `beaver-curator` - новые реплики Бобра целиком и имена
|
||||
файлов, изменившихся с прошлого прогона. Время прошлого прогона куратор
|
||||
сам пишет первой строкой `мета/бобер/куратор.md`; нет ничего нового -
|
||||
брифинга нет и джоб не спавнится.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
LAST_RUN_RE = re.compile(r"^последний прогон:\s*(\S+)")
|
||||
REPLY_RE = re.compile(r"^- (\d{4}-\d{2}-\d{2} \d{2}:\d{2}) · ")
|
||||
MAX_REPLY_CHARS = 60_000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Watched:
|
||||
label: str
|
||||
root: Path
|
||||
pattern: str = "**/*.md"
|
||||
|
||||
|
||||
def last_run(journal: Path, *, default: datetime) -> datetime:
|
||||
"""`последний прогон: <ISO>` из начала журнала, иначе ``default``."""
|
||||
if not journal.exists():
|
||||
return default
|
||||
for line in journal.read_text(encoding="utf-8", errors="ignore").splitlines()[:5]:
|
||||
m = LAST_RUN_RE.match(line.strip())
|
||||
if m:
|
||||
try:
|
||||
stamp = datetime.fromisoformat(m.group(1))
|
||||
except ValueError:
|
||||
return default
|
||||
return stamp if stamp.tzinfo else stamp.replace(tzinfo=UTC)
|
||||
return default
|
||||
|
||||
|
||||
def new_replies(replies_dir: Path, since: datetime, tz: str) -> list[str]:
|
||||
"""Строки `реплики/YYYY-MM.md` позже ``since`` (время в строках локальное)."""
|
||||
zone = ZoneInfo(tz)
|
||||
local_since = since.astimezone(zone).replace(tzinfo=None)
|
||||
out: list[str] = []
|
||||
if not replies_dir.exists():
|
||||
return out
|
||||
for path in sorted(replies_dir.glob("????-??.md"))[-2:]:
|
||||
for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||
m = REPLY_RE.match(line)
|
||||
if not m:
|
||||
continue
|
||||
try:
|
||||
at = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M") # noqa: DTZ007
|
||||
except ValueError:
|
||||
continue
|
||||
if at > local_since:
|
||||
out.append(line)
|
||||
return out
|
||||
|
||||
|
||||
def changed_files(
|
||||
watched: list[Watched], since: datetime, vault: Path, *, skip: tuple[str, ...] = ()
|
||||
) -> list[str]:
|
||||
"""`путь (размер)` для файлов с mtime позже ``since``; ``skip`` - подстроки."""
|
||||
out: list[tuple[str, str]] = []
|
||||
stamp = since.timestamp()
|
||||
for w in watched:
|
||||
if not w.root.exists():
|
||||
continue
|
||||
for path in w.root.glob(w.pattern):
|
||||
if not path.is_file() or path.name.startswith("."):
|
||||
continue
|
||||
rel = str(path.relative_to(vault))
|
||||
if any(s in rel for s in skip):
|
||||
continue
|
||||
if path.stat().st_mtime > stamp:
|
||||
out.append((w.label, f"`{rel}` ({path.stat().st_size // 1024} КБ)"))
|
||||
out.sort()
|
||||
return [f"{label}: {desc}" for label, desc in out]
|
||||
|
||||
|
||||
def briefing(
|
||||
*,
|
||||
vault: Path,
|
||||
journal: Path,
|
||||
replies_dir: Path,
|
||||
watched: list[Watched],
|
||||
tz: str,
|
||||
now: datetime | None = None,
|
||||
default_window: timedelta = timedelta(hours=24),
|
||||
) -> str | None:
|
||||
"""Первое сообщение куратору или ``None``, если с прошлого прогона ничего нет."""
|
||||
now = now or datetime.now(UTC)
|
||||
since = last_run(journal, default=now - default_window)
|
||||
replies = new_replies(replies_dir, since, tz)
|
||||
changed = changed_files(
|
||||
watched, since, vault, skip=(str(journal.relative_to(vault)),)
|
||||
)
|
||||
if not replies and not changed:
|
||||
return None
|
||||
zone = ZoneInfo(tz)
|
||||
head = (
|
||||
f"Прогон куратора {now.astimezone(zone):%Y-%m-%d %H:%M} ({tz}); прошлый - "
|
||||
f"{since.astimezone(zone):%Y-%m-%d %H:%M}. Ниже - что изменилось с тех пор; "
|
||||
"остальное читай сам по слоям. В конце обнови первую строку "
|
||||
f"`{journal.relative_to(vault)}`: `последний прогон: "
|
||||
f"{now.astimezone(zone).isoformat(timespec='seconds')}`."
|
||||
)
|
||||
parts = [head]
|
||||
if replies:
|
||||
text = "\n".join(replies)
|
||||
if len(text) > MAX_REPLY_CHARS:
|
||||
text = text[-MAX_REPLY_CHARS:]
|
||||
text = "…(обрезано, начало - в файле)\n" + text[text.find("\n") + 1 :]
|
||||
parts.append(f"Новые реплики Бобра ({len(replies)}):\n{text}")
|
||||
else:
|
||||
parts.append("Новых реплик Бобра нет.")
|
||||
if changed:
|
||||
parts.append("Изменились файлы:\n" + "\n".join(f"- {c}" for c in changed[:80]))
|
||||
return "\n\n".join(parts)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,334 @@
|
||||
"""Справка под сообщением Бобра и лог его реплик .
|
||||
|
||||
Gateway путей не знает; здесь - что искать по тексту сообщения и куда писать
|
||||
сказанное. ``Recall.block`` отдаёт указатели, не выводы: карточка упомянутого
|
||||
человека, его записки о нём (`мета/бобер/наблюдения/<имя> - наблюдения.md`),
|
||||
дни дневника, где он встречался, и раз в день - подошедшие сроки из досок.
|
||||
``ReplyLog.write`` кладёт каждую реплику Бобра из мастера и веток в
|
||||
`мета/бобер/реплики/YYYY-MM.md` - единственное место, где они grep-абельны:
|
||||
транскрипт живёт в Postgres, а глубокие чаты и так лежат файлами в `💬 чаты/`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from beaver_gateway.conversations.envelope import RecallContext
|
||||
from beaver_gateway.conversations.texts import UserSaid
|
||||
|
||||
LABEL = "справка (указатели по сообщению, не выводы; открой, если относится к вопросу):"
|
||||
VOWELS = "аеёиоуыэюя"
|
||||
KINDS = {"master": "мастер", "branch": "ветка"}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Person:
|
||||
name: str
|
||||
"""Имя файла карточки без `.md`; записки - `<имя> - наблюдения.md`."""
|
||||
aliases: tuple[str, ...]
|
||||
path: str
|
||||
"""Путь карточки от корня vault."""
|
||||
plain: bool
|
||||
"""Искать по голому имени в тексте (ближний круг), а не только по `[[ссылке]]`."""
|
||||
|
||||
@property
|
||||
def keys(self) -> tuple[str, ...]:
|
||||
return (self.name, *self.aliases)
|
||||
|
||||
|
||||
def frontmatter_aliases(text: str) -> tuple[str, ...]:
|
||||
"""`aliases:` из фронтматтера карточки - список строк или пусто."""
|
||||
if not text.startswith("---"):
|
||||
return ()
|
||||
end = text.find("\n---", 3)
|
||||
if end < 0:
|
||||
return ()
|
||||
out: list[str] = []
|
||||
inside = False
|
||||
for line in text[3:end].splitlines():
|
||||
if line.startswith("aliases:"):
|
||||
inside = True
|
||||
rest = line[len("aliases:") :].strip()
|
||||
if rest and rest != "[]":
|
||||
out += [a.strip().strip("\"'") for a in rest.strip("[]").split(",")]
|
||||
continue
|
||||
if inside:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("- "):
|
||||
out.append(stripped[2:].strip().strip("\"'"))
|
||||
continue
|
||||
if stripped:
|
||||
inside = False
|
||||
return tuple(a for a in out if a)
|
||||
|
||||
|
||||
class People:
|
||||
"""Индекс карточек `👤 люди/`: имя, алиасы, путь; пересобирается раз в `ttl`."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: Path,
|
||||
vault: Path,
|
||||
*,
|
||||
plain_dirs: tuple[str, ...] = ("личное", "профессиональное"),
|
||||
skip_dirs: tuple[str, ...] = ("архив", "рандомы", "группы"),
|
||||
ttl: float = 600.0,
|
||||
) -> None:
|
||||
self._root = root
|
||||
self._vault = vault
|
||||
self._plain_dirs = plain_dirs
|
||||
self._skip_dirs = skip_dirs
|
||||
self._ttl = ttl
|
||||
self._built = 0.0
|
||||
self._people: list[Person] = []
|
||||
|
||||
def all(self) -> list[Person]:
|
||||
if time.monotonic() - self._built > self._ttl:
|
||||
self._people = self._scan()
|
||||
self._built = time.monotonic()
|
||||
return self._people
|
||||
|
||||
def _scan(self) -> list[Person]:
|
||||
if not self._root.exists():
|
||||
return []
|
||||
out: list[Person] = []
|
||||
for card in sorted(self._root.rglob("*.md")):
|
||||
parts = card.relative_to(self._root).parts[:-1]
|
||||
plain = bool(parts) and parts[0] in self._plain_dirs
|
||||
plain = plain and not any(p in self._skip_dirs for p in parts)
|
||||
try:
|
||||
head = card.read_text(encoding="utf-8", errors="ignore")[:2000]
|
||||
except OSError:
|
||||
continue
|
||||
out.append(
|
||||
Person(
|
||||
name=card.stem,
|
||||
aliases=frontmatter_aliases(head),
|
||||
path=str(card.relative_to(self._vault)),
|
||||
plain=plain,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _stem_pattern(key: str) -> re.Pattern[str]:
|
||||
"""Имя с падежом: `Зина` → Зина/Зине/Зину/Зиной, `Прохор` → Прохора/Прохором."""
|
||||
stem = key[:-1] if key[-1].lower() in VOWELS and len(key) > 3 else key
|
||||
return re.compile(rf"(?<!\w){re.escape(stem)}\w{{0,2}}(?!\w)", re.IGNORECASE)
|
||||
|
||||
|
||||
def mentions(text: str, people: list[Person], *, limit: int = 3) -> list[Person]:
|
||||
"""Кто упомянут: `[[ссылкой]]` - любой, голым именем - только ближний круг."""
|
||||
found: dict[str, int] = {}
|
||||
by_key = {k.casefold(): p for p in people for k in p.keys}
|
||||
for m in re.finditer(r"\[\[([^\]|#]+)", text):
|
||||
target = m.group(1).strip().split("/")[-1].casefold()
|
||||
target = target.removesuffix(".md")
|
||||
person = by_key.get(target)
|
||||
if person is not None:
|
||||
found.setdefault(person.name, m.start())
|
||||
for person in people:
|
||||
if not person.plain or person.name in found:
|
||||
continue
|
||||
for key in person.keys:
|
||||
if " " in key:
|
||||
m = re.search(rf"(?<!\w){re.escape(key)}(?!\w)", text, re.IGNORECASE)
|
||||
elif len(key) >= 4:
|
||||
m = _stem_pattern(key).search(text)
|
||||
else:
|
||||
continue
|
||||
if m:
|
||||
found.setdefault(person.name, m.start())
|
||||
break
|
||||
order = sorted(found.items(), key=lambda kv: kv[1])[:limit]
|
||||
by_name = {p.name: p for p in people}
|
||||
return [by_name[name] for name, _ in order]
|
||||
|
||||
|
||||
def notes_sections(path: Path) -> dict[str, list[str]]:
|
||||
"""`## заголовок` → непустые строки под ним (до следующего `## `)."""
|
||||
out: dict[str, list[str]] = {}
|
||||
current: str | None = None
|
||||
for raw in path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||
line = raw.rstrip()
|
||||
if line.startswith("## "):
|
||||
current = line[3:].strip().lower()
|
||||
out.setdefault(current, [])
|
||||
continue
|
||||
if current is not None and line.strip():
|
||||
out[current].append(line.strip())
|
||||
return out
|
||||
|
||||
|
||||
def notes_head(path: Path, sections: tuple[str, ...], *, cap: int = 40) -> str | None:
|
||||
"""Выбранные секции файла записок одним блоком - для сида (портрет Бобра)."""
|
||||
if not path.exists():
|
||||
return None
|
||||
parsed = notes_sections(path)
|
||||
lines: list[str] = []
|
||||
for name in sections:
|
||||
body = parsed.get(name, [])
|
||||
if body:
|
||||
lines.append(f"## {name}")
|
||||
lines.extend(body)
|
||||
if not lines:
|
||||
return None
|
||||
if len(lines) > cap:
|
||||
lines = [*lines[:cap], f"… ещё {len(lines) - cap} строк в файле"]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Recall:
|
||||
"""Справка по сообщению: указатели в vault, которые gateway не может знать."""
|
||||
|
||||
vault: Path
|
||||
people_dir: Path
|
||||
notes_dir: Path
|
||||
diary_dir: Path
|
||||
boards_dir: Path
|
||||
tz: str = "Europe/Warsaw"
|
||||
diary_scan: int = 400
|
||||
"""Сколько последних дней дневника просматривать на упоминания."""
|
||||
_people: People | None = field(default=None, init=False, repr=False)
|
||||
_diary_cache: dict[str, tuple[float, list[str]]] = field(
|
||||
default_factory=dict, init=False, repr=False
|
||||
)
|
||||
_deadlines_day: date | None = field(default=None, init=False, repr=False)
|
||||
|
||||
@property
|
||||
def people(self) -> People:
|
||||
if self._people is None:
|
||||
self._people = People(self.people_dir, self.vault)
|
||||
return self._people
|
||||
|
||||
def block(self, ctx: RecallContext) -> str | None:
|
||||
today = ctx.now.astimezone(ZoneInfo(self.tz)).date()
|
||||
lines = [self.person_line(p) for p in mentions(ctx.text, self.people.all())]
|
||||
if ctx.kind == "master":
|
||||
lines += self.deadlines(today)
|
||||
if not lines:
|
||||
return None
|
||||
return "\n".join([LABEL, *lines])
|
||||
|
||||
def person_line(self, person: Person, *, now_lines: int = 6) -> str:
|
||||
"""Указатели по человеку и `## сейчас` из его записок - инлайн."""
|
||||
parts = [f"👤 {person.name} - карточка `{person.path}`"]
|
||||
notes = self.notes_dir / f"{person.name} - наблюдения.md"
|
||||
rel = notes.relative_to(self.vault)
|
||||
now: list[str] = []
|
||||
if notes.exists():
|
||||
sections = notes_sections(notes)
|
||||
feed = sections.get("лента", [])
|
||||
now = [ln for ln in sections.get("сейчас", []) if ln.startswith("- ")]
|
||||
patterns = [
|
||||
ln for ln in sections.get("паттерны", []) if ln.startswith("- ")
|
||||
]
|
||||
desc = f"записки `{rel}`: лента {len(feed)} стр."
|
||||
if patterns:
|
||||
desc += f", паттернов {len(patterns)}"
|
||||
if not now and feed:
|
||||
last = feed[-1][2:]
|
||||
desc += ", последняя: " + (
|
||||
last if len(last) <= 110 else last[:109] + "…"
|
||||
)
|
||||
parts.append(desc)
|
||||
else:
|
||||
parts.append(f"записок нет (`{rel}`)")
|
||||
days = self.diary_hits(person)
|
||||
if days:
|
||||
parts.append("дневник: " + ", ".join(f"[[{d}]]" for d in days))
|
||||
line = "; ".join(parts)
|
||||
if now:
|
||||
line += "; сейчас:\n" + "\n".join(f" {ln}" for ln in now[:now_lines])
|
||||
return line
|
||||
|
||||
def diary_hits(
|
||||
self, person: Person, *, limit: int = 3, ttl: float = 600.0
|
||||
) -> list[str]:
|
||||
cached = self._diary_cache.get(person.name)
|
||||
if cached and time.monotonic() - cached[0] < ttl:
|
||||
return cached[1]
|
||||
keys = [f"[[{k.casefold()}" for k in person.keys]
|
||||
hits: list[str] = []
|
||||
files = sorted(self.diary_dir.glob("????-??-??.md"), reverse=True)
|
||||
for path in files[: self.diary_scan]:
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="ignore").casefold()
|
||||
except OSError:
|
||||
continue
|
||||
if any(k in text for k in keys):
|
||||
hits.append(path.stem)
|
||||
if len(hits) >= limit:
|
||||
break
|
||||
self._diary_cache[person.name] = (time.monotonic(), hits)
|
||||
return hits
|
||||
|
||||
def deadlines(
|
||||
self, today: date, *, horizon_days: int = 1, limit: int = 8
|
||||
) -> list[str]:
|
||||
"""Открытые задачи досок с 📅 не позже завтра - раз в день, первым конвертом."""
|
||||
if self._deadlines_day == today:
|
||||
return []
|
||||
self._deadlines_day = today
|
||||
found: list[tuple[date, str, str]] = []
|
||||
for board in (
|
||||
sorted(self.boards_dir.glob("*.md")) if self.boards_dir.exists() else []
|
||||
):
|
||||
live = board.read_text(encoding="utf-8", errors="ignore").split("\n***", 1)[
|
||||
0
|
||||
]
|
||||
for m in re.finditer(
|
||||
r"^\s*- \[ \] (.+?)\s*📅 (\d{4}-\d{2}-\d{2})", live, re.MULTILINE
|
||||
):
|
||||
try:
|
||||
due = date.fromisoformat(m.group(2))
|
||||
except ValueError:
|
||||
continue
|
||||
if due <= today + timedelta(days=horizon_days):
|
||||
found.append((due, board.stem, m.group(1).strip()))
|
||||
found.sort()
|
||||
return [f"📆 {due:%m-%d} {board}: {task}" for due, board, task in found[:limit]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReplyLog:
|
||||
"""`мета/бобер/реплики/YYYY-MM.md`: строка на реплику Бобра из мастера и веток."""
|
||||
|
||||
dir: Path
|
||||
tz: str = "Europe/Warsaw"
|
||||
max_chars: int = 600
|
||||
|
||||
def write(self, said: UserSaid) -> None:
|
||||
text = said.text
|
||||
if text.startswith("[сид:"):
|
||||
# спавн ветки с текстом: строка сида едет над самим сообщением
|
||||
text = text.split("\n\n", 1)[1] if "\n\n" in text else ""
|
||||
flat = " ⏎ ".join(ln.strip() for ln in text.strip().splitlines() if ln.strip())
|
||||
if not flat:
|
||||
return
|
||||
if len(flat) > self.max_chars:
|
||||
flat = flat[: self.max_chars - 1] + "…"
|
||||
local = said.at.astimezone(ZoneInfo(self.tz))
|
||||
where = KINDS.get(said.kind, said.kind)
|
||||
if said.kind != "master" and said.title:
|
||||
where = f"{where} «{said.title}»"
|
||||
path = self.dir / f"{local:%Y-%m}.md"
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
if not path.exists():
|
||||
path.write_text(
|
||||
f"# реплики Бобра, {local:%Y-%m}\n\n"
|
||||
"> пишет gateway: что Бобёр сказал в мастере и ветках, "
|
||||
"строка на сообщение. Глубокие чаты лежат в `💬 чаты/` сами.\n\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(f"- {local:%Y-%m-%d %H:%M} · {where} · {flat}\n")
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Граница без permission-промптов: правила PreToolUse и зоны vault.
|
||||
|
||||
Модель везде в `bypassPermissions`; что ей можно, решают `disallowed_tools`
|
||||
и правила отсюда. Правило получает `ToolCall` и возвращает `Deny` с
|
||||
причиной, которую модель читает как результат тулзы, или `None`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import shlex
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from beaver_gateway.agents.policy import Deny, PolicyRule, ToolCall
|
||||
|
||||
from beaver_agent.vault import BEAVER, CHATS, VAULT
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = [
|
||||
"DEEP_DISALLOWED",
|
||||
"DISPATCHER_DISALLOWED",
|
||||
"DISTILLER_DISALLOWED",
|
||||
"STRICT_ZONES",
|
||||
"TRIAGE_DISALLOWED",
|
||||
"VAULT_POLICY",
|
||||
"ZONES",
|
||||
"Zones",
|
||||
"bash_zones",
|
||||
"forbid_skills",
|
||||
"requires_skill",
|
||||
"skill_tracker",
|
||||
"vault_zones",
|
||||
]
|
||||
|
||||
DISPATCHER_DISALLOWED = ("ExitPlanMode", "EnterPlanMode", "NotebookEdit")
|
||||
DEEP_DISALLOWED = (
|
||||
"AskUserQuestion",
|
||||
"ExitPlanMode",
|
||||
"EnterPlanMode",
|
||||
"NotebookEdit",
|
||||
"Task",
|
||||
)
|
||||
DISTILLER_DISALLOWED = ("AskUserQuestion", "Task", "WebSearch", "WebFetch")
|
||||
TRIAGE_DISALLOWED = DISTILLER_DISALLOWED
|
||||
|
||||
FILE_TOOLS = ("Write", "Edit", "MultiEdit", "NotebookEdit")
|
||||
MUTATING = frozenset(
|
||||
{
|
||||
"rm",
|
||||
"rmdir",
|
||||
"unlink",
|
||||
"mv",
|
||||
"cp",
|
||||
"tee",
|
||||
"truncate",
|
||||
"touch",
|
||||
"mkdir",
|
||||
"ln",
|
||||
"install",
|
||||
"rsync",
|
||||
"dd",
|
||||
"chmod",
|
||||
"chown",
|
||||
"shred",
|
||||
}
|
||||
)
|
||||
COPYING = frozenset({"cp", "install", "rsync"})
|
||||
DELETING = frozenset({"rm", "rmdir", "unlink", "shred", "dd", "truncate"})
|
||||
INPLACE = frozenset({"sed", "perl"})
|
||||
REDIRECTS = frozenset({">", ">>", ">|", "&>", "&>>", ">&"})
|
||||
SEPARATORS = frozenset({";", "&&", "||", "|", "&", "(", ")"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Zones:
|
||||
"""Зоны vault.
|
||||
|
||||
``write`` - всё, включая удаление; ``create`` - только новые файлы;
|
||||
``edit`` - создавать, править, переносить можно, удалять - нет;
|
||||
``protected`` - не трогать. Приоритет: write > protected > create > edit;
|
||||
путь в vault вне всех зон - отказ.
|
||||
"""
|
||||
|
||||
vault: Path
|
||||
write: tuple[Path, ...]
|
||||
create: tuple[Path, ...] = ()
|
||||
edit: tuple[Path, ...] = ()
|
||||
protected: tuple[Path, ...] = ()
|
||||
|
||||
def verdict(
|
||||
self, path: Path, *, creating: bool, deleting: bool = False
|
||||
) -> Deny | None:
|
||||
if not _under(path, self.vault):
|
||||
return None
|
||||
if any(_under(path, zone) for zone in self.write):
|
||||
return None
|
||||
rel = path.relative_to(self.vault)
|
||||
for zone in self.protected:
|
||||
if _under(path, zone):
|
||||
return Deny(
|
||||
reason=f"{rel}: «{zone.relative_to(self.vault)}» не трогаем - "
|
||||
"это зона Бобра"
|
||||
)
|
||||
for zone in self.create:
|
||||
if _under(path, zone):
|
||||
if creating and not path.exists():
|
||||
return None
|
||||
return Deny(
|
||||
reason=f"{rel}: в «{zone.relative_to(self.vault)}» можно только "
|
||||
"создавать новые файлы, существующие не трогаем"
|
||||
)
|
||||
for zone in self.edit:
|
||||
if _under(path, zone):
|
||||
if deleting:
|
||||
zones = ", ".join(
|
||||
f"«{z.relative_to(self.vault)}»" for z in self.write
|
||||
)
|
||||
return Deny(
|
||||
reason=f"{rel}: удалять можно только в {zones}; чужие "
|
||||
"заметки не удаляем - скажи Бобру, он сам"
|
||||
)
|
||||
return None
|
||||
zones = ", ".join(f"«{z.relative_to(self.vault)}»" for z in self.write)
|
||||
return Deny(reason=f"{rel}: vault только на чтение; писать можно в {zones}")
|
||||
|
||||
|
||||
def _under(path: Path, root: Path) -> bool:
|
||||
return path == root or root in path.parents
|
||||
|
||||
|
||||
def vault_zones(zones: Zones) -> PolicyRule:
|
||||
"""Write/Edit/NotebookEdit только в зонах; Write в create-зону - новый файл."""
|
||||
|
||||
def rule(call: ToolCall) -> Deny | None:
|
||||
if call.tool not in FILE_TOOLS:
|
||||
return None
|
||||
path = call.path()
|
||||
if path is None:
|
||||
return None
|
||||
return zones.verdict(path, creating=call.tool == "Write")
|
||||
|
||||
return rule
|
||||
|
||||
|
||||
def bash_zones(zones: Zones) -> PolicyRule:
|
||||
"""`rm`/`mv`/`cp`/`tee`/`sed -i`/редирект с путём в vault вне зон - отказ."""
|
||||
|
||||
def rule(call: ToolCall) -> Deny | None:
|
||||
if call.tool != "Bash":
|
||||
return None
|
||||
command = call.input.get("command")
|
||||
if not isinstance(command, str):
|
||||
return None
|
||||
for target, creating, deleting in _bash_targets(command):
|
||||
deny = zones.verdict(
|
||||
call.resolve(target), creating=creating, deleting=deleting
|
||||
)
|
||||
if deny is not None:
|
||||
return Deny(reason=f"Bash: {deny.reason}")
|
||||
return None
|
||||
|
||||
return rule
|
||||
|
||||
|
||||
def _bash_targets(command: str) -> Iterator[tuple[str, bool, bool]]:
|
||||
"""``(путь, создаёт, удаляет)`` для каждого пути, который команда трогает."""
|
||||
for segment in _segments(command):
|
||||
plain: list[str] = []
|
||||
i = 0
|
||||
while i < len(segment):
|
||||
word = segment[i]
|
||||
if word in REDIRECTS:
|
||||
if i + 1 < len(segment) and _pathlike(segment[i + 1]):
|
||||
yield segment[i + 1], True, False
|
||||
i += 2
|
||||
continue
|
||||
plain.append(word)
|
||||
i += 1
|
||||
for j, word in enumerate(plain):
|
||||
rest = plain[j + 1 :]
|
||||
inplace = word in INPLACE and any(a.startswith("-i") for a in rest)
|
||||
if word not in MUTATING and not inplace:
|
||||
continue
|
||||
args = [a for a in rest if _pathlike(a)]
|
||||
if word in COPYING:
|
||||
args = args[-1:]
|
||||
for arg in args:
|
||||
yield arg, False, word in DELETING
|
||||
break
|
||||
|
||||
|
||||
def _segments(command: str) -> Iterator[list[str]]:
|
||||
lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
|
||||
lexer.whitespace_split = True
|
||||
try:
|
||||
tokens = list(lexer)
|
||||
except ValueError:
|
||||
tokens = command.split()
|
||||
segment: list[str] = []
|
||||
for token in tokens:
|
||||
if token in SEPARATORS:
|
||||
if segment:
|
||||
yield segment
|
||||
segment = []
|
||||
else:
|
||||
segment.append(token)
|
||||
if segment:
|
||||
yield segment
|
||||
|
||||
|
||||
def _pathlike(word: str) -> bool:
|
||||
return bool(word) and not (
|
||||
word.startswith(("-", "&", "$", "/dev/"))
|
||||
or word.isdigit()
|
||||
or ("=" in word and "/" not in word.split("=", 1)[0])
|
||||
)
|
||||
|
||||
|
||||
def _skill_name(call: ToolCall) -> str:
|
||||
"""Имя скилла из `Skill({"skill": "vault:firefly", ...})`, без плагина."""
|
||||
name = str(call.input.get("skill") or call.input.get("name") or "")
|
||||
return name.rsplit(":", 1)[-1]
|
||||
|
||||
|
||||
def skill_tracker() -> PolicyRule:
|
||||
"""Запоминает открытые скиллы в состоянии сессии (для `requires_skill`)."""
|
||||
|
||||
def rule(call: ToolCall) -> None:
|
||||
if call.tool != "Skill":
|
||||
return
|
||||
opened: set[str] = call.state.setdefault("skills", set())
|
||||
opened.add(_skill_name(call))
|
||||
|
||||
return rule
|
||||
|
||||
|
||||
def forbid_skills(names: tuple[str, ...], kinds: tuple[str, ...]) -> PolicyRule:
|
||||
"""Скиллы из ``names`` (fnmatch, без учёта регистра) в ``kinds`` закрыты.
|
||||
|
||||
Мастер их не открывает - для этого есть ветка; правило отвечает так,
|
||||
чтобы модель сама её открыла.
|
||||
"""
|
||||
patterns = tuple(p.lower() for p in names)
|
||||
|
||||
def rule(call: ToolCall) -> Deny | None:
|
||||
if call.tool != "Skill" or call.kind not in kinds:
|
||||
return None
|
||||
name = _skill_name(call)
|
||||
if not any(fnmatch.fnmatchcase(name.lower(), p) for p in patterns):
|
||||
return None
|
||||
return Deny(reason=f"скилл {name} недоступен в {call.kind}: открой ветку")
|
||||
|
||||
return rule
|
||||
|
||||
|
||||
def requires_skill(skill: str, tools: tuple[str, ...]) -> PolicyRule:
|
||||
"""Тулзы из ``tools`` - только после `Skill(skill)` в той же сессии."""
|
||||
|
||||
def rule(call: ToolCall) -> Deny | None:
|
||||
if not any(fnmatch.fnmatchcase(call.tool, pattern) for pattern in tools):
|
||||
return None
|
||||
if skill in call.state.get("skills", ()):
|
||||
return None
|
||||
return Deny(
|
||||
reason=f"сначала открой скилл «{skill}» (тулза Skill), потом {call.tool}"
|
||||
)
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user