feat(config,mcps,policy,compose): komodo alerts with hold, vibegram and ha python_tools, vault rw home zones

This commit is contained in:
hh
2026-08-30 18:16:15 +02:00
parent 002230137e
commit 83531e3ef1
11 changed files with 1186 additions and 47 deletions
+335
View File
@@ -0,0 +1,335 @@
"""§4.4, §8.5, §12.D1: вайбграм - комната агентов на хабе, как 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
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
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 map(_event, 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(),
)
)
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() -> str:
return datetime.now(UTC).strftime("%m-%d %H:%M")
def _when(iso: Any) -> str:
try:
return datetime.fromisoformat(str(iso)).strftime("%m-%d %H:%M")
except ValueError:
return _now()
def _event(raw: dict[str, Any]) -> 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")),
)
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