feat(config,mcps,policy,compose): komodo alerts with hold, vibegram and ha python_tools, vault rw home zones
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user