agent-memory-brain 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_memory_brain-0.2.0.dist-info/METADATA +98 -0
- agent_memory_brain-0.2.0.dist-info/RECORD +54 -0
- agent_memory_brain-0.2.0.dist-info/WHEEL +4 -0
- agent_memory_brain-0.2.0.dist-info/entry_points.txt +3 -0
- agent_memory_brain-0.2.0.dist-info/licenses/LICENSE +67 -0
- devmem/__init__.py +3 -0
- devmem/ado.py +433 -0
- devmem/agent_colors.py +74 -0
- devmem/cli.py +500 -0
- devmem/db.py +1069 -0
- devmem/dispatch.py +478 -0
- devmem/embed.py +111 -0
- devmem/filelock.py +53 -0
- devmem/git_sync.py +261 -0
- devmem/guard.py +214 -0
- devmem/hook_log.py +49 -0
- devmem/ingest/__init__.py +1 -0
- devmem/ingest/claude.py +87 -0
- devmem/ingest/codex.py +175 -0
- devmem/ingest/runner.py +318 -0
- devmem/ingest/subagents.py +250 -0
- devmem/maintenance.py +801 -0
- devmem/mcp_server.py +542 -0
- devmem/messages.py +684 -0
- devmem/panel/__init__.py +1 -0
- devmem/panel/app.py +971 -0
- devmem/panel/state.py +358 -0
- devmem/panel/static/base.css +27 -0
- devmem/panel/static/brain.css +473 -0
- devmem/panel/static/brain.js +1118 -0
- devmem/panel/static/brain3d.js +600 -0
- devmem/panel/static/chat.js +18 -0
- devmem/panel/static/common.js +208 -0
- devmem/panel/static/site.css +508 -0
- devmem/panel/static/tokens.css +19 -0
- devmem/panel/templates/ado.html +62 -0
- devmem/panel/templates/agents.html +35 -0
- devmem/panel/templates/audit.html +19 -0
- devmem/panel/templates/base.html +42 -0
- devmem/panel/templates/brain.html +101 -0
- devmem/panel/templates/maintenance.html +32 -0
- devmem/panel/templates/memory.html +71 -0
- devmem/panel/templates/message_thread.html +49 -0
- devmem/panel/templates/messages.html +8 -0
- devmem/panel/templates/overview.html +109 -0
- devmem/panel/templates/project.html +48 -0
- devmem/panel/templates/projects.html +21 -0
- devmem/panel/templates/session.html +49 -0
- devmem/panel/templates/sessions.html +67 -0
- devmem/panel/templates/subagent.html +27 -0
- devmem/procs.py +10 -0
- devmem/projects.py +17 -0
- devmem/secrets.py +9 -0
- devmem/skills.py +105 -0
devmem/messages.py
ADDED
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
"""Messages between agents and the subagent feed, shared by the MCP tools, the log scanner and the panel."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
import sqlite3
|
|
7
|
+
import zlib
|
|
8
|
+
from datetime import UTC, datetime, timedelta
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .db import now, parse_ts
|
|
13
|
+
from .secrets import SECRET
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger("devmem.messages")
|
|
16
|
+
|
|
17
|
+
KINDS = ("message", "question", "finding", "event")
|
|
18
|
+
BODY_MAX, SUBJECT_MAX, NAME_MAX = 8000, 200, 100
|
|
19
|
+
RATE_PER_MIN, RETENTION_DAYS, STALE_S = 30, 30, 600
|
|
20
|
+
STOP_S = 2 * 3600 # no write for this long and not finished: the agent counts as stopped, gets a "(no report)" event and its thread closes
|
|
21
|
+
USER = "user"
|
|
22
|
+
EVENTS_TO = "#agents" # Part A events go to a channel, so they only reach inboxes of agents that posted there
|
|
23
|
+
LIVE_TO = "#live"
|
|
24
|
+
LIVE_ROWS_LIMIT = 200
|
|
25
|
+
_UNREAD_BY_USER = "NOT EXISTS(SELECT 1 FROM message_reads mr WHERE mr.message_id=m.id AND mr.reader='user')"
|
|
26
|
+
FRIENDLY_NAMES = {"silent-failure-hunter": "bug hunter", "code-reviewer": "code reviewer", "pr-test-analyzer": "test reviewer",
|
|
27
|
+
"general-purpose": "worker", "Explore": "explorer", "Plan": "planner"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def friendly_name(value: str | None) -> str:
|
|
31
|
+
short = (value or "subagent").rsplit(":", 1)[-1]
|
|
32
|
+
return FRIENDLY_NAMES.get(short, short)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _plain(text: str) -> str:
|
|
36
|
+
text = re.sub(r"!\[([^]]*)\]\([^)]*\)|\[([^]]+)\]\([^)]*\)", lambda m: m.group(1) or m.group(2) or "", text)
|
|
37
|
+
text = re.sub(r"[`*_>#]", "", text)
|
|
38
|
+
return " ".join(text.split())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _cut(text: str, size: int = 80) -> str:
|
|
42
|
+
text = _plain(text)
|
|
43
|
+
return text if len(text) <= size else text[:size - 1].rstrip() + "…"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _sentence(text: str, size: int = 70) -> str:
|
|
47
|
+
plain = _plain(text)
|
|
48
|
+
sentence = re.split(r"(?<=[.!?])\s+", plain, maxsplit=1)[0]
|
|
49
|
+
return _cut(sentence, size)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def report_outcome(text: str) -> tuple[str, bool]:
|
|
53
|
+
"""Return a terse deterministic report result and whether it found a problem."""
|
|
54
|
+
plain = _plain(text)
|
|
55
|
+
if re.search(r"\b(no (?:issues|bugs)|nothing found|LGTM|all pass(?:ed)?)\b", plain, re.I): return "no issues found", False
|
|
56
|
+
count = re.search(r"\b(\d+)\s+(findings?|issues?|bugs?|test gaps?)\b", plain, re.I)
|
|
57
|
+
if count:
|
|
58
|
+
amount, noun = int(count.group(1)), count.group(2).lower()
|
|
59
|
+
noun = "test gaps" if "gap" in noun else ("bugs" if "bug" in noun else "issues")
|
|
60
|
+
severity = re.search(r"\(([^)]*(?:critical|high)[^)]*)\)", plain, re.I)
|
|
61
|
+
return f"{amount} {noun} found" + (f" ({_cut(severity.group(1), 38)})" if severity else ""), amount > 0
|
|
62
|
+
passed = re.search(r"\b(\d+)\s+passed\s*/\s*(\d+)\s+failed\b", plain, re.I)
|
|
63
|
+
if passed:
|
|
64
|
+
good, bad = map(int, passed.groups())
|
|
65
|
+
return ("no issues found" if not bad else f"{bad} failed ({good} passed)"), bool(bad)
|
|
66
|
+
severities = re.findall(r"\b(\d+)\s+(critical|high|important|medium|low)\b", plain, re.I)
|
|
67
|
+
if severities:
|
|
68
|
+
amount = sum(int(number) for number, _ in severities)
|
|
69
|
+
detail = ", ".join(f"{number} {level.lower()}" for number, level in severities)
|
|
70
|
+
return f"{amount} issues found ({_cut(detail, 38)})", amount > 0
|
|
71
|
+
headings = re.findall(r"(?im)^\s*(?:#+\s*)?(?:critical|high|important)[^\n]*\n((?:\s*(?:[-*]|\d+[.)])\s+[^\n]+\n?)+)", text)
|
|
72
|
+
found = sum(len(re.findall(r"(?m)^\s*(?:[-*]|\d+[.)])\s+", block)) for block in headings)
|
|
73
|
+
if found: return f"{found} issues found", True
|
|
74
|
+
return _sentence(text), False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def headline_for(row: dict[str, Any], agent_type: str | None = None) -> tuple[str, str]:
|
|
78
|
+
"""One plain-language feed line and its status icon."""
|
|
79
|
+
kind = row.get("sender_kind")
|
|
80
|
+
who = friendly_name(agent_type or row.get("sender")) if row.get("sender_declared") and kind != "codex" else {"user": "You", "claude": "Claude", "codex": "Codex"}.get(kind, friendly_name(agent_type or row.get("sender")))
|
|
81
|
+
finish = lambda text, icon: (_cut(text, 80), icon)
|
|
82
|
+
if row.get("kind") == "event" and row.get("agent_id"):
|
|
83
|
+
subject = row.get("subject") or ""
|
|
84
|
+
if subject.startswith("started:"):
|
|
85
|
+
return finish(f"{who}: started — {_cut(subject.removeprefix('started:').strip(), 72)}", "▶")
|
|
86
|
+
if subject.startswith("dispatched "):
|
|
87
|
+
return finish(f"Claude: {_cut(subject, 72)}", "▶")
|
|
88
|
+
if subject.startswith("stopped "): return finish(f"{who}: stopped without a report", "■")
|
|
89
|
+
outcome, findings = report_outcome(row.get("body") or "")
|
|
90
|
+
if (row.get("body") or "").startswith("(no report: agent stopped)"): return finish(f"{who}: stopped without a report", "■")
|
|
91
|
+
return finish(f"{who}: {_cut(outcome, max(8, 80 - len(who) - 2))}", "⚠" if findings else "✓")
|
|
92
|
+
subject = row.get("subject") or _sentence(row.get("body") or "", 72)
|
|
93
|
+
icon = "?" if row.get("kind") == "question" else "✉"
|
|
94
|
+
if re.match(re.escape(who) + r"\b", subject, re.I): return finish(subject, icon) # already names the speaker
|
|
95
|
+
if row.get("kind") == "question": return finish(f"{who} asks: {_cut(subject, max(8, 80 - len(who) - 7))}", "?")
|
|
96
|
+
return finish(f"{who}: {_cut(subject, max(8, 80 - len(who) - 2))}", "✉")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _rows(conn, sql: str, params=()) -> list[dict[str, Any]]:
|
|
100
|
+
return [dict(row) for row in conn.execute(sql, params).fetchall()]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def guard(*texts: str | None) -> None:
|
|
104
|
+
if any(text and SECRET.search(text) for text in texts):
|
|
105
|
+
raise ValueError("message rejected: it looks like it contains a secret (key, token or password); nothing was stored")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _name(value: str | None, field: str) -> str:
|
|
109
|
+
value = (value or "").strip()
|
|
110
|
+
if not value or len(value) > NAME_MAX: raise ValueError(f"{field} must be 1-{NAME_MAX} characters")
|
|
111
|
+
return value
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def post(conn, body: str, *, sender: str, sender_kind: str, to: str = "*", subject: str | None = None, kind: str = "message",
|
|
115
|
+
reply_to: int | None = None, project: str | None = None, declared: bool = False) -> int:
|
|
116
|
+
"""Store one message and return its id; a reply joins its parent's thread and answers an open question."""
|
|
117
|
+
if kind not in KINDS: raise ValueError("kind must be " + ", ".join(KINDS))
|
|
118
|
+
if not isinstance(body, str) or not body.strip(): raise ValueError("body is required")
|
|
119
|
+
if len(body) > BODY_MAX: raise ValueError(f"body exceeds {BODY_MAX} characters; put long material in a file and post its path")
|
|
120
|
+
if subject is not None and len(subject) > SUBJECT_MAX: raise ValueError(f"subject exceeds {SUBJECT_MAX} characters")
|
|
121
|
+
sender, to = _name(sender, "sender"), _name(to, "to")
|
|
122
|
+
speaker = friendly_name(sender) if declared and sender_kind != "codex" else {"user": "You", "claude": "Claude", "codex": "Codex"}.get(sender_kind, friendly_name(sender))
|
|
123
|
+
derived_subject = subject is None
|
|
124
|
+
if derived_subject: subject = SECRET.sub("[redacted]", _cut(f"{speaker}: {_sentence(body, 72)}", SUBJECT_MAX))
|
|
125
|
+
guard(body, None if derived_subject else subject) # before anything is written: a rejected secret must never reach the DB
|
|
126
|
+
if conn.execute("SELECT COUNT(*) FROM messages WHERE sender=? AND agent_id IS NULL AND datetime(created_at) >= datetime('now','-60 seconds')",
|
|
127
|
+
(sender,)).fetchone()[0] >= RATE_PER_MIN:
|
|
128
|
+
raise ValueError(f"rate limit: at most {RATE_PER_MIN} posts per minute per sender")
|
|
129
|
+
parent = None
|
|
130
|
+
if reply_to is not None:
|
|
131
|
+
parent = conn.execute("SELECT * FROM messages WHERE id=?", (reply_to,)).fetchone()
|
|
132
|
+
if not parent: raise ValueError(f"message {reply_to} does not exist")
|
|
133
|
+
cur = conn.execute("""INSERT INTO messages(thread_id,reply_to,project,sender,sender_kind,sender_declared,recipient,kind,subject,body,status,created_at)
|
|
134
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,'open',?)""", (parent["thread_id"] if parent else None, reply_to, project if project is not None else (parent["project"] if parent else None),
|
|
135
|
+
sender, sender_kind, int(declared), to, kind, subject, body, now()))
|
|
136
|
+
message_id = cur.lastrowid
|
|
137
|
+
if parent is None: conn.execute("UPDATE messages SET thread_id=id WHERE id=?", (message_id,))
|
|
138
|
+
elif parent["kind"] == "question" and parent["status"] == "open":
|
|
139
|
+
conn.execute("UPDATE messages SET status='answered' WHERE id=?", (reply_to,))
|
|
140
|
+
return message_id
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _with_thread(conn, where: str, params=(), order="m.id", limit: int | None = None) -> list[dict[str, Any]]:
|
|
144
|
+
sql = f"""SELECT m.*, r.status thread_status, r.subject thread_subject FROM messages m LEFT JOIN messages r ON r.id=m.thread_id
|
|
145
|
+
WHERE {where} ORDER BY {order}""" + (" LIMIT ?" if limit else "")
|
|
146
|
+
return _rows(conn, sql, (*params, limit) if limit else params)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def mark_read(conn, ids, reader: str) -> None:
|
|
150
|
+
conn.executemany("INSERT OR IGNORE INTO message_reads(message_id,reader,read_at) VALUES(?,?,?)", [(i, reader, now()) for i in ids])
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def inbox(conn, reader: str, unread_only: bool = True, limit: int = 20) -> list[dict[str, Any]]:
|
|
154
|
+
"""Messages to the reader, to '*', or to channels the reader posted in; oldest first, then marked read."""
|
|
155
|
+
rows = _with_thread(conn, """m.sender != :me AND (m.recipient=:me OR m.recipient='*' OR
|
|
156
|
+
(m.recipient=:live AND (m.sender_kind='user' OR EXISTS(SELECT 1 FROM room_connections c WHERE c.agent_key=m.sender))
|
|
157
|
+
AND EXISTS(SELECT 1 FROM room_connections rc WHERE rc.agent_key=:me AND m.id>rc.since_id)) OR
|
|
158
|
+
(m.recipient LIKE '#%' AND m.recipient NOT IN (:events,:live) AND m.recipient IN
|
|
159
|
+
(SELECT recipient FROM messages WHERE sender=:me AND recipient LIKE '#%' AND recipient NOT IN (:events,:live))))
|
|
160
|
+
AND (:everything OR NOT EXISTS(SELECT 1 FROM message_reads mr WHERE mr.message_id=m.id AND mr.reader=:me))""",
|
|
161
|
+
{"me": reader, "live": LIVE_TO, "events": EVENTS_TO, "everything": int(not unread_only)}, order="m.id DESC")[:max(1, min(limit, 100))]
|
|
162
|
+
rows.reverse()
|
|
163
|
+
# fix(2026-10-28): only an unread check consumes messages; re-reading history (unread_only=False) must not mark everything read.
|
|
164
|
+
if unread_only: mark_read(conn, [row["id"] for row in rows], reader)
|
|
165
|
+
return rows
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def live_rows(conn, hours: int = 2) -> list[dict[str, Any]]:
|
|
169
|
+
"""Recent room timeline, excluding private agent-to-agent mail."""
|
|
170
|
+
rows = _rows(conn, """SELECT * FROM messages m WHERE datetime(m.created_at)>=datetime('now', ?)
|
|
171
|
+
AND NOT (m.sender_kind IN ('claude','subagent','codex') AND m.recipient NOT IN ('*','user') AND m.recipient NOT LIKE '#%')
|
|
172
|
+
ORDER BY m.id DESC LIMIT ?""", (f"-{hours} hours", LIVE_ROWS_LIMIT))
|
|
173
|
+
rows.reverse()
|
|
174
|
+
for row in rows:
|
|
175
|
+
if row["sender_kind"] != "user" or row["recipient"] != "#live":
|
|
176
|
+
row["delivery"] = []
|
|
177
|
+
continue
|
|
178
|
+
reads = [read[0] for read in conn.execute("SELECT reader FROM message_reads WHERE message_id=? AND reader!=?", (row["id"], USER))]
|
|
179
|
+
read_set = set(reads)
|
|
180
|
+
delivery = [{"state": "received", "key": reader} for reader in reads]
|
|
181
|
+
waiting = conn.execute("SELECT agent_key FROM room_connections WHERE since_id<?", (row["id"],)).fetchall()
|
|
182
|
+
delivery.extend({"state": "waiting", "key": member[0]} for member in waiting if member[0] not in read_set)
|
|
183
|
+
row["delivery"] = delivery
|
|
184
|
+
return rows
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def thread(conn, thread_id: int) -> list[dict[str, Any]]:
|
|
188
|
+
return _with_thread(conn, "m.thread_id=?", (thread_id,))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def last_id(conn, thread_id: int) -> int:
|
|
192
|
+
return conn.execute("SELECT COALESCE(MAX(id),0) FROM messages WHERE thread_id=?", (thread_id,)).fetchone()[0]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def newer(conn, thread_id: int, after_id: int) -> list[dict[str, Any]]:
|
|
196
|
+
return _with_thread(conn, "m.thread_id=? AND m.id>?", (thread_id, after_id))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def wait_baseline(conn, thread_id: int, reader: str) -> int:
|
|
200
|
+
"""Default after_id for message_wait: the reader's own newest message in the thread, else the thread root.
|
|
201
|
+
So a reply that landed before the first wait (or between two waits) is still returned."""
|
|
202
|
+
row = conn.execute("SELECT MAX(id) FROM messages WHERE thread_id=? AND sender=?", (thread_id, reader)).fetchone()
|
|
203
|
+
return row[0] if row and row[0] else thread_id
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def close(conn, thread_id: int) -> int:
|
|
207
|
+
return conn.execute("UPDATE messages SET status='closed',closed_at=? WHERE thread_id=? AND status!='closed'", (now(), thread_id)).rowcount
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def reopen(conn, thread_id: int) -> int:
|
|
211
|
+
"""A reopened question that already has a reply goes back to answered, everything else to open."""
|
|
212
|
+
return conn.execute("""UPDATE messages SET closed_at=NULL, status=CASE WHEN kind='question' AND EXISTS(SELECT 1 FROM messages c WHERE c.reply_to=messages.id)
|
|
213
|
+
THEN 'answered' ELSE 'open' END WHERE thread_id=? AND status='closed'""", (thread_id,)).rowcount
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def prune(conn, days: int = RETENTION_DAYS) -> int:
|
|
217
|
+
"""Delete closed threads whose newest message and close time are both older than `days`."""
|
|
218
|
+
window = f"-{int(days)} days"
|
|
219
|
+
deleted = conn.execute("""DELETE FROM messages WHERE thread_id IN (SELECT r.id FROM messages r WHERE r.id=r.thread_id AND r.status='closed'
|
|
220
|
+
AND datetime(COALESCE(r.closed_at,r.created_at)) < datetime('now',?)
|
|
221
|
+
AND NOT EXISTS(SELECT 1 FROM messages m WHERE m.thread_id=r.id AND (m.status!='closed' OR datetime(m.created_at) >= datetime('now',?))))""",
|
|
222
|
+
(window, window)).rowcount
|
|
223
|
+
_prune_subagents(conn)
|
|
224
|
+
return deleted
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _prune_subagents(conn) -> None:
|
|
228
|
+
"""An agent whose spawn thread is gone loses its timeline; its row stays as a tombstone (spawn_msg_id=-1) so the scanner
|
|
229
|
+
never re-posts it, until its log file is gone as well."""
|
|
230
|
+
for (aid,) in conn.execute("""SELECT agent_id FROM subagents s WHERE s.spawn_msg_id > 0
|
|
231
|
+
AND NOT EXISTS(SELECT 1 FROM messages m WHERE m.id=s.spawn_msg_id)""").fetchall():
|
|
232
|
+
conn.execute("DELETE FROM subagent_tools WHERE agent_id=?", (aid,)); conn.execute("DELETE FROM subagent_texts WHERE agent_id=?", (aid,))
|
|
233
|
+
conn.execute("UPDATE subagents SET spawn_msg_id=-1,report_msg_id=COALESCE(report_msg_id,0),task=NULL,last_text=NULL WHERE agent_id=?", (aid,))
|
|
234
|
+
for aid, source in conn.execute("SELECT agent_id,source_file FROM subagents WHERE spawn_msg_id=-1").fetchall():
|
|
235
|
+
if not source or not Path(source).exists():
|
|
236
|
+
conn.execute("DELETE FROM subagent_tools WHERE agent_id=?", (aid,)); conn.execute("DELETE FROM subagents WHERE agent_id=?", (aid,))
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def first_line(row: dict[str, Any], size: int = 140) -> str:
|
|
240
|
+
text = row.get("subject") or next((line for line in (row.get("body") or "").splitlines() if line.strip()), "")
|
|
241
|
+
text = " ".join(text.split())
|
|
242
|
+
return text if len(text) <= size else text[:size - 1] + "…"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def feed(conn, limit: int = 20) -> list[dict[str, Any]]:
|
|
246
|
+
rows = _with_thread(conn, "1=1", order="datetime(m.created_at) DESC, m.id DESC", limit=limit)
|
|
247
|
+
read = {row[0] for row in conn.execute(f"SELECT message_id FROM message_reads WHERE reader='user' AND message_id IN ({','.join('?' * len(rows))})",
|
|
248
|
+
[row["id"] for row in rows])} if rows else set()
|
|
249
|
+
keys = chat_keys(conn, _roots(conn, [row["thread_id"] for row in rows]))
|
|
250
|
+
out = []
|
|
251
|
+
for row in rows:
|
|
252
|
+
who, text = _preview(conn, row)
|
|
253
|
+
agent = conn.execute("SELECT agent_type,agent_kind,model FROM subagents WHERE agent_id=?", (row["agent_id"],)).fetchone() if row["agent_id"] else None
|
|
254
|
+
headline, icon = headline_for(row, agent_name(dict(agent)) if agent else None)
|
|
255
|
+
out.append({"id": row["id"], "thread_id": row["thread_id"], "sender": row["sender"], "sender_kind": row["sender_kind"],
|
|
256
|
+
"declared": bool(row["sender_declared"]), "recipient": row["recipient"], "kind": row["kind"], "line": first_line(row),
|
|
257
|
+
"thread_status": row["thread_status"], "project": row["project"], "agent_id": row["agent_id"], "created_at": row["created_at"],
|
|
258
|
+
"unread": row["id"] not in read and row["sender_kind"] != "user" and row["kind"] != "event",
|
|
259
|
+
"chat": keys.get(row["thread_id"]), "who": who, "text": text, "headline": headline, "icon": icon, "mine": row["sender_kind"] == "user",
|
|
260
|
+
"hue": None if who in ("You", "Claude") else hue(who)})
|
|
261
|
+
for agent in _rows(conn, "SELECT * FROM subagents WHERE spawn_msg_id > 0 AND report_msg_id IS NULL"):
|
|
262
|
+
if subagent_status(agent) != "running": continue
|
|
263
|
+
spawn = conn.execute("SELECT thread_id FROM messages WHERE id=?", (agent["spawn_msg_id"],)).fetchone()
|
|
264
|
+
if not spawn: continue
|
|
265
|
+
tool = conn.execute("SELECT name,arg FROM subagent_tools WHERE agent_id=? ORDER BY source_offset DESC LIMIT 1", (agent["agent_id"],)).fetchone()
|
|
266
|
+
steps = conn.execute("SELECT COUNT(*) FROM subagent_tools WHERE agent_id=?", (agent["agent_id"],)).fetchone()[0]
|
|
267
|
+
detail = (tool["name"] + (" " + tool["arg"] if tool["arg"] else "")) if tool else "working"
|
|
268
|
+
if steps: detail += f" ({steps} steps)"
|
|
269
|
+
who = agent_name(agent)
|
|
270
|
+
out.append({"id": "live:" + agent["agent_id"], "thread_id": spawn["thread_id"], "sender": agent["agent_type"] or "subagent", "sender_kind": "subagent",
|
|
271
|
+
"declared": False, "recipient": EVENTS_TO, "kind": "event", "line": detail, "thread_status": "open", "project": agent["project"],
|
|
272
|
+
"agent_id": agent["agent_id"], "created_at": agent["last_activity_at"] or agent["started_at"], "unread": False,
|
|
273
|
+
"chat": keys.get(spawn["thread_id"]), "who": who, "text": detail, "headline": _cut(f"{who}: working — {detail}"), "icon": "▶", "mine": False,
|
|
274
|
+
"hue": hue(who)})
|
|
275
|
+
out.sort(key=lambda item: parse_ts(item["created_at"]) or _EPOCH, reverse=True)
|
|
276
|
+
return out[:limit]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def unread_count(conn) -> int:
|
|
280
|
+
"""Unread messages for the user; Part A events are traffic, not mail, so they never count."""
|
|
281
|
+
return conn.execute(f"SELECT COUNT(*) FROM messages m WHERE m.kind!='event' AND m.sender_kind!='user' AND {_UNREAD_BY_USER}").fetchone()[0]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def badge(conn, since: str | None = None) -> int:
|
|
285
|
+
"""Open agent questions in open threads, counted once per thread."""
|
|
286
|
+
try:
|
|
287
|
+
return conn.execute(f"""SELECT COUNT(DISTINCT m.thread_id) FROM messages m JOIN messages r ON r.id=m.thread_id
|
|
288
|
+
WHERE m.kind='question' AND m.status='open' AND m.sender_kind!='user' AND r.status!='closed'
|
|
289
|
+
AND (? IS NULL OR datetime(m.created_at)>=datetime(?))""", (since, since)).fetchone()[0]
|
|
290
|
+
except sqlite3.OperationalError as exc:
|
|
291
|
+
if "no such table" not in str(exc): log.exception("messages badge failed")
|
|
292
|
+
return 0 # a pre-v9 database (read-only connection) has no message tables
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def snapshot(conn, limit: int = 20, since: str | None = None) -> dict[str, Any]:
|
|
296
|
+
return {"items": feed(conn, limit), "unread": unread_count(conn), "badge": badge(conn, since), "sig": signature(conn)}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def threads(conn, project: str = "", agent: str = "", kind: str = "", status: str = "", question: bool = False, limit: int = 200) -> list[dict[str, Any]]:
|
|
300
|
+
where, params = ["r.id=r.thread_id"], []
|
|
301
|
+
if project: where.append("r.project=?"); params.append(project)
|
|
302
|
+
if status: where.append("r.status=?"); params.append(status)
|
|
303
|
+
if agent: where.append("EXISTS(SELECT 1 FROM messages a WHERE a.thread_id=r.id AND (a.sender=? OR a.recipient=?))"); params += [agent, agent]
|
|
304
|
+
if kind: where.append("EXISTS(SELECT 1 FROM messages k WHERE k.thread_id=r.id AND k.kind=?)"); params.append(kind)
|
|
305
|
+
if question: where.append("EXISTS(SELECT 1 FROM messages q WHERE q.thread_id=r.id AND q.kind='question')")
|
|
306
|
+
rows = _rows(conn, f"""SELECT r.*, COUNT(m.id) messages, MAX(m.created_at) last_at, SUM(m.kind='question') questions,
|
|
307
|
+
SUM(m.kind!='event' AND m.sender_kind!='user' AND {_UNREAD_BY_USER}) unread
|
|
308
|
+
FROM messages r JOIN messages m ON m.thread_id=r.id WHERE {' AND '.join(where)}
|
|
309
|
+
GROUP BY r.id ORDER BY datetime(MAX(m.created_at)) DESC, r.id DESC LIMIT ?""", (*params, limit))
|
|
310
|
+
for row in rows: row["line"] = first_line(row)
|
|
311
|
+
return rows
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def facets(conn) -> dict[str, list[str]]:
|
|
315
|
+
return {"projects": [r[0] for r in conn.execute("SELECT DISTINCT project FROM messages WHERE project IS NOT NULL ORDER BY project")],
|
|
316
|
+
"agents": [r[0] for r in conn.execute("SELECT sender FROM messages UNION SELECT recipient FROM messages WHERE recipient NOT IN ('*') ORDER BY 1")]}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# ---------------------------------------------------------------- subagents (Part A)
|
|
320
|
+
|
|
321
|
+
def subagent_status(row: dict[str, Any]) -> str:
|
|
322
|
+
"""finished (meta or a terminal last record), stopped (silent for STOP_S), stale (silent for STALE_S) or running."""
|
|
323
|
+
if row.get("finished") or row.get("last_terminal"): return "finished"
|
|
324
|
+
last = parse_ts(row.get("last_activity_at") or row.get("started_at"))
|
|
325
|
+
idle = (datetime.now(UTC) - last).total_seconds() if last else float("inf")
|
|
326
|
+
return "stopped" if idle > STOP_S else "stale" if idle > STALE_S else "running"
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def subagent(conn, agent_id: str) -> dict[str, Any] | None:
|
|
330
|
+
row = conn.execute("SELECT * FROM subagents WHERE agent_id=?", (agent_id,)).fetchone()
|
|
331
|
+
if not row: return None
|
|
332
|
+
result = dict(row)
|
|
333
|
+
result["status"] = subagent_status(result)
|
|
334
|
+
result["report"] = result["last_text"] if result["status"] == "finished" else None
|
|
335
|
+
spawn = conn.execute("SELECT thread_id FROM messages WHERE id=?", (result["spawn_msg_id"],)).fetchone() if (result["spawn_msg_id"] or 0) > 0 else None
|
|
336
|
+
result["thread_id"] = spawn[0] if spawn else None # None once the thread was pruned: no link to a missing page
|
|
337
|
+
result["tools"] = _rows(conn, "SELECT ts,name,arg FROM subagent_tools WHERE agent_id=? ORDER BY source_offset", (agent_id,))
|
|
338
|
+
result["texts"] = _rows(conn, "SELECT ts,role,body FROM subagent_texts WHERE agent_id=? ORDER BY source_offset", (agent_id,))
|
|
339
|
+
result["tokens"] = conn.execute("SELECT COALESCE(SUM(input_tokens+output_tokens+cache_read_tokens+cache_write_tokens),0) FROM usage_events WHERE source_file=?",
|
|
340
|
+
(result["source_file"],)).fetchone()[0] if result["source_file"] else 0
|
|
341
|
+
return result
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def session_subagents(conn, session_ids, max_age_s: int = 3600, per_session: int = 6) -> dict[str, list[dict[str, Any]]]:
|
|
345
|
+
"""Recent subagents per parent session, each with its latest tool call, for the Agents panel."""
|
|
346
|
+
ids = [s for s in dict.fromkeys(session_ids) if s]
|
|
347
|
+
if not ids: return {}
|
|
348
|
+
rows = _rows(conn, f"""SELECT s.agent_id,s.session_id,s.handoff_session_id,s.agent_type,s.agent_kind,s.model,s.description,s.started_at,s.last_activity_at,s.finished,s.last_terminal,
|
|
349
|
+
(SELECT name FROM subagent_tools t WHERE t.agent_id=s.agent_id ORDER BY source_offset DESC LIMIT 1) last_tool,
|
|
350
|
+
(SELECT arg FROM subagent_tools t WHERE t.agent_id=s.agent_id ORDER BY source_offset DESC LIMIT 1) last_arg
|
|
351
|
+
FROM subagents s WHERE (s.session_id IN ({','.join('?' * len(ids))}) OR s.handoff_session_id IN ({','.join('?' * len(ids))}))
|
|
352
|
+
AND datetime(COALESCE(s.last_activity_at,s.started_at)) >= datetime('now',?)
|
|
353
|
+
ORDER BY datetime(COALESCE(s.last_activity_at,s.started_at)) DESC""", (*ids, *ids, f"-{max_age_s} seconds"))
|
|
354
|
+
out: dict[str, list[dict[str, Any]]] = {}
|
|
355
|
+
for row in rows:
|
|
356
|
+
row["status"] = subagent_status(row)
|
|
357
|
+
row["agent_type"] = agent_name(row)
|
|
358
|
+
group = out.setdefault(row["handoff_session_id"] or row["session_id"], [])
|
|
359
|
+
if len(group) < per_session: group.append(row)
|
|
360
|
+
return out
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# ---------------------------------------------------------------- chat view
|
|
364
|
+
|
|
365
|
+
ATTACH_BEFORE, ATTACH_AFTER = timedelta(minutes=5), timedelta(minutes=15)
|
|
366
|
+
_EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def hue(name: str) -> int:
|
|
370
|
+
"""Stable color per member name, shared by the chat page and the Brain panel."""
|
|
371
|
+
return zlib.crc32(name.encode()) % 360
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def short_type(agent_type: str | None) -> str:
|
|
375
|
+
return (agent_type or "subagent").rsplit(":", 1)[-1]
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def agent_name(row: dict[str, Any]) -> str:
|
|
379
|
+
if row.get("agent_kind") == "codex":
|
|
380
|
+
model = row.get("model") or ""
|
|
381
|
+
return "Codex" + (f" ({model})" if model else "")
|
|
382
|
+
return friendly_name(row.get("agent_type"))
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _in(column: str, values: list) -> tuple[str, list]:
|
|
386
|
+
return f"{column} IN ({','.join('?' * len(values))})", list(values)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def chat_keys(conn, roots: list[dict[str, Any]]) -> dict[int, str]:
|
|
390
|
+
"""Chat for each thread root: s:<session> for a subagent's thread, or for a message_post thread started in the project of exactly one
|
|
391
|
+
Claude session active at that time; c:#channel for other channel threads; t:<thread id> for the rest."""
|
|
392
|
+
keys: dict[int, str] = {}
|
|
393
|
+
agents = [r["agent_id"] for r in roots if r.get("agent_id")]
|
|
394
|
+
sessions: dict[str, tuple[str | None, str | None, str]] = {}
|
|
395
|
+
for start in range(0, len(agents), 500):
|
|
396
|
+
where, params = _in("agent_id", agents[start:start + 500])
|
|
397
|
+
sessions.update({row[0]: (row[1], row[2], row[3]) for row in conn.execute(
|
|
398
|
+
f"SELECT agent_id,session_id,handoff_session_id,agent_kind FROM subagents WHERE {where}", params)})
|
|
399
|
+
pending: dict[str, list[dict[str, Any]]] = {}
|
|
400
|
+
for r in roots:
|
|
401
|
+
agent = sessions.get(r.get("agent_id"))
|
|
402
|
+
if agent and agent[1]: keys[r["id"]] = "s:" + agent[1]
|
|
403
|
+
elif agent and agent[2] == "codex": keys[r["id"]] = "d:" + r["agent_id"]
|
|
404
|
+
elif agent and agent[0]: keys[r["id"]] = "s:" + agent[0]
|
|
405
|
+
elif r.get("project") and not r.get("agent_id"): pending.setdefault(r["project"], []).append(r)
|
|
406
|
+
for project, rows in pending.items():
|
|
407
|
+
times = [t for t in (parse_ts(r["created_at"]) for r in rows) if t]
|
|
408
|
+
if not times: continue
|
|
409
|
+
since = (min(times) - timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%S")
|
|
410
|
+
windows = [(sid, parse_ts(a), parse_ts(b)) for sid, a, b in conn.execute(
|
|
411
|
+
"""SELECT session_id,MIN(ts),MAX(ts) FROM usage_events WHERE agent='claude' AND project=? AND ts >= ? AND session_id IS NOT NULL
|
|
412
|
+
AND NOT EXISTS(SELECT 1 FROM ingest_offsets io WHERE io.source_file=usage_events.source_file AND io.context LIKE '%devmem_spawned%') GROUP BY session_id""", (project, since))]
|
|
413
|
+
for r in rows:
|
|
414
|
+
if r.get("sender_kind") == "codex": continue
|
|
415
|
+
at = parse_ts(r["created_at"])
|
|
416
|
+
match = [sid for sid, a, b in windows if at and a and b and a - ATTACH_BEFORE <= at <= b + ATTACH_AFTER]
|
|
417
|
+
if len(match) == 1: keys[r["id"]] = "s:" + match[0]
|
|
418
|
+
codex_runs = [(row[0], parse_ts(row[1]), parse_ts(row[2]), row[3] or "", row[4]) for row in conn.execute(
|
|
419
|
+
"SELECT agent_id,started_at,last_activity_at,handoff_id,handoff_session_id FROM subagents WHERE agent_kind='codex' AND project=?", (project,))]
|
|
420
|
+
for r in rows:
|
|
421
|
+
if r.get("sender_kind") != "codex": continue
|
|
422
|
+
at, sender = parse_ts(r["created_at"]), r.get("sender") or ""
|
|
423
|
+
# fix(2026-10-28): two runs within the window left Codex's own posts in separate chats; the handoff template signs posts with the task id
|
|
424
|
+
# (prefix match: ids stored before the full-slug fix were cut at the first hyphen)
|
|
425
|
+
match = [run for run in codex_runs if run[3] and (sender == run[3] or sender.startswith(run[3] + "-"))]
|
|
426
|
+
if len(match) != 1: match = [run for run in codex_runs if at and run[1] and run[2] and run[1] - ATTACH_BEFORE <= at <= run[2] + ATTACH_AFTER]
|
|
427
|
+
if len(match) == 1: keys[r["id"]] = ("s:" + match[0][4]) if match[0][4] else "d:" + match[0][0]
|
|
428
|
+
for r in roots:
|
|
429
|
+
if r["id"] not in keys:
|
|
430
|
+
channel = r.get("recipient") or ""
|
|
431
|
+
keys[r["id"]] = "c:" + channel if channel.startswith("#") and channel != EVENTS_TO else f"t:{r['id']}"
|
|
432
|
+
return keys
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _roots(conn, thread_ids=None) -> list[dict[str, Any]]:
|
|
436
|
+
if thread_ids is None: return _rows(conn, "SELECT id,agent_id,project,recipient,sender_kind,created_at,subject,body FROM messages WHERE id=thread_id")
|
|
437
|
+
ids = list(dict.fromkeys(thread_ids))
|
|
438
|
+
if not ids: return []
|
|
439
|
+
where, params = _in("id", ids)
|
|
440
|
+
return _rows(conn, f"SELECT id,agent_id,project,recipient,sender_kind,created_at,subject,body FROM messages WHERE id=thread_id AND {where}", params)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _session_agents(conn, session_id: str) -> list[dict[str, Any]]:
|
|
444
|
+
rows = _rows(conn, """SELECT * FROM subagents WHERE (session_id=? OR handoff_session_id=?) AND spawn_msg_id > 0
|
|
445
|
+
ORDER BY datetime(COALESCE(started_at,last_activity_at)), agent_id""", (session_id, session_id))
|
|
446
|
+
seen: dict[str, int] = {}
|
|
447
|
+
for row in rows:
|
|
448
|
+
base = agent_name(row); seen[base] = seen.get(base, 0) + 1
|
|
449
|
+
row["name"] = base if seen[base] == 1 else f"{base} {seen[base]}" # two reviewers of one type stay apart
|
|
450
|
+
row["status"] = subagent_status(row)
|
|
451
|
+
return rows
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _later(a, b) -> bool:
|
|
455
|
+
return (parse_ts(a) or _EPOCH) > (parse_ts(b) or _EPOCH)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _member_of(m: dict[str, Any], by_type: dict[str, dict], session: bool) -> str:
|
|
459
|
+
if m["sender_kind"] == "user": return USER
|
|
460
|
+
if session and m["sender_kind"] == "claude" and not m["sender_declared"]: return "parent"
|
|
461
|
+
agent = by_type.get(m["sender"]) or by_type.get(short_type(m["sender"]))
|
|
462
|
+
return "agent:" + agent["agent_id"] if agent and m["sender_kind"] == "subagent" else "x:" + m["sender"]
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def chat(conn, key: str) -> dict[str, Any] | None:
|
|
466
|
+
"""Every bubble of one chat, oldest first: tasks, remarks, tool-call groups, reports, posted messages and user replies."""
|
|
467
|
+
kind, _, ident = key.partition(":")
|
|
468
|
+
if kind not in ("s", "t", "c", "d") or not ident: return None
|
|
469
|
+
roots = _roots(conn, [int(ident)] if kind == "t" and ident.isdigit() else None)
|
|
470
|
+
keys = chat_keys(conn, roots)
|
|
471
|
+
thread_ids = [r["id"] for r in roots if keys.get(r["id"]) == key]
|
|
472
|
+
if kind == "s": agents = _session_agents(conn, ident)
|
|
473
|
+
elif kind == "d":
|
|
474
|
+
agent = conn.execute("SELECT * FROM subagents WHERE agent_id=? AND agent_kind='codex' AND spawn_msg_id > 0", (ident,)).fetchone()
|
|
475
|
+
agents = [dict(agent)] if agent else []
|
|
476
|
+
for a in agents: a["name"], a["status"] = agent_name(a), subagent_status(a)
|
|
477
|
+
else: agents = []
|
|
478
|
+
if not thread_ids and not agents: return None
|
|
479
|
+
where, params = _in("m.thread_id", thread_ids) if thread_ids else ("0", [])
|
|
480
|
+
items_m = _with_thread(conn, where, params)
|
|
481
|
+
by_id = {a["agent_id"]: a for a in agents}
|
|
482
|
+
by_type: dict[str, dict] = {}
|
|
483
|
+
for a in agents: by_type.setdefault(a["agent_type"] or "subagent", a); by_type.setdefault(short_type(a["agent_type"]), a)
|
|
484
|
+
members: dict[str, dict[str, Any]] = {USER: {"name": "You", "kind": "user", "hue": None}}
|
|
485
|
+
if kind == "s": members["parent"] = {"name": "Claude", "kind": "claude", "hue": None}
|
|
486
|
+
for a in agents:
|
|
487
|
+
members["agent:" + a["agent_id"]] = {"name": a["name"], "kind": "subagent", "hue": hue(a["name"]), "agent_id": a["agent_id"], "status": a["status"],
|
|
488
|
+
"description": a["description"], "type": a["agent_type"]}
|
|
489
|
+
items: list[tuple[tuple, dict[str, Any]]] = []
|
|
490
|
+
|
|
491
|
+
def add(phase: int, item: dict[str, Any]) -> None:
|
|
492
|
+
items.append(((parse_ts(item["at"]) or _EPOCH, phase, len(items)), item))
|
|
493
|
+
|
|
494
|
+
reports: dict[str, str] = {}
|
|
495
|
+
for m in items_m:
|
|
496
|
+
agent = by_id.get(m["agent_id"]) if m["kind"] == "event" else None
|
|
497
|
+
headline, icon = headline_for(m, agent_name(agent) if agent else None)
|
|
498
|
+
base = {"key": f"m:{m['id']}", "type": "bubble", "at": m["created_at"], "message_id": m["id"], "thread_id": m["thread_id"], "agent_id": m["agent_id"],
|
|
499
|
+
"headline": headline, "icon": icon}
|
|
500
|
+
if agent and m["id"] == agent["spawn_msg_id"]:
|
|
501
|
+
if agent.get("agent_kind") == "codex" and not agent.get("handoff_session_id"):
|
|
502
|
+
add(0, {**base, "who": "agent:" + agent["agent_id"], "text": m["body"], "label": "task", "agent_id": agent["agent_id"]})
|
|
503
|
+
else:
|
|
504
|
+
add(0, {**base, "who": "parent", "to": "@" + agent["name"], "text": m["body"], "label": "task", "agent_id": agent["agent_id"]})
|
|
505
|
+
elif agent and m["id"] == agent["report_msg_id"]:
|
|
506
|
+
reports[agent["agent_id"]] = m["body"]
|
|
507
|
+
add(2, {**base, "who": "agent:" + agent["agent_id"], "text": m["body"], "label": "report" if agent.get("ended") != "stopped" else "stopped"})
|
|
508
|
+
else:
|
|
509
|
+
who = _member_of(m, by_type, kind == "s")
|
|
510
|
+
if who.startswith("x:") and who not in members:
|
|
511
|
+
members[who] = {"name": m["sender"], "kind": m["sender_kind"], "hue": hue(m["sender"]), "declared": bool(m["sender_declared"])}
|
|
512
|
+
to = m["recipient"] if m["recipient"] not in ("*", EVENTS_TO) else None
|
|
513
|
+
add(1, {**base, "who": who, "to": to, "text": m["body"], "subject": m["subject"] if m["id"] == m["thread_id"] else None,
|
|
514
|
+
"label": m["kind"] if m["kind"] in ("question", "finding") else None, "status": m["status"] if m["kind"] == "question" else None})
|
|
515
|
+
for a in agents:
|
|
516
|
+
aid, member = a["agent_id"], "agent:" + a["agent_id"]
|
|
517
|
+
timeline = [(r["source_offset"], 0, r) for r in _rows(conn, "SELECT id,ts,role,body,source_offset FROM subagent_texts WHERE agent_id=?", (aid,))]
|
|
518
|
+
timeline += [(r["source_offset"], 1, r) for r in _rows(conn, "SELECT id,ts,name,arg,source_offset FROM subagent_tools WHERE agent_id=?", (aid,))]
|
|
519
|
+
timeline.sort(key=lambda entry: (entry[0], entry[1]))
|
|
520
|
+
report, group = (reports.get(aid) or "").strip(), None
|
|
521
|
+
for _, is_tool, r in timeline:
|
|
522
|
+
if is_tool:
|
|
523
|
+
if group is None:
|
|
524
|
+
group = {"key": f"g:{aid}:{r['id']}", "type": "tools", "who": member, "agent_id": aid, "at": r["ts"], "tools": []}
|
|
525
|
+
add(1, group)
|
|
526
|
+
group["tools"].append({"name": r["name"], "arg": r["arg"]})
|
|
527
|
+
if r["ts"]: group["at"] = r["ts"]
|
|
528
|
+
continue
|
|
529
|
+
group = None
|
|
530
|
+
body = r["body"].rstrip("…")
|
|
531
|
+
if r["role"] == "agent" and report and report.startswith(body): continue # the closing text is already the report bubble
|
|
532
|
+
add(1, {"key": f"x:{r['id']}", "type": "bubble", "at": r["ts"], "agent_id": aid, "text": r["body"],
|
|
533
|
+
**({"who": "parent", "to": "@" + a["name"]} if r["role"] == "parent" else {"who": member})})
|
|
534
|
+
# A tool group sorts by its last call, so a growing group moves down like a typing indicator; re-key after the groups filled in.
|
|
535
|
+
items = [((parse_ts(item["at"]) or _EPOCH, order[1], order[2]), item) for order, item in items]
|
|
536
|
+
items.sort(key=lambda pair: pair[0])
|
|
537
|
+
out = [item for _, item in items]
|
|
538
|
+
for item in out:
|
|
539
|
+
item["mine"] = item["who"] == USER
|
|
540
|
+
if item["type"] == "tools": item["count"] = len(item["tools"])
|
|
541
|
+
headlines = [item for item in out if item.get("message_id")]
|
|
542
|
+
for a in agents:
|
|
543
|
+
if a["status"] != "running": continue
|
|
544
|
+
tool_rows = _rows(conn, "SELECT name,arg FROM subagent_tools WHERE agent_id=? ORDER BY source_offset", (a["agent_id"],))
|
|
545
|
+
last = tool_rows[-1] if tool_rows else None
|
|
546
|
+
tool = (last["name"] + (" " + last["arg"] if last["arg"] else "")) if last else "working"
|
|
547
|
+
if tool_rows: tool += f" ({len(tool_rows)} steps)"
|
|
548
|
+
name = agent_name(a)
|
|
549
|
+
headlines.append({"key": "live:" + a["agent_id"], "type": "live", "at": a["last_activity_at"] or a["started_at"], "who": "agent:" + a["agent_id"],
|
|
550
|
+
"agent_id": a["agent_id"], "icon": "▶", "headline": _cut(f"{name}: working — {tool}"),
|
|
551
|
+
"text": "\n".join(t["name"] + (" " + t["arg"] if t["arg"] else "") for t in tool_rows)})
|
|
552
|
+
headlines.sort(key=lambda item: parse_ts(item["at"]) or _EPOCH)
|
|
553
|
+
title_parts, project = _chat_title_parts(conn, key, roots, thread_ids, agents)
|
|
554
|
+
title = " · ".join(title_parts)
|
|
555
|
+
statuses = {m["thread_status"] for m in items_m if m["id"] == m["thread_id"]}
|
|
556
|
+
return {"key": key, "kind": kind, "title": title, "title_parts": title_parts, "project": project, "members": members, "items": out, "headlines": headlines, "thread_ids": thread_ids,
|
|
557
|
+
"status": "open" if "open" in statuses else (statuses.pop() if len(statuses) == 1 else "mixed" if statuses else None),
|
|
558
|
+
"target": _target_from(out, items_m, by_id, members)}
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _chat_title_parts(conn, key: str, roots, thread_ids, agents) -> tuple[list[str], str | None]:
|
|
562
|
+
"""The chat title as separate parts, so the panel can lay them out without re-splitting text."""
|
|
563
|
+
kind, _, ident = key.partition(":")
|
|
564
|
+
mine = [r for r in roots if r["id"] in thread_ids]
|
|
565
|
+
project = next((r["project"] for r in mine if r["project"]), None) or next((a["project"] for a in agents if a["project"]), None)
|
|
566
|
+
if kind == "s":
|
|
567
|
+
if not project:
|
|
568
|
+
row = conn.execute("SELECT project FROM usage_events WHERE session_id=? AND project IS NOT NULL LIMIT 1", (ident,)).fetchone()
|
|
569
|
+
project = row[0] if row else None
|
|
570
|
+
return [Path(project).name if project else "Session", ident[:8]], project
|
|
571
|
+
if kind == "d":
|
|
572
|
+
agent = next(iter(agents), None) or conn.execute("SELECT * FROM subagents WHERE agent_id=?", (ident,)).fetchone()
|
|
573
|
+
agent = dict(agent) if agent else {}
|
|
574
|
+
project = agent.get("project") or project
|
|
575
|
+
return ["Codex", Path(project).name if project else "Project", first_line({"body": agent.get("task") or ""}, 80)], project
|
|
576
|
+
if kind == "c": return [ident], project
|
|
577
|
+
root = mine[0] if mine else {}
|
|
578
|
+
return [first_line(root, 80) if root else f"Thread {ident}"], project
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _target_from(items, items_m, by_id, members) -> dict[str, Any] | None:
|
|
582
|
+
"""Who a user message goes to: the newest open question from an agent, else the last agent that spoke."""
|
|
583
|
+
question = next((m for m in reversed(items_m) if m["kind"] == "question" and m["status"] == "open" and m["sender_kind"] != "user"), None)
|
|
584
|
+
if question: return {"reply_to": question["id"], "to": question["sender"], "name": question["sender"], "why": "open question"}
|
|
585
|
+
for item in reversed(items):
|
|
586
|
+
if item["type"] != "bubble" or item["who"] == USER: continue
|
|
587
|
+
agent = by_id.get(item.get("agent_id"))
|
|
588
|
+
if item["who"].startswith("agent:") and agent:
|
|
589
|
+
reply = item.get("message_id") or agent["report_msg_id"] or agent["spawn_msg_id"]
|
|
590
|
+
if reply and reply > 0: return {"reply_to": reply, "to": agent["agent_type"] or "subagent", "name": agent["name"], "why": "last to speak"}
|
|
591
|
+
elif item.get("message_id"):
|
|
592
|
+
m = next(x for x in items_m if x["id"] == item["message_id"])
|
|
593
|
+
if m["sender_kind"] == "system": continue
|
|
594
|
+
if item["who"] == "parent" and agent: continue # a task bubble: the parent has no inbox name here; keep looking
|
|
595
|
+
return {"reply_to": m["id"], "to": m["sender"], "name": members.get(item["who"], {}).get("name", m["sender"]), "why": "last to speak"}
|
|
596
|
+
agent_messages = [m for m in items_m if m["sender_kind"] != "system"]
|
|
597
|
+
if agent_messages:
|
|
598
|
+
last = agent_messages[-1]
|
|
599
|
+
return {"reply_to": last["id"], "to": last["sender"] if last["sender_kind"] != "user" else "*", "name": last["sender"], "why": "newest message"}
|
|
600
|
+
return None
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def chats(conn, project: str = "", agent: str = "", kind: str = "", status: str = "", question: bool = False, limit: int = 200) -> list[dict[str, Any]]:
|
|
604
|
+
"""The chat list: one row per chat, newest first, with a preview of its last bubble and its unread count."""
|
|
605
|
+
rows = threads(conn, project, agent, kind, status, question, limit=5000)
|
|
606
|
+
keys = chat_keys(conn, rows)
|
|
607
|
+
grouped: dict[str, dict[str, Any]] = {}
|
|
608
|
+
for t in rows:
|
|
609
|
+
c = grouped.setdefault(keys[t["id"]], {"key": keys[t["id"]], "kind": keys[t["id"]][0], "threads": [], "last_at": t["last_at"], "unread": 0,
|
|
610
|
+
"questions": 0, "open": False})
|
|
611
|
+
c["threads"].append(t["id"]); c["unread"] += t["unread"] or 0; c["questions"] += t["questions"] or 0; c["open"] |= t["status"] == "open"
|
|
612
|
+
if _later(t["last_at"], c["last_at"]): c["last_at"] = t["last_at"]
|
|
613
|
+
out = []
|
|
614
|
+
for c in list(grouped.values()):
|
|
615
|
+
where, params = _in("thread_id", c["threads"])
|
|
616
|
+
last = conn.execute(f"SELECT * FROM messages WHERE {where} ORDER BY datetime(created_at) DESC, id DESC LIMIT 1", params).fetchone()
|
|
617
|
+
c["who"], c["preview"] = _preview(conn, dict(last)) if last else ("", "")
|
|
618
|
+
agent = conn.execute("SELECT agent_type,agent_kind,model FROM subagents WHERE agent_id=?", (last["agent_id"],)).fetchone() if last and last["agent_id"] else None
|
|
619
|
+
c["headline"] = headline_for(dict(last), agent_name(dict(agent)) if agent else None)[0] if last else ""
|
|
620
|
+
ident = c["key"][2:]
|
|
621
|
+
agent_rows = []
|
|
622
|
+
if c["kind"] == "s":
|
|
623
|
+
row = conn.execute("""SELECT t.ts,t.body,t.role,s.agent_type FROM subagent_texts t JOIN subagents s ON s.agent_id=t.agent_id
|
|
624
|
+
WHERE s.session_id=? ORDER BY datetime(t.ts) DESC, t.id DESC LIMIT 1""", (ident,)).fetchone()
|
|
625
|
+
if row and _later(row["ts"], c["last_at"]):
|
|
626
|
+
c["last_at"], c["who"], c["preview"] = row["ts"], ("Claude" if row["role"] == "parent" else short_type(row["agent_type"])), first_line({"body": row["body"]}, 90)
|
|
627
|
+
active = conn.execute("SELECT MAX(last_activity_at) FROM subagents WHERE session_id=?", (ident,)).fetchone()[0]
|
|
628
|
+
if active and _later(active, c["last_at"]): c["last_at"] = active
|
|
629
|
+
c["running"] = conn.execute("SELECT COUNT(*) FROM subagents WHERE session_id=? AND report_msg_id IS NULL AND spawn_msg_id > 0", (ident,)).fetchone()[0]
|
|
630
|
+
agent_rows = _rows(conn, "SELECT * FROM subagents WHERE session_id=? AND spawn_msg_id > 0", (ident,))
|
|
631
|
+
elif c["threads"]:
|
|
632
|
+
where_agents, params_agents = _in("m.thread_id", c["threads"])
|
|
633
|
+
agent_rows = _rows(conn, f"SELECT DISTINCT s.* FROM subagents s JOIN messages m ON m.agent_id=s.agent_id WHERE {where_agents}", params_agents)
|
|
634
|
+
unanswered = conn.execute(f"SELECT COUNT(*) FROM messages m WHERE {where} AND m.kind='question' AND m.status='open' AND m.recipient='user'", params).fetchone()[0]
|
|
635
|
+
if agent_rows:
|
|
636
|
+
states = [subagent_status(row) for row in agent_rows]
|
|
637
|
+
done, working = sum(state == "finished" for state in states), sum(state == "running" for state in states)
|
|
638
|
+
c["running"] = working
|
|
639
|
+
parts = [f"{len(states)} agent" + ("s" if len(states) != 1 else "")]
|
|
640
|
+
if done: parts.append(f"{done} done")
|
|
641
|
+
if working: parts.append(f"{working} working")
|
|
642
|
+
if unanswered: parts.append(f"{unanswered} question for you")
|
|
643
|
+
c["state_parts"] = parts
|
|
644
|
+
else: c["state_parts"] = ["1 thread"] + ([f"{unanswered} question for you"] if unanswered else [])
|
|
645
|
+
c["state"] = " · ".join(c["state_parts"])
|
|
646
|
+
c["title_parts"], c["project"] = _chat_title_parts(conn, c["key"], _roots(conn, c["threads"]), c["threads"], [])
|
|
647
|
+
c["title"] = " · ".join(c["title_parts"])
|
|
648
|
+
c["hue"] = hue(c["title"])
|
|
649
|
+
out.append(c)
|
|
650
|
+
out.sort(key=lambda c: parse_ts(c["last_at"]) or _EPOCH, reverse=True)
|
|
651
|
+
return out[:limit]
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _preview(conn, m: dict[str, Any]) -> tuple[str, str]:
|
|
655
|
+
if m["kind"] == "event" and m["agent_id"]:
|
|
656
|
+
agent = conn.execute("SELECT agent_type,spawn_msg_id FROM subagents WHERE agent_id=?", (m["agent_id"],)).fetchone()
|
|
657
|
+
if agent and agent["spawn_msg_id"] == m["id"]: return "Claude", "@" + short_type(agent["agent_type"]) + " " + first_line({"body": m["body"]}, 90)
|
|
658
|
+
return short_type(m["sender"]), first_line({"body": m["body"]}, 90)
|
|
659
|
+
return ("You" if m["sender_kind"] == "user" else m["sender"]), first_line({"body": m["body"]}, 90)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def chat_post(conn, key: str, body: str) -> int:
|
|
663
|
+
"""Post the user's message into a chat: a reply to the chat's target, so an agent waiting in message_wait gets it."""
|
|
664
|
+
current = chat(conn, key)
|
|
665
|
+
if not current: raise LookupError("chat not found")
|
|
666
|
+
target = current["target"]
|
|
667
|
+
if not target: raise LookupError("this chat has no message to reply to")
|
|
668
|
+
message_id = post(conn, body, sender=USER, sender_kind="user", to=target["to"] if target["to"] != USER else "*", reply_to=target["reply_to"])
|
|
669
|
+
mark_read(conn, [message_id], USER)
|
|
670
|
+
from .dispatch import enqueue
|
|
671
|
+
enqueue(conn, message_id, key)
|
|
672
|
+
return message_id
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def mark_chat_read(conn, current: dict[str, Any]) -> None:
|
|
676
|
+
ids = [item["message_id"] for item in current["items"] if item.get("message_id")]
|
|
677
|
+
mark_read(conn, ids, USER)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def signature(conn) -> list[int]:
|
|
681
|
+
"""Changes whenever a bubble could appear or change: new or re-statused messages, new tool calls or remarks, agents ending."""
|
|
682
|
+
return list(conn.execute("""SELECT (SELECT COALESCE(MAX(id),0) FROM messages), (SELECT COUNT(*) FROM messages WHERE status!='open'),
|
|
683
|
+
(SELECT COALESCE(MAX(id),0) FROM subagent_tools), (SELECT COALESCE(MAX(id),0) FROM subagent_texts),
|
|
684
|
+
(SELECT COUNT(*) FROM subagents WHERE report_msg_id IS NOT NULL)""").fetchone())
|