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
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Claude Code subagent logs: <session>/subagents/agent-<id>.jsonl plus its agent-<id>.meta.json sidecar.
|
|
2
|
+
|
|
3
|
+
Kept per agent: task (first user message), one line per tool call, each assistant remark (first 500 chars) and later parent
|
|
4
|
+
message, the last assistant text (the report once finished) and whether the newest record ends the run.
|
|
5
|
+
Tool results and thinking blocks are never read; every stored text goes through the maintenance secret regex.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from ..db import now, parse_ts, project_for_cwd
|
|
17
|
+
from ..secrets import SECRET
|
|
18
|
+
from ..messages import BODY_MAX, EVENTS_TO, NAME_MAX, STOP_S, SUBJECT_MAX, friendly_name, report_outcome
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("devmem.ingest")
|
|
21
|
+
_BAD_META: set[tuple[str, float]] = set()
|
|
22
|
+
|
|
23
|
+
TASK_MAX, COMMAND_MAX, ARG_MAX = 2000, 80, 120
|
|
24
|
+
ARG_KEYS = ("file_path", "notebook_path", "command", "pattern", "path", "url", "query", "skill")
|
|
25
|
+
HANDBACK = "SubagentHandback" # Agent SDK subagents deliver their report as this tool call's message
|
|
26
|
+
REPORT_WAIT_S = 60 # meta says finished but no end-of-run record yet: wait this long for the last line before posting the report
|
|
27
|
+
REMARK_MAX = 500
|
|
28
|
+
_HANDOFF = re.compile(r"\b\d{8}-\d{6}-[a-z0-9]+(?:-[a-z0-9]+)*\b")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def agent_id(path: Path) -> str | None:
|
|
32
|
+
name = path.name
|
|
33
|
+
if path.parent.name == "subagents" and name.startswith("agent-") and name.endswith(".jsonl"):
|
|
34
|
+
return name[len("agent-"):-len(".jsonl")]
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_AUTH = re.compile(r"\b(authorization|proxy-authorization)(\s*[:=]\s*)[^'\"\n]+", re.I)
|
|
39
|
+
_KEYED = re.compile(r"\b(?:(Bearer|Basic)(\s+)()|(token|x-api-key|api[_-]?key|access[_-]?key|secret|passw(?:or)?d|pwd|cookie)(\s*[:=]\s*)(['\"]?))[^\s'\"&;]+", re.I)
|
|
40
|
+
_SHORT_KEY = re.compile(r"\b(?:sk|pk|rk|ghp|gho|xox[abposr])[-_][\w-]{4,}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def clean(text: str, size: int) -> str:
|
|
44
|
+
# fix(2026-10-28): commands are stored now, and SECRET only knows long real-looking keys ("Bearer sk-SECRET" slipped through).
|
|
45
|
+
text = SECRET.sub("[redacted]", _SHORT_KEY.sub("[redacted]", _KEYED.sub(lambda m: "".join(g or "" for g in m.groups()) + "[redacted]", _AUTH.sub(r"\1\2[redacted]", text)))) # before truncating, so a cut can never leave half a secret
|
|
46
|
+
return text if len(text) <= size else text[:size - 1] + "…"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def short_arg(tool_input: Any) -> str:
|
|
50
|
+
if not isinstance(tool_input, dict): return ""
|
|
51
|
+
for key in ARG_KEYS:
|
|
52
|
+
value = tool_input.get(key)
|
|
53
|
+
if isinstance(value, str) and value.strip():
|
|
54
|
+
return clean(" ".join(value.split()), COMMAND_MAX if key == "command" else ARG_MAX)
|
|
55
|
+
return ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _text(content) -> str | None:
|
|
59
|
+
if isinstance(content, str): return content
|
|
60
|
+
if isinstance(content, list):
|
|
61
|
+
parts = [item.get("text") for item in content if isinstance(item, dict) and item.get("type") == "text" and isinstance(item.get("text"), str)]
|
|
62
|
+
return "\n".join(parts) if parts else None
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _str(value) -> str | None: return value if isinstance(value, str) and value else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse(line: str) -> dict[str, Any] | None:
|
|
70
|
+
try:
|
|
71
|
+
obj = json.loads(line)
|
|
72
|
+
except json.JSONDecodeError:
|
|
73
|
+
return None
|
|
74
|
+
if not isinstance(obj, dict) or obj.get("type") not in ("user", "assistant"): return None
|
|
75
|
+
message = obj.get("message") if isinstance(obj.get("message"), dict) else {}
|
|
76
|
+
content = message.get("content")
|
|
77
|
+
# fix(2026-10-28): keep only string fields; a dict sessionId or cwd used to abort the whole ingest pass on every retry.
|
|
78
|
+
rec = {"ts": _str(obj.get("timestamp")), "session_id": _str(obj.get("sessionId")), "cwd": _str(obj.get("cwd")), "task": None, "tools": [],
|
|
79
|
+
"text": None, "texts": [], "terminal": False}
|
|
80
|
+
if obj["type"] == "user":
|
|
81
|
+
rec["task"] = _text(content) # tool_result lists carry no text items, so they are skipped
|
|
82
|
+
rec["terminal"] = obj.get("toolEndsTurn") is True # the result of SubagentHandback ends the run
|
|
83
|
+
return rec
|
|
84
|
+
handback = False
|
|
85
|
+
for item in content if isinstance(content, list) else []:
|
|
86
|
+
if not isinstance(item, dict): continue
|
|
87
|
+
if item.get("type") == "tool_use":
|
|
88
|
+
name = str(item.get("name") or "tool")[:80]
|
|
89
|
+
rec["tools"].append((name, short_arg(item.get("input"))))
|
|
90
|
+
message_text = (item.get("input") or {}).get("message") if name == HANDBACK and isinstance(item.get("input"), dict) else None
|
|
91
|
+
if isinstance(message_text, str) and message_text.strip(): rec["text"] = message_text
|
|
92
|
+
handback = handback or name == HANDBACK
|
|
93
|
+
elif item.get("type") == "text" and isinstance(item.get("text"), str) and item["text"].strip():
|
|
94
|
+
rec["text"] = item["text"]
|
|
95
|
+
rec["texts"].append(clean(item["text"].strip(), REMARK_MAX))
|
|
96
|
+
rec["terminal"] = handback or (message.get("stop_reason") in ("end_turn", "stop_sequence") and bool(rec["texts"]) and not rec["tools"])
|
|
97
|
+
return rec
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def ensure(conn, aid: str, source_file: str, session_id: str | None = None) -> None:
|
|
101
|
+
conn.execute("INSERT OR IGNORE INTO subagents(agent_id,source_file,session_id) VALUES(?,?,?)", (aid, source_file, session_id))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def apply_codex(conn, rec: dict[str, Any], source_file: str, offset: int) -> None:
|
|
105
|
+
aid = rec.get("session_id")
|
|
106
|
+
if not isinstance(aid, str) or not aid: return
|
|
107
|
+
ts = rec.get("ts") if parse_ts(rec.get("ts")) else None
|
|
108
|
+
task = rec.get("task")
|
|
109
|
+
ensure(conn, aid, source_file, aid)
|
|
110
|
+
handoff = _HANDOFF.search(task or "")
|
|
111
|
+
conn.execute("""UPDATE subagents SET agent_kind='codex',agent_type='codex',project=COALESCE(project,?),model=COALESCE(model,?),
|
|
112
|
+
started_at=COALESCE(started_at,?),last_activity_at=CASE WHEN ? IS NOT NULL AND (last_activity_at IS NULL OR datetime(?) > datetime(last_activity_at)) THEN ? ELSE last_activity_at END,
|
|
113
|
+
task=COALESCE(task,?),handoff_id=COALESCE(handoff_id,?),last_text=COALESCE(?,last_text),finished=CASE WHEN ? THEN 0 ELSE MAX(finished,?) END,last_terminal=? WHERE agent_id=?""",
|
|
114
|
+
(project_for_cwd(rec.get("project")) if rec.get("project") else None, rec.get("model"), ts, ts, ts, ts,
|
|
115
|
+
clean(task, TASK_MAX) if task else None, handoff.group(0) if handoff else None,
|
|
116
|
+
clean(rec.get("report_text"), BODY_MAX) if rec.get("report_text") else None, int(bool(task)), int(rec.get("finished", False)), int(rec.get("terminal", False)), aid))
|
|
117
|
+
if task:
|
|
118
|
+
conn.execute("UPDATE subagents SET report_msg_id=NULL,ended=NULL WHERE agent_id=?", (aid,))
|
|
119
|
+
conn.execute("UPDATE messages SET status='open',closed_at=NULL WHERE thread_id=(SELECT thread_id FROM messages WHERE id=(SELECT spawn_msg_id FROM subagents WHERE agent_id=?))", (aid,))
|
|
120
|
+
if rec.get("text"):
|
|
121
|
+
conn.execute("INSERT OR IGNORE INTO subagent_texts(agent_id,ts,role,body,source_offset) VALUES(?,?,?,?,?)",
|
|
122
|
+
(aid, ts, "agent", rec["text"], offset))
|
|
123
|
+
conn.executemany("INSERT OR IGNORE INTO subagent_tools(agent_id,ts,name,arg,source_offset) VALUES(?,?,?,?,?)",
|
|
124
|
+
[(aid, ts, name, arg, offset + index) for index, (name, arg) in enumerate(rec.get("tools", []))])
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def link_codex_handoffs(conn, claude_root: Path) -> None:
|
|
128
|
+
"""Link only task ids visibly created or passed to Bash in exactly one newest Claude session."""
|
|
129
|
+
pending = conn.execute("SELECT agent_id,handoff_id FROM subagents WHERE agent_kind='codex' AND handoff_id IS NOT NULL AND handoff_session_id IS NULL").fetchall()
|
|
130
|
+
for row in pending:
|
|
131
|
+
matches: list[tuple[datetime, str]] = []
|
|
132
|
+
needle = row["handoff_id"]
|
|
133
|
+
for path in (claude_root / "projects").glob("*/*.jsonl") if (claude_root / "projects").exists() else ():
|
|
134
|
+
try:
|
|
135
|
+
for line in path.open(encoding="utf-8", errors="replace"):
|
|
136
|
+
if needle not in line: continue
|
|
137
|
+
obj = json.loads(line)
|
|
138
|
+
message = obj.get("message") if isinstance(obj, dict) and isinstance(obj.get("message"), dict) else {}
|
|
139
|
+
content = message.get("content")
|
|
140
|
+
text = content if isinstance(content, str) else json.dumps(content) if isinstance(content, list) else ""
|
|
141
|
+
bash = '"name": "Bash"' in line or '"name":"Bash"' in line
|
|
142
|
+
if ("Created task:" in text or bash) and needle in text:
|
|
143
|
+
sid, at = obj.get("sessionId"), parse_ts(obj.get("timestamp"))
|
|
144
|
+
if isinstance(sid, str) and at: matches.append((at, sid))
|
|
145
|
+
except (OSError, ValueError): continue
|
|
146
|
+
if matches:
|
|
147
|
+
conn.execute("UPDATE subagents SET handoff_session_id=? WHERE agent_id=?", (max(matches)[1], row["agent_id"]))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def apply(conn, aid: str, rec: dict[str, Any], offset: int) -> None:
|
|
151
|
+
ts = rec["ts"] if parse_ts(rec["ts"]) else None
|
|
152
|
+
texts = [("agent", text) for text in rec["texts"]]
|
|
153
|
+
if rec["task"] and not rec["task"].lstrip().startswith("<"): # later parent messages (SendMessage); reminders and wrappers start with "<"
|
|
154
|
+
row = conn.execute("SELECT task FROM subagents WHERE agent_id=?", (aid,)).fetchone()
|
|
155
|
+
# Not the task itself: a v10 re-read starts at byte 0 with the task already stored.
|
|
156
|
+
if row and row[0] is not None and row[0] != clean(rec["task"], TASK_MAX): texts.append(("parent", clean(rec["task"].strip(), REMARK_MAX)))
|
|
157
|
+
conn.execute("""UPDATE subagents SET session_id=COALESCE(session_id,?), project=COALESCE(project,?), started_at=COALESCE(started_at,?),
|
|
158
|
+
last_activity_at=CASE WHEN ? IS NOT NULL AND (last_activity_at IS NULL OR datetime(?) > datetime(last_activity_at)) THEN ? ELSE last_activity_at END,
|
|
159
|
+
task=COALESCE(task,?), last_text=COALESCE(?,last_text), last_terminal=? WHERE agent_id=?""",
|
|
160
|
+
(rec["session_id"], project_for_cwd(rec["cwd"]) if rec["cwd"] else None, ts, ts, ts, ts,
|
|
161
|
+
clean(rec["task"], TASK_MAX) if rec["task"] else None, clean(rec["text"], BODY_MAX) if rec["text"] else None, int(rec["terminal"]), aid))
|
|
162
|
+
conn.executemany("INSERT OR IGNORE INTO subagent_texts(agent_id,ts,role,body,source_offset) VALUES(?,?,?,?,?)",
|
|
163
|
+
[(aid, ts, role, body, offset + index) for index, (role, body) in enumerate(texts)])
|
|
164
|
+
# A line is far longer than its number of items, so offset+index never collides with the next line's offset.
|
|
165
|
+
conn.executemany("INSERT OR IGNORE INTO subagent_tools(agent_id,ts,name,arg,source_offset) VALUES(?,?,?,?,?)",
|
|
166
|
+
[(aid, ts, name, arg, offset + index) for index, (name, arg) in enumerate(rec["tools"])])
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def scan_meta(conn, claude_root: Path) -> int:
|
|
170
|
+
"""Re-read only sidecars whose mtime changed; returns how many were read."""
|
|
171
|
+
root = claude_root / "projects"
|
|
172
|
+
if not root.exists(): return 0
|
|
173
|
+
known = {row[0]: row[1] for row in conn.execute("SELECT agent_id,meta_mtime FROM subagents")}
|
|
174
|
+
read = 0
|
|
175
|
+
for meta in root.glob("*/*/subagents/agent-*.meta.json"):
|
|
176
|
+
aid = meta.name[len("agent-"):-len(".meta.json")]
|
|
177
|
+
try:
|
|
178
|
+
mtime = meta.stat().st_mtime
|
|
179
|
+
if known.get(aid) == mtime: continue
|
|
180
|
+
data = json.loads(meta.read_text(encoding="utf-8"))
|
|
181
|
+
if not isinstance(data, dict): raise ValueError("not a JSON object")
|
|
182
|
+
except FileNotFoundError:
|
|
183
|
+
continue
|
|
184
|
+
except (OSError, ValueError) as exc:
|
|
185
|
+
if (aid, mtime) not in _BAD_META: # once per agent and file version; a fixed or rewritten sidecar is read again
|
|
186
|
+
_BAD_META.add((aid, mtime)); log.warning("unreadable subagent sidecar %s: %s", meta, exc)
|
|
187
|
+
continue
|
|
188
|
+
ensure(conn, aid, str(meta.with_name(f"agent-{aid}.jsonl")), meta.parent.parent.name)
|
|
189
|
+
description = data.get("description") if isinstance(data.get("description"), str) else None
|
|
190
|
+
conn.execute("UPDATE subagents SET agent_type=?,description=?,tool_use_id=?,spawn_depth=?,finished=?,meta_mtime=? WHERE agent_id=?",
|
|
191
|
+
(str(data.get("agentType") or "")[:NAME_MAX] or None, clean(description, SUBJECT_MAX) if description else None,
|
|
192
|
+
data.get("toolUseId") if isinstance(data.get("toolUseId"), str) else None,
|
|
193
|
+
data.get("spawnDepth") if isinstance(data.get("spawnDepth"), int) else None, int(data.get("finished") is True), mtime, aid))
|
|
194
|
+
read += 1
|
|
195
|
+
return read
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _label(row) -> str:
|
|
200
|
+
return row["description"] or row["agent_type"] or "subagent"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _end(row, at: datetime) -> tuple[str, str] | None:
|
|
204
|
+
"""(ended, report body) once an agent is done, else None. Done = meta finished, a terminal last record, or no write for STOP_S."""
|
|
205
|
+
last = parse_ts(row["last_activity_at"] or row["started_at"])
|
|
206
|
+
idle = (at - last).total_seconds() if last else float("inf")
|
|
207
|
+
if row["finished"] or row["last_terminal"]:
|
|
208
|
+
if row["tail_partial"] and idle < STOP_S: return None # the final line is still being written; an earlier text is not the report
|
|
209
|
+
if row["tail_partial"]:
|
|
210
|
+
return "stopped", clean("(no report: agent stopped; its last text follows)\n\n" + row["last_text"], BODY_MAX) if row["last_text"] else "(no report: agent stopped)"
|
|
211
|
+
if not row["last_terminal"] and idle < REPORT_WAIT_S: return None # meta can land before the last record
|
|
212
|
+
return "finished", row["last_text"] or "(finished without a text report)"
|
|
213
|
+
if idle < STOP_S: return None
|
|
214
|
+
return "stopped", clean("(no report: agent stopped; its last text follows)\n\n" + row["last_text"], BODY_MAX) if row["last_text"] else "(no report: agent stopped)"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def emit_events(conn) -> int:
|
|
218
|
+
"""Turn each spawn and each end (report, or stopped) into a feed event (kind=event) in one thread per agent; the end closes the thread."""
|
|
219
|
+
count = 0
|
|
220
|
+
for row in conn.execute("SELECT * FROM subagents WHERE spawn_msg_id IS NULL AND (task IS NOT NULL OR agent_type IS NOT NULL)").fetchall():
|
|
221
|
+
is_codex = row["agent_kind"] == "codex"
|
|
222
|
+
title = row["handoff_id"][16:] if is_codex and row["handoff_id"] else None # "20261028-135756-codex-chat-demo" -> "codex-chat-demo"
|
|
223
|
+
subject = ("dispatched Codex — " if row["handoff_session_id"] else "started: ") + (title or row["task"] or _label(row)) if is_codex else \
|
|
224
|
+
"dispatched " + friendly_name(row["agent_type"]) + " — " + _label(row)
|
|
225
|
+
cur = conn.execute("""INSERT INTO messages(project,sender,sender_kind,recipient,kind,subject,body,status,created_at,agent_id)
|
|
226
|
+
VALUES(?,?,'subagent',?,'event',?,?,'open',?,?)""",
|
|
227
|
+
(row["project"], row["agent_type"] or "subagent", EVENTS_TO,
|
|
228
|
+
clean(subject, SUBJECT_MAX),
|
|
229
|
+
row["task"] or row["description"] or "(no task recorded)", row["started_at"] or now(), row["agent_id"]))
|
|
230
|
+
conn.execute("UPDATE messages SET thread_id=id WHERE id=?", (cur.lastrowid,))
|
|
231
|
+
conn.execute("UPDATE subagents SET spawn_msg_id=? WHERE agent_id=?", (cur.lastrowid, row["agent_id"]))
|
|
232
|
+
count += 1
|
|
233
|
+
at = datetime.now(UTC)
|
|
234
|
+
# fix(2026-10-28): most sidecars never say finished, so runs also end on their last record or after STOP_S of silence; this also repairs agents scanned before.
|
|
235
|
+
for row in conn.execute("SELECT * FROM subagents WHERE report_msg_id IS NULL AND spawn_msg_id > 0").fetchall():
|
|
236
|
+
end = _end(row, at)
|
|
237
|
+
if not end: continue
|
|
238
|
+
spawn = conn.execute("SELECT thread_id FROM messages WHERE id=?", (row["spawn_msg_id"],)).fetchone()
|
|
239
|
+
if not spawn: # the thread was pruned; nothing to attach a report to
|
|
240
|
+
conn.execute("UPDATE subagents SET report_msg_id=0,ended=? WHERE agent_id=?", (end[0], row["agent_id"])); continue
|
|
241
|
+
stamp = row["last_activity_at"] or row["started_at"] or now()
|
|
242
|
+
cur = conn.execute("""INSERT INTO messages(thread_id,reply_to,project,sender,sender_kind,recipient,kind,subject,body,status,created_at,agent_id)
|
|
243
|
+
VALUES(?,?,?,?,'subagent',?,'event',?,?,'open',?,?)""",
|
|
244
|
+
(spawn["thread_id"], row["spawn_msg_id"], row["project"], row["agent_type"] or "subagent", EVENTS_TO,
|
|
245
|
+
clean((friendly_name(row["agent_type"]) + ": " + report_outcome(end[1])[0]) if end[0] == "finished" else friendly_name(row["agent_type"]) + ": stopped without a report", SUBJECT_MAX), end[1], stamp, row["agent_id"]))
|
|
246
|
+
conn.execute("UPDATE subagents SET report_msg_id=?,ended=? WHERE agent_id=?", (cur.lastrowid, end[0], row["agent_id"]))
|
|
247
|
+
# An ended agent's thread is done; closed threads age out through message pruning like any other.
|
|
248
|
+
conn.execute("UPDATE messages SET status='closed',closed_at=? WHERE thread_id=? AND status!='closed'", (stamp, spawn["thread_id"]))
|
|
249
|
+
count += 1
|
|
250
|
+
return count
|