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.
Files changed (54) hide show
  1. agent_memory_brain-0.2.0.dist-info/METADATA +98 -0
  2. agent_memory_brain-0.2.0.dist-info/RECORD +54 -0
  3. agent_memory_brain-0.2.0.dist-info/WHEEL +4 -0
  4. agent_memory_brain-0.2.0.dist-info/entry_points.txt +3 -0
  5. agent_memory_brain-0.2.0.dist-info/licenses/LICENSE +67 -0
  6. devmem/__init__.py +3 -0
  7. devmem/ado.py +433 -0
  8. devmem/agent_colors.py +74 -0
  9. devmem/cli.py +500 -0
  10. devmem/db.py +1069 -0
  11. devmem/dispatch.py +478 -0
  12. devmem/embed.py +111 -0
  13. devmem/filelock.py +53 -0
  14. devmem/git_sync.py +261 -0
  15. devmem/guard.py +214 -0
  16. devmem/hook_log.py +49 -0
  17. devmem/ingest/__init__.py +1 -0
  18. devmem/ingest/claude.py +87 -0
  19. devmem/ingest/codex.py +175 -0
  20. devmem/ingest/runner.py +318 -0
  21. devmem/ingest/subagents.py +250 -0
  22. devmem/maintenance.py +801 -0
  23. devmem/mcp_server.py +542 -0
  24. devmem/messages.py +684 -0
  25. devmem/panel/__init__.py +1 -0
  26. devmem/panel/app.py +971 -0
  27. devmem/panel/state.py +358 -0
  28. devmem/panel/static/base.css +27 -0
  29. devmem/panel/static/brain.css +473 -0
  30. devmem/panel/static/brain.js +1118 -0
  31. devmem/panel/static/brain3d.js +600 -0
  32. devmem/panel/static/chat.js +18 -0
  33. devmem/panel/static/common.js +208 -0
  34. devmem/panel/static/site.css +508 -0
  35. devmem/panel/static/tokens.css +19 -0
  36. devmem/panel/templates/ado.html +62 -0
  37. devmem/panel/templates/agents.html +35 -0
  38. devmem/panel/templates/audit.html +19 -0
  39. devmem/panel/templates/base.html +42 -0
  40. devmem/panel/templates/brain.html +101 -0
  41. devmem/panel/templates/maintenance.html +32 -0
  42. devmem/panel/templates/memory.html +71 -0
  43. devmem/panel/templates/message_thread.html +49 -0
  44. devmem/panel/templates/messages.html +8 -0
  45. devmem/panel/templates/overview.html +109 -0
  46. devmem/panel/templates/project.html +48 -0
  47. devmem/panel/templates/projects.html +21 -0
  48. devmem/panel/templates/session.html +49 -0
  49. devmem/panel/templates/sessions.html +67 -0
  50. devmem/panel/templates/subagent.html +27 -0
  51. devmem/procs.py +10 -0
  52. devmem/projects.py +17 -0
  53. devmem/secrets.py +9 -0
  54. 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