Files
app/acp/room.py
T

124 lines
4.7 KiB
Python

"""Room daemon: a human comments on a room issue -> a seat answers, now, not at the next Tick.
python3 -m phastacp.room (long-running; LaunchAgent xyz.teahub.room on hb-5)
A room is an open issue whose body contains `<!-- room:`. Every POLL seconds, for each
room: if the newest conversational comment is from a human (not a seat, not a bot),
react 👀 on it (the "picked up" signal), run the addressed seat's turn (default: claude),
post the reply as that seat, and if the reply @mentions the other seat, give it one
turn too (max FOLLOW turns, so a seat pair can't filibuster the chair).
State: the issue itself (last comment + reactions). The only local state is a small
file remembering which comment ids were handled, so a restart cannot double-reply.
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from factory.gitea import REPO, Gitea, GiteaError # noqa: E402
from phastacp.cards import file_cards, second_cards # noqa: E402
from phastacp.taste import ask, speaker_label, turn_prompt # noqa: E402
SEATS = os.getenv("ROOM_SEATS", "claude,codex").split(",")
POLL = float(os.getenv("ROOM_POLL", "3"))
FOLLOW = int(os.getenv("ROOM_FOLLOW", "1"))
STATE = Path(os.getenv("ROOM_STATE", os.path.expanduser("~/claude/room/state.json")))
BOTS = {"gitea-actions", "tick", "matcha-actions"}
NOISE = ("<!-- room:closed -->", "<!-- receipt:", "<!-- mention-dispatch -->", "<!-- codex-job:")
def log(*a: object) -> None:
print(time.strftime("%H:%M:%S"), *a, flush=True)
def load_state() -> dict:
try:
return json.loads(STATE.read_text())
except Exception: # noqa: BLE001
return {"handled": []}
def save_state(st: dict) -> None:
STATE.parent.mkdir(parents=True, exist_ok=True)
st["handled"] = st["handled"][-500:]
STATE.write_text(json.dumps(st))
def conversational(c: dict) -> bool:
body = c.get("body") or ""
return c["user"]["login"] not in BOTS and not any(m in body for m in NOISE)
def rooms(g: Gitea) -> list[dict]:
return [i for i in g.open_issues() if (i.get("body") or "").lstrip().startswith("<!-- room:") and "<!-- room:closed -->" not in (i.get("body") or "")]
def agenda_of(issue: dict) -> str:
m = re.search(r"(?m)^agenda:\s*(.+)$", issue.get("body") or "")
return m.group(1).strip() if m else issue.get("title", "")
def serve_room(clients: dict[str, Gitea], issue: dict, st: dict) -> None:
number = int(issue["number"])
g = clients[SEATS[0]]
comments = [c for c in g.comments(number) if conversational(c)]
if not comments:
return
last = comments[-1]
who = last["user"]["login"]
if who in SEATS or last["id"] in st["handled"]:
return
# picked up
try:
g.post(f"/repos/{REPO}/issues/comments/{last['id']}/reactions", {"content": "eyes"})
except GiteaError:
pass
st["handled"].append(last["id"]); save_state(st)
transcript = [(speaker_label(c["user"]["login"], SEATS), (c.get("body") or "").strip()) for c in comments]
agenda = agenda_of(issue)
speaker = next((s for s in SEATS if re.search(rf"@{re.escape(s)}\b", last.get("body") or "")), SEATS[0])
for _ in range(1 + FOLLOW):
others = [s for s in SEATS if s != speaker]
t0 = time.time()
try:
text = ask(speaker, turn_prompt(speaker, agenda, transcript, others))
except subprocess.TimeoutExpired:
text = "!pass (timed out)"
posted = clients[speaker].comment(number, text)
transcript.append((speaker, text))
log(f"#{number} {speaker} replied to {who} ({len(text.split())}w, {time.time()-t0:.0f}s)")
try:
for r in file_cards(clients[speaker], clients[SEATS[0]], number, int(posted["id"]), text):
log(f"#{number} {speaker} {r}")
for r in second_cards(clients[speaker], clients[SEATS[0]], number, text):
log(f"#{number} {speaker} {r}")
except Exception as e: # noqa: BLE001
log(f"#{number} card filing failed: {e!r}")
nxt = next((s for s in others if re.search(rf"@{re.escape(s)}\b", text)), None)
if not nxt:
break
speaker = nxt
def main() -> int:
clients = {s: Gitea.seat(s) for s in SEATS}
st = load_state()
log(f"room daemon: seats={SEATS} poll={POLL}s")
while True:
try:
for issue in rooms(clients[SEATS[0]]):
serve_room(clients, issue, st)
except Exception as e: # noqa: BLE001 - keep serving; the ledger is the record
log("error:", repr(e)[:300])
time.sleep(POLL)
if __name__ == "__main__":
raise SystemExit(main())