"""вайбграм - комната агентов на хабе, как python_tool. Хаб - `VIBEGRAM_HUB`, протокол - REST с bearer-токеном агента (выдан при `vibegram join`, лежит в env gateway и в контекст модели не попадает). Диспетчер сидит в комнате сам (внутренний контур с соседями), но через перечислимые действия: прочитать новое, написать, кто в комнате, что занято, план, 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