Harbor as of 2026-10-05: the coordination hub for the Open Sea's decomp team
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.
This commit is contained in:
commit
dbfd1720c5
43 files changed
+12440
No files matched your search
@@ -0,0 +1,262 @@
|
||||
"""Getting a team online and watching it work:
|
||||
|
||||
activity log the last tool calls of each agent (tool and target: a file path, a command, a Harbor tool)
|
||||
roll call the human asks every agent "are you there?" with a one-tap answer; shows who answered and how fast
|
||||
launch status per agent: app connected, brief read, roll call answered, stopped / on a trial run / running
|
||||
expert finder who has worked on a scope (notes, reviews, claims, tasks, messages)
|
||||
summaries a short summary pinned to a long thread
|
||||
|
||||
Timed resumes ("trial runs") live in core.set_flag / stopped(): a resume with an end time stops the agent again when
|
||||
the time is up, with no process needed.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
|
||||
from .util import HarborError, first_line, iso, parse_iso, parse_scope, scopes_overlap, utc
|
||||
|
||||
LAUNCH_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS activity_log(id INTEGER PRIMARY KEY, agent TEXT NOT NULL, at TEXT NOT NULL, tool TEXT,
|
||||
target TEXT);
|
||||
CREATE INDEX IF NOT EXISTS activity_agent ON activity_log(agent, id);
|
||||
CREATE TABLE IF NOT EXISTS summaries(id INTEGER PRIMARY KEY, thread_id INTEGER NOT NULL, body TEXT NOT NULL,
|
||||
author TEXT NOT NULL, at TEXT NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS summaries_thread ON summaries(thread_id, id);
|
||||
"""
|
||||
KEEP_ACTIVITY = 300
|
||||
SECRETISH = re.compile(r"(?i)(token|secret|password|passwd|api[_-]?key|bearer)\S*\s*[=:]?\s*\S+")
|
||||
|
||||
|
||||
def describe_target(tool, tool_input, paths):
|
||||
"""A short, safe description of what a tool call touches."""
|
||||
if paths:
|
||||
return ", ".join(paths)[:240]
|
||||
if isinstance(tool_input, dict):
|
||||
command = tool_input.get("command") or tool_input.get("cmd")
|
||||
if isinstance(command, list):
|
||||
command = command[-1] if command else ""
|
||||
if isinstance(command, str) and command.strip():
|
||||
return SECRETISH.sub(r"\1=…", " ".join(command.split()))[:240]
|
||||
for key in ("file_path", "path", "url", "pattern", "query"):
|
||||
if isinstance(tool_input.get(key), str):
|
||||
return tool_input[key][:240]
|
||||
return None
|
||||
|
||||
|
||||
class LaunchMixin:
|
||||
# ----------------------------------------------------------------- activity log
|
||||
def _log_activity(self, c, agent, tool, target):
|
||||
c.execute("INSERT INTO activity_log(agent, at, tool, target) VALUES (?,?,?,?)",
|
||||
(agent, self.now(), str(tool)[:60], target))
|
||||
c.execute("DELETE FROM activity_log WHERE agent=? AND id <= (SELECT id FROM activity_log WHERE agent=? "
|
||||
"ORDER BY id DESC LIMIT 1 OFFSET ?)", (agent, agent, KEEP_ACTIVITY))
|
||||
|
||||
def activity_feed(self, limit=60, agent=None):
|
||||
query, args = "SELECT * FROM activity_log", []
|
||||
if agent:
|
||||
query += " WHERE agent=?"
|
||||
args.append(agent)
|
||||
rows = [dict(r) for r in self.conn.execute(query + " ORDER BY id DESC LIMIT ?", (*args, int(limit)))]
|
||||
for r in rows:
|
||||
r["seconds"] = int((utc(self.clock()) - parse_iso(r["at"])).total_seconds())
|
||||
return rows
|
||||
|
||||
# ----------------------------------------------------------------- roll call
|
||||
def rollcall(self, by, origin, agents=None):
|
||||
"""The human asks each agent (default: all) to answer 'Here'. Works while agents are stopped: answering is
|
||||
one of the things a stopped agent may still do."""
|
||||
if by != self.human or origin != "web":
|
||||
raise HarborError(f"only {self.human} starts a roll call, from the board")
|
||||
targets = agents or self.project.ai_names
|
||||
for name in targets:
|
||||
self._check_agent(name, allow_human=False)
|
||||
stamp = self.now()
|
||||
row = self.conn.execute("SELECT value FROM meta WHERE key='rollcall'").fetchone()
|
||||
latest = json.loads(row["value"]).get("by_agent", {}) if row else {}
|
||||
for name in targets: # a new roll call replaces the agent's unanswered one
|
||||
old = latest.get(name)
|
||||
if old and self.question(old)["answer"] is None:
|
||||
self._write(lambda c, q=old: c.execute(
|
||||
"UPDATE questions SET answer='(replaced by a newer roll call)', answered_by=?, answered_at=? "
|
||||
"WHERE id=? AND answer IS NULL", (self.human, stamp, q)))
|
||||
msg = self.question(old)["message_id"]
|
||||
self._write(lambda c, m=msg, a=name: c.execute(
|
||||
"UPDATE deliveries SET read_at=COALESCE(read_at, ?), acked_at=COALESCE(acked_at, ?) WHERE "
|
||||
"message_id=? AND agent=?", (stamp, stamp, m, a)))
|
||||
made = []
|
||||
for name in targets:
|
||||
q = self.ask(by, f"Roll call ({stamp}): answer 'Here' so {self.human} knows you are online and reading "
|
||||
"Harbor - with the Harbor answer tool, or `harbor answer <question id> Here`.",
|
||||
["Here"], to=name, origin="web", thread="rollcall")
|
||||
made.append(q["id"])
|
||||
latest[name] = q["id"]
|
||||
self._write(lambda c: c.execute("INSERT INTO meta(key, value) VALUES ('rollcall', ?) ON CONFLICT(key) DO UPDATE "
|
||||
"SET value=excluded.value", (json.dumps({"at": stamp, "by_agent": latest}),)))
|
||||
return self.rollcall_status()
|
||||
|
||||
def rollcall_status(self):
|
||||
row = self.conn.execute("SELECT value FROM meta WHERE key='rollcall'").fetchone()
|
||||
if not row:
|
||||
return None
|
||||
info = json.loads(row["value"])
|
||||
out = []
|
||||
for qid in info.get("by_agent", {}).values() or info.get("questions", []):
|
||||
q = self.question(qid)
|
||||
d = self.conn.execute("SELECT read_at FROM deliveries WHERE message_id=? AND agent=?",
|
||||
(q["message_id"], q["to_name"])).fetchone()
|
||||
seconds = None
|
||||
if q["answered_at"]:
|
||||
seconds = int((parse_iso(q["answered_at"]) - parse_iso(q["created_at"])).total_seconds())
|
||||
out.append({"agent": q["to_name"], "question": qid, "read_at": d["read_at"] if d else None,
|
||||
"answered_at": q["answered_at"], "answer": q["answer"], "seconds": seconds})
|
||||
return {"at": info["at"], "agents": out}
|
||||
|
||||
# ----------------------------------------------------------------- launch status
|
||||
def note_brief(self, agent):
|
||||
self._write(lambda c: c.execute("INSERT INTO meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET "
|
||||
"value=excluded.value", (f"brief:{agent}", self.now())))
|
||||
|
||||
def launch_status(self):
|
||||
roll = {r["agent"]: r for r in (self.rollcall_status() or {}).get("agents", [])}
|
||||
links = self.connections()
|
||||
out = []
|
||||
for a in self.agents():
|
||||
if a["kind"] == "human":
|
||||
continue
|
||||
name = a["name"]
|
||||
brief = self.conn.execute("SELECT value FROM meta WHERE key=?", (f"brief:{name}",)).fetchone()
|
||||
stops = self.stopped(name)
|
||||
trial = self.trial(name)
|
||||
seen = [c for c in links.get(name, []) if c["minutes"] is not None and c["minutes"] <= 15]
|
||||
out.append({
|
||||
"agent": name, "host": next((x.get("host") for x in self.project.agents if x["name"] == name), ""),
|
||||
"connected": bool([c for c in seen if c["channel"] in ("mcp", "hook")]),
|
||||
"hooks": next((c["last_at"] for c in links.get(name, []) if c["channel"] == "hook"
|
||||
and c["minutes"] is not None and c["minutes"] <= 24 * 60), None),
|
||||
"connected_via": sorted({f"{c['channel']}{'/' + c['host'] if c['host'] else ''}" for c in seen}),
|
||||
"brief_at": brief["value"] if brief else None,
|
||||
"rollcall": roll.get(name),
|
||||
"state": "stopped" if stops else ("trial" if trial else "running"),
|
||||
"stop_reason": stops[0]["reason"] if stops else None, "trial_until": trial,
|
||||
"working": self.working(a), "last_tool": a.get("last_tool"),
|
||||
})
|
||||
return out
|
||||
|
||||
# ----------------------------------------------------------------- autopilot
|
||||
AUTOPILOT_REST_AFTER = 3
|
||||
|
||||
def continuation(self, agent):
|
||||
"""What a running agent should do next instead of ending its turn, or None to let it rest.
|
||||
|
||||
Stopped agents always rest: the human's stop is the off switch. An agent that ends AUTOPILOT_REST_AFTER
|
||||
turns in a row without a single tool call in between is let rest as well (attention shows it as resting),
|
||||
so a stuck agent does not loop. Any tool call resets the count."""
|
||||
if not self.project.autopilot or agent == self.human or self.stopped(agent):
|
||||
return None
|
||||
last = self.conn.execute("SELECT MAX(rowid) FROM activity_log WHERE agent=?", (agent,)).fetchone()[0] or 0
|
||||
key = f"autopilot:{agent}"
|
||||
row = self.conn.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone()
|
||||
state = json.loads(row["value"]) if row else {}
|
||||
streak = state.get("streak", 0) + 1 if state.get("last_tool") == last else 0
|
||||
resting = streak >= self.AUTOPILOT_REST_AFTER
|
||||
value = json.dumps({"streak": streak, "last_tool": last, "at": self.now(), "resting": resting})
|
||||
self._write(lambda c: c.execute("INSERT INTO meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET "
|
||||
"value=excluded.value", (key, value)))
|
||||
if resting:
|
||||
return None
|
||||
mine = [t for t in self.tasks(states=("in_progress",), limit=100000) if t["assignee"] == agent]
|
||||
band = self.conn.execute("SELECT 1 FROM claims WHERE agent=? AND kind='band' AND released_at IS NULL",
|
||||
(agent,)).fetchone()
|
||||
if mine:
|
||||
t = mine[0]
|
||||
step = (f"Continue task #{t['id']}: {t['title']}. When it is finished, submit it to the review queue with "
|
||||
"its task number, then take the next one with next.")
|
||||
elif (nxt := self.next_task(agent, take=False)) is not None:
|
||||
step = f"Take your next task with next (the best one now is #{nxt['id']}: {nxt['title']})."
|
||||
elif band:
|
||||
step = ("The task queue has nothing for you: take the next eligible game function in your own band "
|
||||
"(claim it first), make it readable, and submit it to the review queue.")
|
||||
else:
|
||||
waiting = [i for i in self.items(states=("submitted", "in_review")) if i["author"] != agent]
|
||||
spec = next((x for x in self.project.agents if x["name"] == agent), {})
|
||||
if waiting:
|
||||
step = (f"Review the next item in the review queue (#{waiting[0]['id']}: {waiting[0]['title']}) and move "
|
||||
"it to approved or changes_requested with the evidence.")
|
||||
else:
|
||||
step = ("Continue the work your instructions describe" + (f": {spec['instructions']}" if
|
||||
spec.get("instructions") else ".") + " When there is nothing at all to do right now, wait for "
|
||||
"new work with wait_for_messages instead of ending your turn.")
|
||||
return ("Harbor autopilot: you are running, so keep working instead of ending your turn. First read any new "
|
||||
f"Harbor messages (inbox). Then: {step} Keep doing current, and write a handoff at natural checkpoints, "
|
||||
"not every turn. Only the user's stop ends your work.")
|
||||
|
||||
def resting_agents(self):
|
||||
out = []
|
||||
for r in self.conn.execute("SELECT key, value FROM meta WHERE key LIKE 'autopilot:%'"):
|
||||
agent = r["key"].split(":", 1)[1]
|
||||
info = json.loads(r["value"])
|
||||
if info.get("resting") and not self.stopped(agent):
|
||||
out.append({"agent": agent, "since": info.get("at")})
|
||||
return out
|
||||
|
||||
# ----------------------------------------------------------------- expert finder
|
||||
def experts(self, scope, limit=5):
|
||||
"""Agents ranked by how much they have worked on a scope."""
|
||||
target = parse_scope(scope)
|
||||
score, detail = {}, {}
|
||||
|
||||
def add(agent, kind, weight):
|
||||
if agent == self.human:
|
||||
return
|
||||
score[agent] = score.get(agent, 0) + weight
|
||||
detail.setdefault(agent, {}).setdefault(kind, 0)
|
||||
detail[agent][kind] += 1
|
||||
for n in self.notes(target[0]):
|
||||
add(n["author"], "notes", 3)
|
||||
for r in self.conn.execute("SELECT * FROM claims ORDER BY id DESC LIMIT 5000"):
|
||||
if scopes_overlap(target, (r["scope"], r["lo"], r["hi"])) and r["kind"] != "band":
|
||||
add(r["agent"], "claims", 2)
|
||||
for r in self.conn.execute("SELECT i.scope, i.author, e.agent, e.to_state FROM item_events e JOIN items i "
|
||||
"ON i.id=e.item_id WHERE i.scope IS NOT NULL"):
|
||||
try:
|
||||
if scopes_overlap(target, parse_scope(r["scope"])):
|
||||
add(r["agent"], "drafts" if r["agent"] == r["author"] else "reviews", 3)
|
||||
except HarborError:
|
||||
pass
|
||||
ctx = self.context(scope, limit=40)
|
||||
for m in ctx["messages"]:
|
||||
add(m["sender"], "messages", 1)
|
||||
ranked = sorted(score, key=lambda a: (-score[a], -len(detail[a]), a))[:limit]
|
||||
return [{"agent": a, "score": score[a], "detail": detail[a]} for a in ranked]
|
||||
|
||||
# ----------------------------------------------------------------- thread summaries
|
||||
def summarize(self, author, thread, body):
|
||||
self._check_agent(author)
|
||||
info, _ = self.thread(thread)
|
||||
body = (body or "").strip()
|
||||
if not body:
|
||||
raise HarborError("empty summary")
|
||||
if len(body) > 4000:
|
||||
raise HarborError("a summary is at most 4,000 characters - it is meant to be short")
|
||||
now = self.now()
|
||||
sid = self._write(lambda c: c.execute("INSERT INTO summaries(thread_id, body, author, at) VALUES (?,?,?,?)",
|
||||
(info["id"], body, author, now)).lastrowid)
|
||||
return {"id": sid, "thread": info["topic"]}
|
||||
|
||||
def summary(self, thread_id):
|
||||
row = self.conn.execute("SELECT * FROM summaries WHERE thread_id=? ORDER BY id DESC LIMIT 1",
|
||||
(int(thread_id),)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def threads_needing_summary(self, agent, min_messages=20):
|
||||
rows = self.conn.execute(
|
||||
"SELECT t.id, t.topic, COUNT(m.id) n, MAX(m.created_at) last FROM threads t JOIN messages m ON m.thread_id=t.id "
|
||||
"WHERE t.topic NOT LIKE 'legacy:%' AND t.state='open' AND t.topic NOT IN ('general', 'control', 'questions', "
|
||||
"'rollcall', 'wins') GROUP BY t.id HAVING n>=? AND EXISTS (SELECT 1 FROM messages x WHERE x.thread_id=t.id "
|
||||
"AND x.sender=?)", (min_messages, agent)).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
s = self.summary(r["id"])
|
||||
if not s or s["at"] < r["last"] and self.conn.execute(
|
||||
"SELECT COUNT(*) FROM messages WHERE thread_id=? AND created_at>?", (r["id"], s["at"])).fetchone()[0] >= 10:
|
||||
out.append({"thread": r["topic"], "messages": r["n"]})
|
||||
return out
|
||||
Reference in new issue
Block a user