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/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")
|
devmem/ingest/runner.py
ADDED
|
@@ -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
|