forked from Keelrleek/Harbor
Messages, threads, acknowledgements, claims, bands, tasks, review items and stop flags for a team of AI agents; the chat page and board; host hooks and MCP server; autopilot; tools/watch.py. Put under version control after the weekend's changes so they have history and can be rolled back. Local only.
403 lines
22 KiB
Python
403 lines
22 KiB
Python
"""Experimental team features on top of messages and tasks:
|
|
|
|
questions an agent asks the human (or an agent) a question with answer options; the answer comes back as a reply
|
|
notes team memory attached to a scope (fn:0x..., file:..., name:...); `context` gathers everything known
|
|
touches which files each agent is editing (from host hooks); two agents on the same file raise a collision
|
|
pulse per-agent activity in 5-minute buckets (tool calls and messages) for the board's timeline
|
|
metrics numbers agents report (readable functions, tests passing, ...) charted on the board
|
|
wins a landed review item is announced to the human
|
|
|
|
Mixed into Harbor (core.py); every method uses Harbor's connection, clock and write helper.
|
|
"""
|
|
import datetime
|
|
import json
|
|
import math
|
|
import re
|
|
from pathlib import PureWindowsPath
|
|
|
|
from .util import HarborError, first_line, iso, parse_scope, scopes_overlap, utc
|
|
|
|
EXTRA_SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS questions(
|
|
id INTEGER PRIMARY KEY, message_id INTEGER NOT NULL, asker TEXT NOT NULL, to_name TEXT NOT NULL,
|
|
question TEXT NOT NULL, options TEXT NOT NULL, fallback TEXT, answer TEXT, answered_by TEXT,
|
|
answer_message_id INTEGER, created_at TEXT NOT NULL, answered_at TEXT);
|
|
CREATE INDEX IF NOT EXISTS questions_message ON questions(message_id);
|
|
CREATE TABLE IF NOT EXISTS notes(
|
|
id INTEGER PRIMARY KEY, scope TEXT NOT NULL, lo INTEGER, hi INTEGER, body TEXT NOT NULL, author TEXT NOT NULL,
|
|
created_at TEXT NOT NULL, replaced_by INTEGER);
|
|
CREATE TABLE IF NOT EXISTS touches(agent TEXT NOT NULL, path TEXT NOT NULL, tool TEXT, at TEXT NOT NULL,
|
|
PRIMARY KEY(agent, path));
|
|
CREATE TABLE IF NOT EXISTS pulse(agent TEXT NOT NULL, bucket TEXT NOT NULL, tools INTEGER NOT NULL DEFAULT 0,
|
|
messages INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(agent, bucket));
|
|
CREATE TABLE IF NOT EXISTS metrics(id INTEGER PRIMARY KEY, name TEXT NOT NULL, value REAL NOT NULL, note TEXT,
|
|
by_agent TEXT NOT NULL, at TEXT NOT NULL);
|
|
CREATE INDEX IF NOT EXISTS metrics_name ON metrics(name, id);
|
|
"""
|
|
EDIT_TOOL = re.compile(r"edit|write|patch|apply|create|replace|insert|str_replace|multiedit|notebook", re.I)
|
|
PATCH_FILE = re.compile(r"^\*\*\* (?:Update|Add|Delete) File: (.+)$", re.M)
|
|
|
|
|
|
def bucket_of(dt):
|
|
return iso(dt.replace(minute=dt.minute - dt.minute % 5, second=0))
|
|
|
|
|
|
class ExtrasMixin:
|
|
# ----------------------------------------------------------------- questions with answer options
|
|
def ask(self, asker, question, options=(), to=None, due=None, fallback=None, origin="cli", thread=None):
|
|
"""Ask `to` (default: the human) a question; options are the answer buttons shown on the chat page."""
|
|
self._check_agent(asker, allow_human=(origin == "web"))
|
|
to = to or self.human
|
|
self._check_agent(to)
|
|
options = [" ".join(str(o).split())[:60] for o in options if str(o).strip()][:6]
|
|
if fallback:
|
|
self._check_agent(fallback, allow_human=False)
|
|
if fallback not in self.coordinators:
|
|
raise HarborError("the fallback answerer must be a coordinator")
|
|
if fallback == asker:
|
|
raise HarborError("the fallback answerer cannot be the one asking")
|
|
text = question.strip() + ("\n\nOptions: " + " / ".join(options) if options else "")
|
|
msg = self.send(asker, [to], text, thread=thread or "questions", subject="Questions", kind="question",
|
|
importance="high", due=due, origin=origin)
|
|
now = self.now()
|
|
qid = self._write(lambda c: c.execute(
|
|
"INSERT INTO questions(message_id, asker, to_name, question, options, fallback, created_at) "
|
|
"VALUES (?,?,?,?,?,?,?)", (msg["id"], asker, to, question.strip(), json.dumps(options), fallback, now)).lastrowid)
|
|
self._write(lambda c: c.execute("UPDATE messages SET refs=? WHERE id=?",
|
|
(json.dumps({**(self.message(msg["id"])["refs"] or {}), "question": qid}), msg["id"])))
|
|
return {**self.question(qid), "warnings": msg["warnings"]}
|
|
|
|
def question(self, qid):
|
|
row = self.conn.execute("SELECT * FROM questions WHERE id=?", (int(qid),)).fetchone()
|
|
if not row:
|
|
raise HarborError(f"no question #{qid}")
|
|
q = dict(row)
|
|
q["options"] = json.loads(q["options"])
|
|
return q
|
|
|
|
def questions(self, open_only=True, to=None, asker=None, limit=50):
|
|
query, args = "SELECT * FROM questions WHERE 1=1", []
|
|
if open_only:
|
|
query += " AND answer IS NULL"
|
|
if to:
|
|
query += " AND to_name=?"
|
|
args.append(to)
|
|
if asker:
|
|
query += " AND asker=?"
|
|
args.append(asker)
|
|
rows = self.conn.execute(query + " ORDER BY id DESC LIMIT ?", (*args, int(limit)))
|
|
return [self.question(r["id"]) for r in rows]
|
|
|
|
def questions_by_message(self, message_ids):
|
|
ids = [int(i) for i in message_ids]
|
|
out = {}
|
|
for start in range(0, len(ids), 500):
|
|
chunk = ids[start:start + 500]
|
|
for r in self.conn.execute(f"SELECT id FROM questions WHERE message_id IN ({','.join('?' * len(chunk))})", chunk):
|
|
q = self.question(r["id"])
|
|
out[q["message_id"]] = q
|
|
return out
|
|
|
|
def answer(self, by, qid, answer, origin="cli"):
|
|
q = self.question(qid)
|
|
if q["answer"] is not None:
|
|
raise HarborError(f"question #{qid} was already answered by {q['answered_by']}: {q['answer']}")
|
|
if by == self.human and origin != "web":
|
|
raise HarborError(f"only the chat page answers as {self.human}")
|
|
allowed = by == q["to_name"] or (q["to_name"] == self.human and by == q["fallback"] and
|
|
self.presence()["status"] != "here")
|
|
if not allowed:
|
|
raise HarborError(f"question #{qid} is for {q['to_name']}" + (
|
|
f"; {q['fallback']} may answer while {self.human} is away" if q["fallback"] else ""))
|
|
answer = " ".join(str(answer).split())
|
|
if not answer:
|
|
raise HarborError("empty answer")
|
|
if q["options"] and answer not in q["options"] and not answer.lower().startswith("other:"):
|
|
raise HarborError(f"answer with one of: {' / '.join(q['options'])} (or 'other: ...')")
|
|
now = self.now()
|
|
|
|
def claim_answer(c):
|
|
cur = c.execute("UPDATE questions SET answer=?, answered_by=?, answered_at=? WHERE id=? AND answer IS NULL",
|
|
(answer, by, now, int(qid)))
|
|
if cur.rowcount == 0:
|
|
raise HarborError(f"question #{qid} was answered meanwhile")
|
|
c.execute("UPDATE deliveries SET acked_at=COALESCE(acked_at, ?), read_at=COALESCE(read_at, ?) "
|
|
"WHERE message_id=? AND agent=?", (now, now, q["message_id"], q["to_name"]))
|
|
self._write(claim_answer)
|
|
people = [q["asker"]] + ([q["to_name"]] if by != q["to_name"] else []) # a fallback answer reaches the human
|
|
reply = self.send(by, people, f"Answer to question #{qid}: {answer}" + (
|
|
f" (answered by {by} while {q['to_name']} was away)" if by != q["to_name"] else ""),
|
|
reply_to=q["message_id"], kind="answer", importance="high", trusted_refs={"question": int(qid)},
|
|
origin="system")
|
|
self._write(lambda c: c.execute("UPDATE questions SET answer_message_id=? WHERE id=?", (reply["id"], int(qid))))
|
|
return self.question(qid)
|
|
|
|
# ----------------------------------------------------------------- notes and scope context
|
|
def note(self, author, scope, body, replaces=None):
|
|
self._check_agent(author)
|
|
canonical, lo, hi = parse_scope(scope)
|
|
body = (body or "").strip()
|
|
if not body:
|
|
raise HarborError("empty note")
|
|
now = self.now()
|
|
|
|
if replaces:
|
|
old = self.conn.execute("SELECT author FROM notes WHERE id=?", (int(replaces),)).fetchone()
|
|
if not old:
|
|
raise HarborError(f"no note #{replaces}")
|
|
if old["author"] != author and author not in self.coordinators:
|
|
raise HarborError(f"note #{replaces} is {old['author']}'s; add your own note instead")
|
|
|
|
def do(c):
|
|
nid = c.execute("INSERT INTO notes(scope, lo, hi, body, author, created_at) VALUES (?,?,?,?,?,?)",
|
|
(canonical, lo, hi, body, author, now)).lastrowid
|
|
if replaces:
|
|
c.execute("UPDATE notes SET replaced_by=? WHERE id=?", (nid, int(replaces)))
|
|
self._touch(c, author)
|
|
return nid
|
|
return {"id": self._write(do), "scope": canonical}
|
|
|
|
def notes(self, scope=None, include_replaced=False, limit=50):
|
|
rows = [dict(r) for r in self.conn.execute(
|
|
"SELECT * FROM notes" + ("" if include_replaced else " WHERE replaced_by IS NULL") + " ORDER BY id DESC")]
|
|
if scope:
|
|
target = parse_scope(scope)
|
|
rows = [r for r in rows if scopes_overlap(target, (r["scope"], r["lo"], r["hi"]))]
|
|
return rows[:limit]
|
|
|
|
def context(self, scope, limit=12):
|
|
"""Everything Harbor knows about a scope: notes, threads, messages that mention it, tasks, review items,
|
|
and who has claimed it before."""
|
|
canonical, lo, hi = parse_scope(scope)
|
|
target = (canonical, lo, hi)
|
|
needles = []
|
|
if lo is not None and lo == hi:
|
|
needles = [f"{lo:X}", f"0x{lo:X}"]
|
|
elif lo is None:
|
|
needles = [canonical.split(":", 1)[1]]
|
|
seen, messages = set(), []
|
|
for needle in needles:
|
|
for m in self.search(needle, limit=limit):
|
|
if m["id"] not in seen:
|
|
seen.add(m["id"])
|
|
messages.append(m)
|
|
messages.sort(key=lambda m: m["id"], reverse=True)
|
|
threads = [t for t in self.threads(limit=500, include_legacy=False) if t["topic"] == canonical]
|
|
tasks = [t for t in self.tasks(states=("open", "in_progress", "blocked", "review", "done", "cancelled"))
|
|
if t["scope"] and scopes_overlap(target, (t["scope"], t["lo"], t["hi"]))]
|
|
items = []
|
|
for row in self.conn.execute("SELECT * FROM items WHERE scope IS NOT NULL ORDER BY id DESC"):
|
|
try:
|
|
if scopes_overlap(target, parse_scope(row["scope"])):
|
|
items.append(dict(row))
|
|
except HarborError:
|
|
pass
|
|
claims = [dict(r) for r in self.conn.execute("SELECT * FROM claims ORDER BY id DESC LIMIT 2000")
|
|
if scopes_overlap(target, (r["scope"], r["lo"], r["hi"]))]
|
|
return {"scope": canonical, "notes": self.notes(canonical), "threads": threads, "messages": messages[:limit],
|
|
"tasks": tasks, "items": items, "claims": claims[:limit]}
|
|
|
|
def context_text(self, scope, limit=8):
|
|
ctx = self.context(scope, limit=limit)
|
|
lines = [f"What the team knows about {ctx['scope']}:"]
|
|
for n in ctx["notes"]:
|
|
lines.append(f" note #{n['id']} by {n['author']} ({n['created_at'][:10]}): {first_line(n['body'], 200)}")
|
|
for i in ctx["items"][:5]:
|
|
lines.append(f" review item #{i['id']} {i['state']} by {i['author']}: {i['title']}")
|
|
for t in ctx["tasks"][:5]:
|
|
lines.append(f" task #{t['id']} {t['state']} ({t['assignee'] or 'unassigned'}): {t['title']}")
|
|
if ctx["claims"]:
|
|
who = sorted({c["agent"] for c in ctx["claims"]})
|
|
lines.append(f" claimed before by: {', '.join(who)}")
|
|
for t in ctx["threads"][:3]:
|
|
lines.append(f" thread {t['topic']}: {t['messages']} messages (harbor thread {t['topic']})")
|
|
for m in ctx["messages"][:limit]:
|
|
lines.append(f" #{m['id']} {m['created_at'][:10]} {m['sender']}: {first_line(m['body'], 160)}")
|
|
for t in ctx["threads"][:3]:
|
|
s = self.summary(t["id"])
|
|
if s:
|
|
lines.append(f" summary of {t['topic']} by {s['author']}: {first_line(s['body'], 200)}")
|
|
experts = self.experts(ctx["scope"])
|
|
if experts:
|
|
lines.append(" who knows this: " + "; ".join(
|
|
f"{e['agent']} ({', '.join(f'{v} {k}' for k, v in e['detail'].items())})" for e in experts))
|
|
if len(lines) == 1:
|
|
lines.append(" nothing yet - add what you learn with `note`.")
|
|
return "\n".join(lines)
|
|
|
|
# ----------------------------------------------------------------- file touches and collisions
|
|
def _relative(self, agent, path, cwd=None):
|
|
"""A path as the team sees it: relative to the agent's workdir (worktrees of one repository compare equal)."""
|
|
raw = str(path).strip().strip('"').strip("'").replace("\\", "/")
|
|
p = PureWindowsPath(raw)
|
|
if not p.is_absolute() and cwd:
|
|
p = PureWindowsPath(cwd) / p
|
|
raw = str(p).replace("\\", "/")
|
|
roots = [a.get("workdir") for a in self.project.agents if a.get("workdir")]
|
|
spec = next((a for a in self.project.agents if a["name"] == agent), {})
|
|
ordered = ([spec.get("workdir")] if spec.get("workdir") else []) + roots + ([cwd] if cwd else [])
|
|
if p.is_absolute():
|
|
for root in ordered:
|
|
try:
|
|
return str(p.relative_to(PureWindowsPath(root))).replace("\\", "/").lower()
|
|
except ValueError:
|
|
continue
|
|
return raw.lower()
|
|
while raw.startswith("./"):
|
|
raw = raw[2:]
|
|
return raw.lower()
|
|
|
|
@staticmethod
|
|
def edited_paths(tool, tool_input):
|
|
"""Paths an edit-like tool call writes, from the common shapes of hook payloads."""
|
|
def strings(value):
|
|
if isinstance(value, str):
|
|
yield value
|
|
elif isinstance(value, dict):
|
|
for v in value.values():
|
|
yield from strings(v)
|
|
elif isinstance(value, list):
|
|
for v in value:
|
|
yield from strings(v)
|
|
if not EDIT_TOOL.search(str(tool or "")) or not isinstance(tool_input, dict):
|
|
return [m.strip() for s in strings(tool_input) for m in PATCH_FILE.findall(s)]
|
|
paths = []
|
|
for key in ("file_path", "filePath", "path", "notebook_path", "target_file"):
|
|
if isinstance(tool_input.get(key), str):
|
|
paths.append(tool_input[key])
|
|
for key in ("paths", "files"):
|
|
if isinstance(tool_input.get(key), list):
|
|
paths += [x for x in tool_input[key] if isinstance(x, str)]
|
|
for key in ("patch", "input", "command"):
|
|
value = tool_input.get(key)
|
|
if isinstance(value, list):
|
|
value = " ".join(map(str, value))
|
|
if isinstance(value, str):
|
|
paths += [m.strip() for m in PATCH_FILE.findall(value)]
|
|
return paths
|
|
|
|
def record_touches(self, agent, paths, tool="", cwd=None, window=None):
|
|
"""Remember that agent is editing paths; returns collisions [{path, other, minutes}] (and announces new ones)."""
|
|
if not paths:
|
|
return []
|
|
window = window or self.project.threshold("collision_window", 45)
|
|
now_dt = utc(self.clock())
|
|
now = iso(now_dt)
|
|
since = iso(now_dt - datetime.timedelta(minutes=window))
|
|
rel = sorted({self._relative(agent, p, cwd) for p in paths})
|
|
collisions = []
|
|
shared = self.is_shared_file
|
|
|
|
def do(c):
|
|
for path in rel:
|
|
c.execute("INSERT INTO touches(agent, path, tool, at) VALUES (?,?,?,?) ON CONFLICT(agent, path) DO UPDATE "
|
|
"SET tool=excluded.tool, at=excluded.at", (agent, path, str(tool)[:60], now))
|
|
if shared(path):
|
|
continue # append-only file the integrator merges: not a collision
|
|
for r in c.execute("SELECT agent, at FROM touches WHERE path=? AND agent!=? AND at>=?", (path, agent, since)):
|
|
collisions.append({"path": path, "other": r["agent"], "minutes": self.minutes_since(r["at"])})
|
|
self._write(do)
|
|
fresh = []
|
|
for col in collisions:
|
|
pair = "|".join(sorted([agent, col["other"]]))
|
|
key = f"collision:{pair}:{col['path']}"
|
|
row = self.conn.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone()
|
|
if row and self.minutes_since(row["value"]) < 120:
|
|
continue
|
|
self._write(lambda c, k=key: c.execute("INSERT INTO meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE "
|
|
"SET value=excluded.value", (k, now)))
|
|
fresh.append(col)
|
|
people = sorted({col["other"], *self.project.coordinators} - {agent})
|
|
self.send(agent, people, f"Heads-up from Harbor: {agent} is editing {col['path']}, which {col['other']} "
|
|
f"edited {col['minutes']} min ago. Agree in this thread who changes what before either of you "
|
|
"commits, so the merge does not conflict.", thread=f"file:{col['path']}",
|
|
subject=f"Both editing {col['path']}", kind="finding", importance="high",
|
|
refs={"file": col["path"]}, origin="hook")
|
|
return collisions
|
|
|
|
def is_shared_file(self, path):
|
|
"""True for files the project lists in shared_files (e.g. annotations/*.csv): every agent appends to them on its
|
|
own branch and the integrator merges them, so two agents editing one is normal, not a collision."""
|
|
import fnmatch
|
|
p = str(path).replace("\\", "/")
|
|
return any(fnmatch.fnmatch(p, pattern) for pattern in self.project.shared_files)
|
|
|
|
def recent_collisions(self, minutes=120):
|
|
since = iso(utc(self.clock() - minutes * 60))
|
|
rows = self.conn.execute("SELECT a.path, a.agent a1, b.agent a2, MAX(a.at, b.at) at FROM touches a JOIN touches b "
|
|
"ON a.path=b.path AND a.agent<b.agent WHERE a.at>=? AND b.at>=?", (since, since))
|
|
return [dict(r) for r in rows if not self.is_shared_file(r["path"])]
|
|
|
|
# ----------------------------------------------------------------- pulse (activity timeline)
|
|
def _pulse(self, c, agent, tools=0, messages=0):
|
|
c.execute("INSERT INTO pulse(agent, bucket, tools, messages) VALUES (?,?,?,?) ON CONFLICT(agent, bucket) DO UPDATE "
|
|
"SET tools=tools+excluded.tools, messages=messages+excluded.messages",
|
|
(agent, bucket_of(utc(self.clock())), tools, messages))
|
|
|
|
def pulse(self, hours=12):
|
|
since = bucket_of(utc(self.clock() - hours * 3600))
|
|
out = {}
|
|
for r in self.conn.execute("SELECT * FROM pulse WHERE bucket>=? ORDER BY bucket", (since,)):
|
|
out.setdefault(r["agent"], []).append({"bucket": r["bucket"], "tools": r["tools"], "messages": r["messages"]})
|
|
return {"since": since, "now": self.now(), "agents": out}
|
|
|
|
def idle_minutes(self, agent):
|
|
row = self.conn.execute("SELECT MAX(bucket) b FROM pulse WHERE agent=?", (agent,)).fetchone()
|
|
return self.minutes_since(row["b"]) if row and row["b"] else None
|
|
|
|
# ----------------------------------------------------------------- metrics and wins
|
|
def metric(self, by, name, value, note=None):
|
|
self._check_agent(by)
|
|
name = re.sub(r"[^a-z0-9_.-]+", "-", str(name).strip().lower())[:40]
|
|
if not name:
|
|
raise HarborError("a metric needs a name")
|
|
try:
|
|
value = float(value)
|
|
except (TypeError, ValueError):
|
|
raise HarborError(f"metric value {value!r} is not a number")
|
|
if not math.isfinite(value):
|
|
raise HarborError("a metric value must be a finite number")
|
|
now = self.now()
|
|
mid = self._write(lambda c: c.execute("INSERT INTO metrics(name, value, note, by_agent, at) VALUES (?,?,?,?,?)",
|
|
(name, value, note, by, now)).lastrowid)
|
|
return {"id": mid, "name": name, "value": value}
|
|
|
|
def metrics(self, points=60):
|
|
out = {}
|
|
for r in self.conn.execute("SELECT DISTINCT name FROM metrics ORDER BY name"):
|
|
rows = [dict(x) for x in self.conn.execute("SELECT * FROM metrics WHERE name=? ORDER BY id DESC LIMIT ?",
|
|
(r["name"], int(points)))]
|
|
rows.reverse()
|
|
out[r["name"]] = {"latest": rows[-1], "series": [{"at": x["at"], "value": x["value"]} for x in rows]}
|
|
return out
|
|
|
|
def announce_win(self, item, agent, commit):
|
|
"""Tell the human, in the 'wins' thread, that an item landed."""
|
|
if agent == self.human:
|
|
return
|
|
text = f"Landed: {item['title']} by {item['author']}" + (f" ({item['scope']})" if item.get("scope") else "") + \
|
|
(f" in {commit}" if commit else "") + "."
|
|
self.send(agent, [self.human], text, thread="wins", subject="Wins", kind="status", refs={"item": item["id"]},
|
|
origin="system")
|
|
|
|
def export_thread(self, thread):
|
|
"""A thread as Markdown, for records and reports."""
|
|
info, messages = self.thread(thread)
|
|
lines = [f"# {info['subject'] or info['topic']}", "", f"Thread `{info['topic']}` in {self.project.title}, "
|
|
f"{len(messages)} messages, exported {self.now()}.", ""]
|
|
for m in messages:
|
|
to = ", ".join(m["recipients"])
|
|
tags = [t for t in (m["kind"] if m["kind"] != "note" else "", m["importance"] if m["importance"] != "normal"
|
|
else "", f"corrects #{m['corrects']}" if m.get("corrects") else "") if t]
|
|
lines.append(f"## #{m['id']} {m['sender']} → {to} · {m['created_at'].replace('T', ' ')[:16]} UTC"
|
|
+ (f" ({', '.join(tags)})" if tags else ""))
|
|
lines += ["", m["body"], ""]
|
|
return "\n".join(lines)
|
|
|
|
def wins(self, since="24h"):
|
|
from .util import parse_since
|
|
start = parse_since(since, utc(self.clock()))
|
|
return [dict(r) for r in self.conn.execute(
|
|
"SELECT e.*, i.title, i.author, i.scope FROM item_events e JOIN items i ON i.id=e.item_id "
|
|
"WHERE e.to_state='landed' AND e.at>=? ORDER BY e.id DESC", (start,))]
|