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
devmem/ingest/codex.py ADDED
@@ -0,0 +1,175 @@
1
+ """Codex session JSONL parsing."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ from datetime import timedelta
7
+ from pathlib import Path
8
+ from typing import Any, Iterator
9
+
10
+ from ..db import iso, parse_ts
11
+
12
+ TOOL_TYPES = ("function_call", "custom_tool_call", "local_shell_call")
13
+ TURN_TYPES = ("task_started", "task_complete")
14
+ DEFAULT_WINDOWS = {"primary": 300, "secondary": 10080}
15
+ TASK_MAX, REMARK_MAX, COMMAND_MAX = 2000, 500, 80
16
+ HANDOFF = re.compile(r"\b\d{8}-\d{6}-[a-z0-9]+(?:-[a-z0-9]+)*\b")
17
+
18
+
19
+ def _resets_at(limit: dict[str, Any], ts: Any) -> str | None:
20
+ if limit.get("resets_at") is not None:
21
+ return iso(limit.get("resets_at"))
22
+ seconds = limit.get("resets_in_seconds")
23
+ base = parse_ts(ts)
24
+ if isinstance(seconds, (int, float)) and base:
25
+ return (base + timedelta(seconds=seconds)).isoformat()
26
+ return None
27
+
28
+
29
+ def _limits(payload: dict[str, Any], ts: Any) -> list[dict[str, Any]]:
30
+ rate_limits = payload.get("rate_limits")
31
+ if not isinstance(rate_limits, dict):
32
+ return []
33
+ limit_id = rate_limits.get("limit_id")
34
+ prefix = f"{limit_id}:" if isinstance(limit_id, str) and limit_id not in ("", "codex") else ""
35
+ out = []
36
+ for name, default_minutes in DEFAULT_WINDOWS.items():
37
+ limit = rate_limits.get(name)
38
+ if isinstance(limit, dict) and limit.get("used_percent") is not None:
39
+ out.append({"agent": "codex", "label": prefix + name, "ts": ts,
40
+ "window_minutes": limit.get("window_minutes") or default_minutes,
41
+ "used_percent": limit.get("used_percent"), "resets_at": _resets_at(limit, ts)})
42
+ return out
43
+
44
+
45
+ def _activity(obj: dict[str, Any], payload: dict[str, Any], context: dict[str, Any]) -> dict[str, Any] | None:
46
+ kind_type = obj.get("type"), payload.get("type")
47
+ base = {"agent": "codex", "session_id": context.get("session_id"), "project": context.get("project"), "ts": obj.get("timestamp")}
48
+ if kind_type[0] == "response_item" and kind_type[1] in TOOL_TYPES:
49
+ # Arguments/input hold raw commands and code: never stored.
50
+ return {**base, "kind": "tool", "name": str(payload.get("name") or kind_type[1])[:80], "summary": ""}
51
+ if kind_type[0] == "event_msg" and kind_type[1] in TURN_TYPES:
52
+ summary = ""
53
+ if kind_type[1] == "task_complete" and isinstance(payload.get("duration_ms"), (int, float)):
54
+ summary = f"{payload['duration_ms'] / 1000:.0f}s"
55
+ return {**base, "kind": "turn", "name": kind_type[1], "summary": summary}
56
+ return None
57
+
58
+
59
+ def parse_record(line: str, context: dict[str, Any]) -> tuple[dict[str, Any] | None, list[dict[str, Any]], list[dict[str, Any]]]:
60
+ """Return (usage event or None, rate-limit observations, activity events) for one line; updates context."""
61
+ try:
62
+ obj = json.loads(line)
63
+ except json.JSONDecodeError:
64
+ return None, [], []
65
+ if not isinstance(obj, dict):
66
+ return None, [], []
67
+ payload = obj.get("payload") or {}
68
+ if not isinstance(payload, dict):
69
+ payload = {}
70
+ if obj.get("type") in ("session_meta", "session_meta_event"):
71
+ context["session_id"] = payload.get("id") or payload.get("session_id") or context.get("session_id")
72
+ context["project"] = payload.get("cwd") or context.get("project")
73
+ if obj.get("type") == "turn_context":
74
+ context["model"] = payload.get("model") or context.get("model")
75
+ context["project"] = payload.get("cwd") or context.get("project")
76
+ activity = _activity(obj, payload, context)
77
+ activities = [activity] if activity else []
78
+ if obj.get("type") != "event_msg":
79
+ return None, [], activities
80
+ if payload.get("type") == "turn_context":
81
+ context["model"] = payload.get("model") or context.get("model")
82
+ usage = (payload.get("info") or {}).get("last_token_usage") if isinstance(payload.get("info"), dict) else None
83
+ event = None
84
+ if payload.get("type") == "token_count" and isinstance(usage, dict):
85
+ event = {
86
+ "agent": "codex", "session_id": context.get("session_id"), "project": context.get("project"),
87
+ "model": context.get("model", "codex"), "ts": obj.get("timestamp"),
88
+ "input_tokens": int(usage.get("input_tokens", usage.get("input", 0)) or 0),
89
+ "output_tokens": int(usage.get("output_tokens", usage.get("output", 0)) or 0),
90
+ "cache_read_tokens": int(usage.get("cached_input_tokens", usage.get("cached_input", 0)) or 0),
91
+ "cache_write_tokens": 0,
92
+ "reasoning_tokens": int(usage.get("reasoning_output_tokens", usage.get("reasoning_output", 0)) or 0),
93
+ }
94
+ return event, _limits(payload, obj.get("timestamp")), activities
95
+
96
+
97
+ def _text(content: Any) -> str | None:
98
+ if isinstance(content, str): return content
99
+ if isinstance(content, list):
100
+ parts = [item.get("text") for item in content if isinstance(item, dict) and isinstance(item.get("text"), str)]
101
+ return "\n".join(parts) if parts else None
102
+ return None
103
+
104
+
105
+ def _clean(text: str, size: int) -> str:
106
+ from .subagents import clean
107
+ return clean(text.strip(), size)
108
+
109
+
110
+ def _arguments(payload: dict[str, Any]) -> dict[str, Any]:
111
+ value = payload.get("arguments", payload.get("input", payload.get("args", {})))
112
+ if not value and isinstance(payload.get("command"), str): value = {"command": payload["command"]}
113
+ if isinstance(value, str):
114
+ try: value = json.loads(value)
115
+ except ValueError: return {"command": value}
116
+ return value if isinstance(value, dict) else {}
117
+
118
+
119
+ def _tool(payload: dict[str, Any]) -> tuple[str, str]:
120
+ from .subagents import short_arg
121
+ name = str(payload.get("name") or payload.get("call_id") or payload.get("type") or "tool")[:80]
122
+ args = _arguments(payload)
123
+ command = args.get("command") if isinstance(args.get("command"), str) else None
124
+ if command:
125
+ return name, _clean(" ".join(command.split()), COMMAND_MAX)
126
+ if name == "apply_patch" or name.endswith(".apply_patch"):
127
+ patch = args.get("patch") or args.get("input") or ""
128
+ paths = re.findall(r"(?:\+\+\+|---) [ab]/([^\s]+)|\*\*\* Update File: ([^\n]+)|\*\*\* Add File: ([^\n]+)", patch) if isinstance(patch, str) else []
129
+ touched = [next(part for part in match if part) for match in paths]
130
+ return name, _clean(", ".join(dict.fromkeys(touched)), 120)
131
+ if name.startswith("mcp__"):
132
+ bits = name.split("__")
133
+ return name, ".".join(bits[1:3]) if len(bits) >= 3 else name
134
+ return name, short_arg(args)
135
+
136
+
137
+ def _is_task(text: str) -> bool:
138
+ plain = text.lstrip()
139
+ return bool(plain) and not (plain.startswith("<environment_context") or plain.startswith("<skills_instructions") or
140
+ plain.startswith("<permissions") or plain.startswith("<agents_instructions"))
141
+
142
+
143
+ def parse_agent_record(line: str, context: dict[str, Any]) -> dict[str, Any] | None:
144
+ """One safe, display-only Codex rollout record. parse_record owns shared context and token parsing."""
145
+ if context.get("devmem_spawned"): return None
146
+ try: obj = json.loads(line)
147
+ except json.JSONDecodeError: return None
148
+ if not isinstance(obj, dict): return None
149
+ payload = obj.get("payload") if isinstance(obj.get("payload"), dict) else {}
150
+ typ, inner = obj.get("type"), payload.get("type")
151
+ rec = {"session_id": context.get("session_id"), "project": context.get("project"), "model": context.get("model"),
152
+ "ts": obj.get("timestamp"), "task": None, "text": None, "tools": [], "finished": False, "terminal": False}
153
+ if typ == "event_msg" and inner == "task_complete": rec["finished"] = rec["terminal"] = True
154
+ if typ != "response_item": return rec if rec["terminal"] else None
155
+ if inner in TOOL_TYPES: rec["tools"].append(_tool(payload)); return rec
156
+ if inner != "message": return None # reasoning and tool output records are deliberately ignored
157
+ role, text = payload.get("role"), _text(payload.get("content"))
158
+ if role == "user" and isinstance(text, str) and _is_task(text):
159
+ rec["task"] = _clean(text, TASK_MAX); return rec
160
+ if role == "assistant" and isinstance(text, str) and text.strip():
161
+ rec["text"] = _clean(text, REMARK_MAX)
162
+ rec["report_text"] = _clean(text, 8000)
163
+ return rec
164
+ return None
165
+
166
+
167
+ def parse_line(line: str, context: dict[str, Any]) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
168
+ """Return an optional usage event and rate-limit observations for one line."""
169
+ event, limits, _ = parse_record(line, context)
170
+ return event, limits
171
+
172
+
173
+ def files(root: Path) -> Iterator[Path]:
174
+ if root.exists():
175
+ yield from root.glob("sessions/**/*.jsonl")
@@ -0,0 +1,318 @@
1
+ """Incremental, read-only ingestion of local agent logs."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from ..db import connection, iso, parse_ts, project_for_cwd
11
+ from . import claude, codex, subagents
12
+
13
+ CLAUDE_LIMIT_WINDOWS = {"five_hour": 300}
14
+ RESET_TOLERANCE_S = 120 # resets derived from "resets_in_seconds" jitter by a second or two per event
15
+ log = logging.getLogger("devmem.ingest")
16
+ _FAILED: set[tuple[str, int | None, str]] = set()
17
+
18
+
19
+ class _LineFailed(Exception):
20
+ """A line that could not be stored; carries its byte offset for the log."""
21
+ def __init__(self, offset: int): super().__init__(offset); self.offset = offset
22
+
23
+
24
+ def _guarded(conn, path, fn, default, reset=None):
25
+ """Run one file's ingest inside a SAVEPOINT: a bad file is rolled back, logged once, and skipped; the rest of the pass goes on."""
26
+ conn.execute("SAVEPOINT ingest_file")
27
+ try:
28
+ result = fn()
29
+ except Exception as exc:
30
+ conn.execute("ROLLBACK TO ingest_file"); conn.execute("RELEASE ingest_file")
31
+ if reset: reset() # caches that may describe rolled-back rows
32
+ if isinstance(exc, FileNotFoundError): return default # removed between stat and open: gone, nothing to keep
33
+ cause = exc.__cause__ if isinstance(exc, _LineFailed) and exc.__cause__ else exc
34
+ key = (str(path), getattr(exc, "offset", None), type(cause).__name__)
35
+ if key not in _FAILED:
36
+ _FAILED.add(key); log.warning("ingest skipped %s at byte %s: %s: %s", path, key[1], key[2], cause)
37
+ return default
38
+ conn.execute("RELEASE ingest_file")
39
+ return result
40
+
41
+
42
+ def claude_limits_path() -> Path:
43
+ return Path(os.environ.get("DEVMEM_CLAUDE_LIMITS", "~/.devmem/claude_limits.json")).expanduser()
44
+
45
+
46
+ def _read_new(path: Path, offset: int):
47
+ with path.open("rb") as f:
48
+ f.seek(offset)
49
+ while True:
50
+ at = f.tell()
51
+ raw = f.readline()
52
+ if not raw:
53
+ break
54
+ if not raw.endswith(b"\n"):
55
+ yield at, None, at # leave an unfinished final line for next pass
56
+ break
57
+ yield at, raw.decode("utf-8", errors="replace"), f.tell()
58
+
59
+
60
+ def _insert_event(conn, event, source_file: str, offset: int) -> bool:
61
+ event["project"] = project_for_cwd(event.get("project")) # logs carry the raw cwd; group by git root
62
+ values = [event.get(k) for k in ("agent", "session_id", "project", "model", "ts", "input_tokens", "output_tokens", "cache_read_tokens", "cache_write_tokens", "reasoning_tokens")]
63
+ cur = conn.execute("""INSERT OR IGNORE INTO usage_events
64
+ (agent,session_id,project,model,ts,input_tokens,output_tokens,cache_read_tokens,cache_write_tokens,reasoning_tokens,source_file,source_offset)
65
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", (*values, source_file, offset))
66
+ return cur.rowcount > 0
67
+
68
+
69
+ def _insert_activities(conn, events: list[dict[str, Any]], source_file: str, offset: int) -> int:
70
+ count = 0
71
+ # A line is far longer than its number of items, so offset+index never collides with the next line's offset.
72
+ for index, event in enumerate(events):
73
+ cur = conn.execute("""INSERT OR IGNORE INTO activity_events
74
+ (agent,session_id,project,ts,kind,name,summary,source_file,source_offset) VALUES (?,?,?,?,?,?,?,?,?)""",
75
+ (event["agent"], event.get("session_id"), project_for_cwd(event.get("project")), event.get("ts"),
76
+ event["kind"], event.get("name"), event.get("summary") or "", source_file, offset + index))
77
+ count += cur.rowcount > 0
78
+ return count
79
+
80
+
81
+ class LimitStore:
82
+ """Insert a rate_limits row only when a window's values change; otherwise just advance observed_at."""
83
+
84
+ def __init__(self, conn):
85
+ self.conn = conn
86
+ self.last: dict[tuple[str, str], dict[str, Any] | None] = {}
87
+
88
+ def _latest(self, agent: str, label: str) -> dict[str, Any] | None:
89
+ key = (agent, label)
90
+ if key not in self.last:
91
+ row = self.conn.execute("SELECT id,used_percent,resets_at,window_minutes,ts,observed_at FROM rate_limits "
92
+ "WHERE agent=? AND label=? ORDER BY id DESC LIMIT 1", key).fetchone()
93
+ self.last[key] = dict(row) if row else None
94
+ return self.last[key]
95
+
96
+ @staticmethod
97
+ def _same(row: dict[str, Any], obs: dict[str, Any]) -> bool:
98
+ try:
99
+ if float(row["used_percent"]) != float(obs["used_percent"]):
100
+ return False
101
+ except (TypeError, ValueError):
102
+ if row["used_percent"] != obs["used_percent"]:
103
+ return False
104
+ if row["window_minutes"] != obs["window_minutes"]:
105
+ return False
106
+ a, b = parse_ts(row["resets_at"]), parse_ts(obs["resets_at"])
107
+ if a is None or b is None:
108
+ return a is b
109
+ return abs((a - b).total_seconds()) <= RESET_TOLERANCE_S
110
+
111
+ def observe(self, obs: dict[str, Any]) -> bool:
112
+ """Record one observation; return True when a new row was inserted."""
113
+ ts = iso(obs.get("ts")) or obs.get("ts")
114
+ row = self._latest(obs["agent"], obs["label"])
115
+ if row is not None and self._same(row, obs):
116
+ seen = parse_ts(row.get("observed_at")) or parse_ts(row.get("ts"))
117
+ new = parse_ts(ts)
118
+ if new and (seen is None or new > seen):
119
+ row["observed_at"] = ts
120
+ self.conn.execute("UPDATE rate_limits SET observed_at=? WHERE id=?", (ts, row["id"]))
121
+ return False
122
+ cur = self.conn.execute("INSERT INTO rate_limits(agent,label,ts,observed_at,window_minutes,used_percent,resets_at) VALUES (?,?,?,?,?,?,?)",
123
+ (obs["agent"], obs["label"], ts, ts, obs["window_minutes"], obs["used_percent"], obs["resets_at"]))
124
+ self.last[(obs["agent"], obs["label"])] = {"id": cur.lastrowid, "used_percent": obs["used_percent"], "resets_at": obs["resets_at"],
125
+ "window_minutes": obs["window_minutes"], "ts": ts, "observed_at": ts}
126
+ return True
127
+
128
+
129
+ def _claude_window_minutes(label: str) -> int | None:
130
+ if label in CLAUDE_LIMIT_WINDOWS:
131
+ return CLAUDE_LIMIT_WINDOWS[label]
132
+ if label.startswith("seven_day"):
133
+ return 10080
134
+ return None
135
+
136
+
137
+ def ingest_claude_limits(conn, path: Path | None = None, store: LimitStore | None = None) -> int:
138
+ """Read the status-line snapshot (written by a Claude Code status-line script); return the number of rows inserted."""
139
+ path = path or claude_limits_path()
140
+ try:
141
+ data = json.loads(path.read_text(encoding="utf-8"))
142
+ except (OSError, ValueError):
143
+ return 0
144
+ if not isinstance(data, dict) or not isinstance(data.get("rate_limits"), dict):
145
+ return 0
146
+ store = store or LimitStore(conn)
147
+ ts = data.get("ts")
148
+ inserted = 0
149
+ for label, window in data["rate_limits"].items():
150
+ if not isinstance(window, dict):
151
+ continue
152
+ used = window.get("used_percentage", window.get("used_percent"))
153
+ if used is None:
154
+ continue
155
+ inserted += store.observe({"agent": "claude", "label": str(label), "ts": ts, "window_minutes": _claude_window_minutes(str(label)),
156
+ "used_percent": used, "resets_at": iso(window.get("resets_at"))})
157
+ return inserted
158
+
159
+
160
+ def ingest_file(conn, path: Path, parser: str, known: dict[str, int] | None = None, store: LimitStore | None = None,
161
+ stats: dict[str, int] | None = None) -> int:
162
+ """Ingest new complete lines of one log; return the number of new usage events."""
163
+ source = str(path)
164
+ try:
165
+ size = path.stat().st_size
166
+ except OSError:
167
+ return 0
168
+ if known is not None and known.get(source, 0) == size:
169
+ return 0 # unchanged since last pass: stat only, no open, no query
170
+ row = conn.execute("SELECT byte_offset,context FROM ingest_offsets WHERE source_file=?", (source,)).fetchone()
171
+ start = int(row[0]) if row else 0
172
+ if start == size:
173
+ return 0
174
+ if size < start:
175
+ start = 0 # truncated or rotated
176
+ store = store or LimitStore(conn)
177
+ count, end, seen = 0, start, set()
178
+ context: dict = {}
179
+ if start:
180
+ try:
181
+ context = json.loads(row[1]) if row and row[1] else {}
182
+ except ValueError:
183
+ context = {}
184
+ if parser == "codex" and not context: # offsets written by v1 carry no parser state: rebuild it once from the prefix
185
+ with path.open("rb") as old:
186
+ for raw in old:
187
+ if old.tell() > start:
188
+ break
189
+ codex.parse_record(raw.decode("utf-8", errors="replace"), context)
190
+ for at, line, next_at in _read_new(path, start):
191
+ if line is None:
192
+ break
193
+ end = next_at
194
+ try:
195
+ if "prompt_checked" not in context: _check_spawned(parser, line, context)
196
+ count += _ingest_line(conn, parser, line, source, at, seen, context, store, stats)
197
+ except Exception as exc:
198
+ raise _LineFailed(at) from exc
199
+ conn.execute("INSERT INTO ingest_offsets(source_file,byte_offset,context) VALUES (?,?,?) "
200
+ "ON CONFLICT(source_file) DO UPDATE SET byte_offset=excluded.byte_offset, context=excluded.context",
201
+ (source, end, json.dumps(context) if context else None))
202
+ if parser == "codex" and isinstance(context.get("session_id"), str):
203
+ conn.execute("UPDATE subagents SET tail_partial=? WHERE agent_id=?", (int(end < size), context["session_id"]))
204
+ if known is not None:
205
+ known[source] = end
206
+ return count
207
+
208
+
209
+ SPAWNED = "<devmem-spawned>" # dispatch and maintenance start their runner prompts with this
210
+
211
+
212
+ def _check_spawned(parser: str, line: str, context: dict) -> None:
213
+ """Only the session's first user prompt can mark it devmem-spawned; a later mention of the marker (a diff, a quoted log) never does."""
214
+ if '"user"' not in line: return
215
+ try: obj = json.loads(line)
216
+ except ValueError: return
217
+ if not isinstance(obj, dict): return
218
+ if parser == "codex":
219
+ payload = obj.get("payload") if isinstance(obj.get("payload"), dict) else {}
220
+ if obj.get("type") != "response_item" or payload.get("type") != "message" or payload.get("role") != "user": return
221
+ text = codex._text(payload.get("content"))
222
+ if not isinstance(text, str) or not codex._is_task(text): return # environment and instruction preambles come first
223
+ else:
224
+ message = obj.get("message") if isinstance(obj.get("message"), dict) else {}
225
+ if obj.get("type") != "user" or obj.get("isMeta") or message.get("role") != "user": return
226
+ text = codex._text(message.get("content"))
227
+ if not isinstance(text, str) or not text.strip(): return
228
+ context["prompt_checked"] = True
229
+ if text.lstrip().startswith(SPAWNED): context["devmem_spawned"] = True
230
+
231
+
232
+ def _ingest_line(conn, parser: str, line: str, source: str, at: int, seen: set, context: dict, store: LimitStore, stats: dict[str, int] | None) -> int:
233
+ count = activities = 0
234
+ if parser == "claude":
235
+ event, acts = claude.parse_record(line)
236
+ activities += _insert_activities(conn, acts, source, at)
237
+ if event:
238
+ key = event.pop("dedup_key")
239
+ fresh = key == (None, None) or key not in seen
240
+ if fresh and key != (None, None):
241
+ seen.add(key)
242
+ fresh = conn.execute("INSERT OR IGNORE INTO usage_dedup(agent,dedup_key) VALUES('claude',?)", (json.dumps(key),)).rowcount > 0
243
+ if fresh:
244
+ count += _insert_event(conn, event, source, at)
245
+ else:
246
+ event, limits, acts = codex.parse_record(line, context)
247
+ rec = codex.parse_agent_record(line, context)
248
+ if rec:
249
+ subagents.apply_codex(conn, rec, source, at)
250
+ activities += _insert_activities(conn, acts, source, at)
251
+ if event:
252
+ count += _insert_event(conn, event, source, at)
253
+ for limit in limits:
254
+ store.observe(limit)
255
+ if stats is not None:
256
+ stats["activity"] = stats.get("activity", 0) + activities
257
+ return count
258
+
259
+
260
+ def ingest_subagent_file(conn, path: Path, known: dict[str, int] | None = None) -> bool:
261
+ """Read new lines of one subagent log into subagents/subagent_tools, from that table's own offset.
262
+
263
+ The offset is separate from ingest_offsets so logs already counted for tokens before v9 are still read once here.
264
+ """
265
+ aid = subagents.agent_id(path)
266
+ try:
267
+ size = path.stat().st_size
268
+ except OSError:
269
+ return False
270
+ if aid is None or (known is not None and known.get(aid) == size):
271
+ return False
272
+ row = conn.execute("SELECT byte_offset FROM subagents WHERE agent_id=?", (aid,)).fetchone()
273
+ start = int(row[0]) if row else 0
274
+ if start == size:
275
+ return False
276
+ if size < start:
277
+ start = 0
278
+ subagents.ensure(conn, aid, str(path), path.parent.parent.name)
279
+ end = start
280
+ for at, line, next_at in _read_new(path, start):
281
+ if line is None:
282
+ break
283
+ end = next_at
284
+ try:
285
+ rec = subagents.parse(line)
286
+ if rec:
287
+ subagents.apply(conn, aid, rec, at)
288
+ except Exception as exc:
289
+ raise _LineFailed(at) from exc
290
+ # tail_partial: the last line is still being written, so the newest complete record may not be the run's end.
291
+ conn.execute("UPDATE subagents SET byte_offset=?,source_file=?,tail_partial=? WHERE agent_id=?", (end, str(path), int(end < size), aid))
292
+ if known is not None:
293
+ known[aid] = end
294
+ return True
295
+
296
+
297
+ def ingest_all(claude_root: Path | None = None, codex_root: Path | None = None, limits_path: Path | None = None,
298
+ stats: dict[str, int] | None = None) -> int:
299
+ """One incremental pass over all logs; return the number of new usage events."""
300
+ claude_root = claude_root or Path(os.environ.get("DEVMEM_CLAUDE_HOME", "~/.claude")).expanduser()
301
+ codex_root = codex_root or Path(os.environ.get("DEVMEM_CODEX_HOME", "~/.codex")).expanduser()
302
+ total = 0
303
+ with connection() as conn:
304
+ known = {r[0]: int(r[1]) for r in conn.execute("SELECT source_file,byte_offset FROM ingest_offsets")}
305
+ known_agents = {r[0]: int(r[1]) for r in conn.execute("SELECT agent_id,byte_offset FROM subagents")}
306
+ store = LimitStore(conn)
307
+ # fix(2026-10-28): every file (and each subagent step) runs in its own SAVEPOINT; one bad log never freezes usage ingest.
308
+ _guarded(conn, claude_root, lambda: subagents.scan_meta(conn, claude_root), 0) # first, so spawn events carry type and description
309
+ for path in claude.files(claude_root):
310
+ total += _guarded(conn, path, lambda: ingest_file(conn, path, "claude", known, store, stats), 0, store.last.clear)
311
+ if subagents.agent_id(path):
312
+ _guarded(conn, path, lambda: ingest_subagent_file(conn, path, known_agents), False)
313
+ for path in sorted(codex.files(codex_root)):
314
+ total += _guarded(conn, path, lambda: ingest_file(conn, path, "codex", known, store, stats), 0, store.last.clear)
315
+ _guarded(conn, "Codex handoff links", lambda: subagents.link_codex_handoffs(conn, claude_root), None)
316
+ _guarded(conn, "subagent events", lambda: subagents.emit_events(conn), 0)
317
+ _guarded(conn, limits_path or claude_limits_path(), lambda: ingest_claude_limits(conn, limits_path, store), 0, store.last.clear)
318
+ return total