123 lines
4.7 KiB
Python
123 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())
|