agent-session-analytics 0.1.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_session_analytics/__init__.py +5 -0
- agent_session_analytics/__main__.py +5 -0
- agent_session_analytics/_cli.py +57 -0
- agent_session_analytics/_db.py +158 -0
- agent_session_analytics/_ingest.py +1190 -0
- agent_session_analytics/_query.py +59 -0
- agent_session_analytics/py.typed +0 -0
- agent_session_analytics/references/canonical-schema.md +149 -0
- agent_session_analytics/references/harness-coverage.md +182 -0
- agent_session_analytics/references/query-conventions.md +91 -0
- agent_session_analytics-0.1.0.dist-info/METADATA +122 -0
- agent_session_analytics-0.1.0.dist-info/RECORD +15 -0
- agent_session_analytics-0.1.0.dist-info/WHEEL +4 -0
- agent_session_analytics-0.1.0.dist-info/entry_points.txt +2 -0
- agent_session_analytics-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1190 @@
|
|
|
1
|
+
"""Ingest multi-harness coding-agent session logs into one DuckDB database.
|
|
2
|
+
|
|
3
|
+
Each harness has a *normalizing adapter* that discovers its native session
|
|
4
|
+
logs and emits one canonical row shape — the same JSON envelope Claude already
|
|
5
|
+
uses (type / message.content[] / timestamp / sessionId / cwd / …) plus a
|
|
6
|
+
``harness`` tag. The adapters write their canonical rows to a staging dir; the
|
|
7
|
+
flattening SQL then loads the union and threads ``harness`` through every
|
|
8
|
+
session-scoped table, so a single query can compare sources.
|
|
9
|
+
|
|
10
|
+
Adapters are discovery-gated and best-effort: a harness with no accessible logs
|
|
11
|
+
records a "no accessible logs" finding (see references/harness-coverage.md) and
|
|
12
|
+
is skipped non-fatally. "Done" is full coverage of what is reachable, not
|
|
13
|
+
parsing the unparseable.
|
|
14
|
+
|
|
15
|
+
Engine: DuckDB CLI — no Python duckdb module required. Skips ingestion if the
|
|
16
|
+
database is less than 1 hour old and carries the current schema version.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import fcntl
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import shutil
|
|
26
|
+
import sys
|
|
27
|
+
import time
|
|
28
|
+
from collections.abc import Iterator
|
|
29
|
+
from datetime import UTC, datetime, timedelta
|
|
30
|
+
from typing import TYPE_CHECKING, cast
|
|
31
|
+
|
|
32
|
+
from agent_session_analytics._db import (
|
|
33
|
+
DB_DIR,
|
|
34
|
+
DB_PATH,
|
|
35
|
+
DB_TMP_PATH,
|
|
36
|
+
SCHEMA_VERSION,
|
|
37
|
+
STAGE_DIR,
|
|
38
|
+
IngestError,
|
|
39
|
+
configured_path,
|
|
40
|
+
db_is_fresh,
|
|
41
|
+
query_rows,
|
|
42
|
+
require_duckdb,
|
|
43
|
+
run_sql,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if TYPE_CHECKING:
|
|
47
|
+
from typing import TypeAlias
|
|
48
|
+
|
|
49
|
+
_JsonValue: TypeAlias = "None | bool | int | float | str | list[_JsonValue] | dict[str, _JsonValue]"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _sql_quote(path: str) -> str:
|
|
53
|
+
"""Escape a filesystem path for embedding as a single-quoted SQL literal."""
|
|
54
|
+
return path.replace("'", "''")
|
|
55
|
+
|
|
56
|
+
# Canonical raw-entry columns the flattening SQL reads. Adapters emit a subset;
|
|
57
|
+
# read_json(union_by_name) fills the rest with NULL.
|
|
58
|
+
RAW_COLUMNS = {
|
|
59
|
+
"harness": "VARCHAR",
|
|
60
|
+
"type": "VARCHAR",
|
|
61
|
+
"subtype": "VARCHAR",
|
|
62
|
+
"timestamp": "VARCHAR",
|
|
63
|
+
"sessionId": "VARCHAR",
|
|
64
|
+
"uuid": "VARCHAR",
|
|
65
|
+
"parentUuid": "VARCHAR",
|
|
66
|
+
"message": "JSON",
|
|
67
|
+
"version": "VARCHAR",
|
|
68
|
+
"gitBranch": "VARCHAR",
|
|
69
|
+
"slug": "VARCHAR",
|
|
70
|
+
"cwd": "VARCHAR",
|
|
71
|
+
"hookCount": "INTEGER",
|
|
72
|
+
"hookInfos": "JSON",
|
|
73
|
+
"hookErrors": "JSON",
|
|
74
|
+
"preventedContinuation": "BOOLEAN",
|
|
75
|
+
"stopReason": "VARCHAR",
|
|
76
|
+
"hasOutput": "BOOLEAN",
|
|
77
|
+
"level": "VARCHAR",
|
|
78
|
+
"isSidechain": "BOOLEAN",
|
|
79
|
+
"userType": "VARCHAR",
|
|
80
|
+
"filename": "VARCHAR",
|
|
81
|
+
"parentSessionId": "VARCHAR",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# --------------------------------------------------------------------------
|
|
86
|
+
# Harness adapters
|
|
87
|
+
#
|
|
88
|
+
# Each adapter is (name, discover, normalize):
|
|
89
|
+
# discover() -> list of source paths, or [] when no logs are reachable.
|
|
90
|
+
# normalize(path) -> yields canonical raw-entry dicts (already harness-tagged).
|
|
91
|
+
# An adapter that finds nothing is non-fatal — the run logs it and continues.
|
|
92
|
+
# --------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _iter_jsonl(path: str) -> Iterator[_JsonValue]:
|
|
96
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
97
|
+
for line in fh:
|
|
98
|
+
line = line.strip()
|
|
99
|
+
if not line:
|
|
100
|
+
continue
|
|
101
|
+
try:
|
|
102
|
+
yield cast(_JsonValue, json.loads(line))
|
|
103
|
+
except json.JSONDecodeError:
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def claude_discover() -> list[str]:
|
|
108
|
+
root = os.path.join(configured_path("CLAUDE_CONFIG_DIR", "~/.claude"), "projects")
|
|
109
|
+
if not os.path.isdir(root):
|
|
110
|
+
return []
|
|
111
|
+
out: list[str] = []
|
|
112
|
+
for dirpath, _dirs, files in os.walk(root):
|
|
113
|
+
out.extend(os.path.join(dirpath, f) for f in files if f.endswith(".jsonl"))
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def claude_normalize(path: str) -> Iterator[dict[str, _JsonValue]]:
|
|
118
|
+
"""Claude logs are already in the canonical envelope; just tag them."""
|
|
119
|
+
for entry in _iter_jsonl(path):
|
|
120
|
+
if not isinstance(entry, dict):
|
|
121
|
+
continue
|
|
122
|
+
entry["harness"] = "claude"
|
|
123
|
+
yield entry
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def codex_discover() -> list[str]:
|
|
127
|
+
root = os.path.join(configured_path("CODEX_HOME", "~/.codex"), "sessions")
|
|
128
|
+
if not os.path.isdir(root):
|
|
129
|
+
return []
|
|
130
|
+
out: list[str] = []
|
|
131
|
+
for dirpath, _dirs, files in os.walk(root):
|
|
132
|
+
out.extend(os.path.join(dirpath, f) for f in files if f.endswith(".jsonl"))
|
|
133
|
+
return out
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _codex_payload_is_error(payload: _JsonValue) -> bool:
|
|
137
|
+
if not isinstance(payload, dict):
|
|
138
|
+
return False
|
|
139
|
+
status = payload.get("status")
|
|
140
|
+
if isinstance(status, str) and status.casefold() in ("error", "failed", "failure"):
|
|
141
|
+
return True
|
|
142
|
+
for key in ("isError", "is_error"):
|
|
143
|
+
value = payload.get(key)
|
|
144
|
+
if value is True or (isinstance(value, str) and value.casefold() == "true"):
|
|
145
|
+
return True
|
|
146
|
+
code = payload.get("exit_code")
|
|
147
|
+
return isinstance(code, int) and not isinstance(code, bool) and code != 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _codex_content_block_payloads(blocks: list[_JsonValue]) -> Iterator[dict[str, _JsonValue]]:
|
|
151
|
+
if not blocks:
|
|
152
|
+
return
|
|
153
|
+
texts: list[str] = []
|
|
154
|
+
for block in blocks:
|
|
155
|
+
if not isinstance(block, dict) or block.get("type") != "input_text":
|
|
156
|
+
continue
|
|
157
|
+
block_text = block.get("text")
|
|
158
|
+
if not isinstance(block_text, str):
|
|
159
|
+
continue
|
|
160
|
+
texts.append(block_text)
|
|
161
|
+
if not texts:
|
|
162
|
+
return
|
|
163
|
+
header = texts[0]
|
|
164
|
+
if not re.fullmatch(
|
|
165
|
+
r"Script completed\nWall time(?:\s*:\s*|\s+)[^\n]*\nOutput:\n",
|
|
166
|
+
header,
|
|
167
|
+
):
|
|
168
|
+
return
|
|
169
|
+
for block_text in texts[1:]:
|
|
170
|
+
try:
|
|
171
|
+
payload = cast(_JsonValue, json.loads(block_text.strip()))
|
|
172
|
+
except ValueError:
|
|
173
|
+
continue
|
|
174
|
+
if isinstance(payload, dict):
|
|
175
|
+
yield payload
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _codex_structured_payloads(out: _JsonValue) -> Iterator[dict[str, _JsonValue]]:
|
|
179
|
+
if isinstance(out, dict):
|
|
180
|
+
yield out
|
|
181
|
+
return
|
|
182
|
+
if isinstance(out, list):
|
|
183
|
+
yield from _codex_content_block_payloads(out)
|
|
184
|
+
return
|
|
185
|
+
if not isinstance(out, str):
|
|
186
|
+
return
|
|
187
|
+
text = out.strip()
|
|
188
|
+
try:
|
|
189
|
+
parsed = cast(_JsonValue, json.loads(text))
|
|
190
|
+
except ValueError:
|
|
191
|
+
parsed = None
|
|
192
|
+
if isinstance(parsed, dict):
|
|
193
|
+
yield parsed
|
|
194
|
+
return
|
|
195
|
+
if isinstance(parsed, list):
|
|
196
|
+
yield from _codex_content_block_payloads(parsed)
|
|
197
|
+
return
|
|
198
|
+
marker = re.search(
|
|
199
|
+
r"^Script completed\n(?:Wall time(?:\s*:\s*|\s+)[^\n]*\n)?Output:\s*(.+)\s*\Z",
|
|
200
|
+
out,
|
|
201
|
+
re.DOTALL,
|
|
202
|
+
)
|
|
203
|
+
if not marker:
|
|
204
|
+
return
|
|
205
|
+
decoder = json.JSONDecoder()
|
|
206
|
+
text = marker.group(1)
|
|
207
|
+
position = 0
|
|
208
|
+
while position < len(text):
|
|
209
|
+
while position < len(text) and text[position].isspace():
|
|
210
|
+
position += 1
|
|
211
|
+
if position == len(text):
|
|
212
|
+
return
|
|
213
|
+
try:
|
|
214
|
+
parsed, end = cast(tuple[_JsonValue, int], decoder.raw_decode(text, position))
|
|
215
|
+
except ValueError:
|
|
216
|
+
return
|
|
217
|
+
if isinstance(parsed, dict):
|
|
218
|
+
yield parsed
|
|
219
|
+
elif isinstance(parsed, list):
|
|
220
|
+
yield from (item for item in parsed if isinstance(item, dict))
|
|
221
|
+
position = end
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _codex_output_is_error(out: _JsonValue) -> str:
|
|
225
|
+
"""Detect structured or legacy failure signals in a Codex tool output."""
|
|
226
|
+
if any(
|
|
227
|
+
_codex_payload_is_error(payload) for payload in _codex_structured_payloads(out)
|
|
228
|
+
):
|
|
229
|
+
return "true"
|
|
230
|
+
if isinstance(out, str):
|
|
231
|
+
match = re.search(r"Process exited with code (-?\d+)\b", out)
|
|
232
|
+
if match and any(int(digit) != 0 for digit in match.group(1).lstrip("-")):
|
|
233
|
+
return "true"
|
|
234
|
+
return "false"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _codex_command(name: _JsonValue, parsed: _JsonValue, raw_input: _JsonValue) -> str | None:
|
|
238
|
+
"""The executed command string for codex shell-ish tools, or None.
|
|
239
|
+
|
|
240
|
+
Mirrors claude's ``input.command`` so ``tool_uses.bash_cmd`` populates:
|
|
241
|
+
``exec_command`` carries it in ``cmd``; legacy ``shell`` carries an argv
|
|
242
|
+
array (the ``-lc``/``-c`` payload is the real command); the ``exec``
|
|
243
|
+
custom tool's argument IS the raw code string.
|
|
244
|
+
"""
|
|
245
|
+
if name == "exec_command" and isinstance(parsed, dict):
|
|
246
|
+
cmd = parsed.get("cmd")
|
|
247
|
+
return cmd if isinstance(cmd, str) else None
|
|
248
|
+
if name == "shell" and isinstance(parsed, dict):
|
|
249
|
+
cmd = parsed.get("command")
|
|
250
|
+
if isinstance(cmd, str):
|
|
251
|
+
return cmd
|
|
252
|
+
if isinstance(cmd, list) and cmd:
|
|
253
|
+
if len(cmd) == 3 and cmd[1] in ("-lc", "-c"):
|
|
254
|
+
return str(cmd[2])
|
|
255
|
+
return " ".join(str(c) for c in cmd)
|
|
256
|
+
return None
|
|
257
|
+
if name == "exec" and isinstance(raw_input, str):
|
|
258
|
+
return raw_input
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _codex_token_usage(
|
|
263
|
+
payload: dict[str, _JsonValue], last_total: _JsonValue
|
|
264
|
+
) -> tuple[dict[str, _JsonValue] | None, _JsonValue]:
|
|
265
|
+
"""Map a token_count payload to (Claude-style usage, total_token_usage).
|
|
266
|
+
|
|
267
|
+
Returns usage None when last_token_usage is null or when total_token_usage
|
|
268
|
+
repeats the previous event (Codex re-emits it). The returned total is the
|
|
269
|
+
latest total seen on any token_count event, with or without usage.
|
|
270
|
+
"""
|
|
271
|
+
info = payload.get("info")
|
|
272
|
+
if not isinstance(info, dict):
|
|
273
|
+
return None, last_total
|
|
274
|
+
total = info.get("total_token_usage")
|
|
275
|
+
new_total = last_total if total is None else total
|
|
276
|
+
last = info.get("last_token_usage")
|
|
277
|
+
if not isinstance(last, dict):
|
|
278
|
+
return None, new_total
|
|
279
|
+
if total is not None and total == last_total:
|
|
280
|
+
return None, new_total
|
|
281
|
+
usage: dict[str, _JsonValue] = {
|
|
282
|
+
"input_tokens": last.get("input_tokens"),
|
|
283
|
+
"output_tokens": last.get("output_tokens"),
|
|
284
|
+
"cache_read_input_tokens": last.get("cached_input_tokens"),
|
|
285
|
+
}
|
|
286
|
+
return usage, new_total
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _codex_parent_session(payload: dict[str, _JsonValue]) -> str | None:
|
|
290
|
+
"""Parent thread id of a sub-agent rollout, or None.
|
|
291
|
+
|
|
292
|
+
A sub-agent session_meta carries ``source.subagent.thread_spawn``; other
|
|
293
|
+
sessions carry a plain string or no ``source``.
|
|
294
|
+
"""
|
|
295
|
+
source = payload.get("source")
|
|
296
|
+
subagent = source.get("subagent") if isinstance(source, dict) else None
|
|
297
|
+
spawn = subagent.get("thread_spawn") if isinstance(subagent, dict) else None
|
|
298
|
+
parent = spawn.get("parent_thread_id") if isinstance(spawn, dict) else None
|
|
299
|
+
return parent if isinstance(parent, str) and parent else None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def codex_normalize(path: str) -> Iterator[dict[str, object]]:
|
|
303
|
+
"""Codex rollout JSONL -> canonical envelope.
|
|
304
|
+
|
|
305
|
+
session_meta carries id + cwd; a response_item/function_call becomes an
|
|
306
|
+
assistant tool_use block; a function_call_output OR custom_tool_call_output
|
|
307
|
+
becomes a user tool_result block (dropping the custom outputs was the ~50%
|
|
308
|
+
result-join gap — issue #704). Codex tool names (shell / apply_patch /
|
|
309
|
+
custom tools) are kept verbatim, and ``input.command`` is normalized to the
|
|
310
|
+
executed command string for shell-ish tools so ``bash_cmd`` extracts.
|
|
311
|
+
``tool_search_output`` items have no matching call item and are dropped.
|
|
312
|
+
A forked rollout holds two ``session_meta`` records (own id first, parent
|
|
313
|
+
second); the first id, ``cwd``, and ``cli_version`` describe the file. A
|
|
314
|
+
token row before any id falls back to the file path, so ids stay unique.
|
|
315
|
+
``session_meta.cli_version`` stamps ``version`` on every entry. A
|
|
316
|
+
sub-agent rollout names its parent in the first ``session_meta``; that id
|
|
317
|
+
stamps ``parentSessionId`` on every entry. Each non-duplicate
|
|
318
|
+
``token_count`` event with a ``last_token_usage`` becomes one
|
|
319
|
+
``token_usage`` entry (unique ``message.id``, empty content). Only
|
|
320
|
+
model_turns reads it, as one row; no other table sees it.
|
|
321
|
+
"""
|
|
322
|
+
session_id = None
|
|
323
|
+
parent_session_id = None
|
|
324
|
+
cwd = None
|
|
325
|
+
cli_version = None
|
|
326
|
+
model = None
|
|
327
|
+
last_total = None
|
|
328
|
+
token_turns = 0
|
|
329
|
+
for entry in _iter_jsonl(path):
|
|
330
|
+
if not isinstance(entry, dict):
|
|
331
|
+
continue
|
|
332
|
+
ts = entry.get("timestamp")
|
|
333
|
+
payload = entry.get("payload")
|
|
334
|
+
etype = entry.get("type")
|
|
335
|
+
if etype == "session_meta" and isinstance(payload, dict):
|
|
336
|
+
if session_id is None:
|
|
337
|
+
parent_session_id = _codex_parent_session(payload)
|
|
338
|
+
session_id = session_id or payload.get("id")
|
|
339
|
+
cwd = cwd or payload.get("cwd")
|
|
340
|
+
cli_version = cli_version or payload.get("cli_version") or None
|
|
341
|
+
continue
|
|
342
|
+
if etype == "turn_context" and isinstance(payload, dict):
|
|
343
|
+
cwd = payload.get("cwd") or cwd
|
|
344
|
+
model = payload.get("model") or model
|
|
345
|
+
continue
|
|
346
|
+
if (
|
|
347
|
+
etype == "event_msg"
|
|
348
|
+
and isinstance(payload, dict)
|
|
349
|
+
and payload.get("type") == "token_count"
|
|
350
|
+
):
|
|
351
|
+
usage, last_total = _codex_token_usage(payload, last_total)
|
|
352
|
+
if usage is None:
|
|
353
|
+
continue
|
|
354
|
+
token_turns += 1
|
|
355
|
+
yield {
|
|
356
|
+
"harness": "codex",
|
|
357
|
+
"type": "token_usage",
|
|
358
|
+
"timestamp": ts,
|
|
359
|
+
"sessionId": session_id,
|
|
360
|
+
"parentSessionId": parent_session_id,
|
|
361
|
+
"version": cli_version,
|
|
362
|
+
"cwd": cwd,
|
|
363
|
+
"message": {
|
|
364
|
+
"id": f"codex-tokens:{session_id or path}:{token_turns}",
|
|
365
|
+
"model": model or "unknown",
|
|
366
|
+
"content": [],
|
|
367
|
+
"usage": usage,
|
|
368
|
+
},
|
|
369
|
+
}
|
|
370
|
+
continue
|
|
371
|
+
if etype != "response_item" or not isinstance(payload, dict):
|
|
372
|
+
continue
|
|
373
|
+
ptype = payload.get("type")
|
|
374
|
+
if ptype in ("function_call", "custom_tool_call"):
|
|
375
|
+
raw_input = payload.get("arguments") or payload.get("input")
|
|
376
|
+
try:
|
|
377
|
+
parsed: _JsonValue = (
|
|
378
|
+
cast(_JsonValue, json.loads(raw_input)) if isinstance(raw_input, str) else raw_input
|
|
379
|
+
)
|
|
380
|
+
except (json.JSONDecodeError, TypeError):
|
|
381
|
+
parsed = {"raw": raw_input}
|
|
382
|
+
if not isinstance(parsed, dict):
|
|
383
|
+
parsed = {"raw": parsed}
|
|
384
|
+
name = payload.get("name")
|
|
385
|
+
command = _codex_command(name, parsed, raw_input)
|
|
386
|
+
if command is not None:
|
|
387
|
+
parsed["command"] = command
|
|
388
|
+
yield {
|
|
389
|
+
"harness": "codex",
|
|
390
|
+
"type": "assistant",
|
|
391
|
+
"timestamp": ts,
|
|
392
|
+
"sessionId": session_id,
|
|
393
|
+
"parentSessionId": parent_session_id,
|
|
394
|
+
"version": cli_version,
|
|
395
|
+
"cwd": cwd,
|
|
396
|
+
"message": {
|
|
397
|
+
"content": [
|
|
398
|
+
{
|
|
399
|
+
"type": "tool_use",
|
|
400
|
+
"id": payload.get("call_id"),
|
|
401
|
+
"name": name,
|
|
402
|
+
"input": parsed,
|
|
403
|
+
}
|
|
404
|
+
]
|
|
405
|
+
},
|
|
406
|
+
}
|
|
407
|
+
elif ptype in ("function_call_output", "custom_tool_call_output"):
|
|
408
|
+
out = payload.get("output")
|
|
409
|
+
is_error = _codex_output_is_error(out)
|
|
410
|
+
if isinstance(out, dict):
|
|
411
|
+
out = json.dumps(out)
|
|
412
|
+
yield {
|
|
413
|
+
"harness": "codex",
|
|
414
|
+
"type": "user",
|
|
415
|
+
"timestamp": ts,
|
|
416
|
+
"sessionId": session_id,
|
|
417
|
+
"parentSessionId": parent_session_id,
|
|
418
|
+
"version": cli_version,
|
|
419
|
+
"cwd": cwd,
|
|
420
|
+
"message": {
|
|
421
|
+
"content": [
|
|
422
|
+
{
|
|
423
|
+
"type": "tool_result",
|
|
424
|
+
"tool_use_id": payload.get("call_id"),
|
|
425
|
+
"content": out,
|
|
426
|
+
"is_error": is_error,
|
|
427
|
+
}
|
|
428
|
+
]
|
|
429
|
+
},
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _pi_family_discover(config_dir: str) -> list[str]:
|
|
434
|
+
root = os.path.expanduser(config_dir)
|
|
435
|
+
if not os.path.isdir(root):
|
|
436
|
+
return []
|
|
437
|
+
out: list[str] = []
|
|
438
|
+
for dirpath, _dirs, files in os.walk(root):
|
|
439
|
+
out.extend(os.path.join(dirpath, f) for f in files if f.endswith(".jsonl"))
|
|
440
|
+
return out
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
_PI_FAMILY_STOP_REASONS = {
|
|
444
|
+
"stop": "end_turn",
|
|
445
|
+
"toolUse": "tool_use",
|
|
446
|
+
"length": "max_tokens",
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _pi_family_turn_meta(msg: dict[str, _JsonValue]) -> dict[str, _JsonValue]:
|
|
451
|
+
"""Map Pi-family turn metadata to canonical Claude field names."""
|
|
452
|
+
usage = msg.get("usage")
|
|
453
|
+
if not isinstance(usage, dict):
|
|
454
|
+
usage = {}
|
|
455
|
+
snapshot = msg.get("contextSnapshot")
|
|
456
|
+
if not isinstance(snapshot, dict):
|
|
457
|
+
snapshot = {}
|
|
458
|
+
stop = msg.get("stopReason")
|
|
459
|
+
if isinstance(stop, str):
|
|
460
|
+
stop = _PI_FAMILY_STOP_REASONS.get(stop, stop)
|
|
461
|
+
elif isinstance(stop, (list, dict)):
|
|
462
|
+
stop = None
|
|
463
|
+
model = msg.get("model")
|
|
464
|
+
if model and msg.get("provider"):
|
|
465
|
+
model = f"{msg['provider']}/{model}"
|
|
466
|
+
meta: dict[str, _JsonValue] = {
|
|
467
|
+
"model": model,
|
|
468
|
+
"stop_reason": stop,
|
|
469
|
+
"error_message": msg.get("errorMessage"),
|
|
470
|
+
"usage": {
|
|
471
|
+
"input_tokens": usage.get("input"),
|
|
472
|
+
"output_tokens": usage.get("output"),
|
|
473
|
+
"cache_read_input_tokens": usage.get("cacheRead"),
|
|
474
|
+
},
|
|
475
|
+
"duration_ms": msg.get("duration"),
|
|
476
|
+
"ttft_ms": msg.get("ttft"),
|
|
477
|
+
"prompt_tokens": snapshot.get("promptTokens"),
|
|
478
|
+
}
|
|
479
|
+
return {key: value for key, value in meta.items() if value is not None}
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def omp_discover() -> list[str]:
|
|
483
|
+
return _pi_family_discover("~/.omp/agent/sessions")
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def pi_discover() -> list[str]:
|
|
487
|
+
return _pi_family_discover("~/.pi/agent/sessions")
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _pi_family_normalize(path: str, harness: str) -> Iterator[dict[str, object]]:
|
|
491
|
+
"""Pi-family session JSONL -> canonical envelope.
|
|
492
|
+
|
|
493
|
+
The ``session`` header entry carries id + cwd, threaded onto every row.
|
|
494
|
+
``message`` entries: assistant ``toolCall`` blocks become tool_use blocks
|
|
495
|
+
(other blocks pass through); role ``toolResult`` becomes a user tool_result
|
|
496
|
+
block joined on ``toolCallId``, with the msg-level ``isError`` boolean as
|
|
497
|
+
the error flag; role ``user`` passes through. Tool names (``bash``, ``read``,
|
|
498
|
+
``mcp__<server>_<tool>``) are kept verbatim.
|
|
499
|
+
"""
|
|
500
|
+
session_id = None
|
|
501
|
+
cwd = None
|
|
502
|
+
for entry in _iter_jsonl(path):
|
|
503
|
+
if not isinstance(entry, dict):
|
|
504
|
+
continue
|
|
505
|
+
etype = entry.get("type")
|
|
506
|
+
if etype == "session":
|
|
507
|
+
session_id = entry.get("id") or session_id
|
|
508
|
+
cwd = entry.get("cwd") or cwd
|
|
509
|
+
continue
|
|
510
|
+
if etype != "message":
|
|
511
|
+
continue
|
|
512
|
+
msg = entry.get("message")
|
|
513
|
+
if not isinstance(msg, dict):
|
|
514
|
+
continue
|
|
515
|
+
ts = entry.get("timestamp")
|
|
516
|
+
role = msg.get("role")
|
|
517
|
+
if role == "assistant":
|
|
518
|
+
blocks: list[dict[str, _JsonValue]] = []
|
|
519
|
+
content = msg.get("content")
|
|
520
|
+
for block in content if isinstance(content, list) else []:
|
|
521
|
+
if not isinstance(block, dict):
|
|
522
|
+
continue
|
|
523
|
+
if block.get("type") == "toolCall":
|
|
524
|
+
args = block.get("arguments")
|
|
525
|
+
if isinstance(args, str):
|
|
526
|
+
try:
|
|
527
|
+
args = cast(_JsonValue, json.loads(args))
|
|
528
|
+
except json.JSONDecodeError:
|
|
529
|
+
args = {"raw": args}
|
|
530
|
+
if not isinstance(args, dict):
|
|
531
|
+
args = {"raw": args}
|
|
532
|
+
args = cast(dict[str, _JsonValue], args)
|
|
533
|
+
blocks.append(
|
|
534
|
+
{
|
|
535
|
+
"type": "tool_use",
|
|
536
|
+
"id": block.get("id"),
|
|
537
|
+
"name": block.get("name"),
|
|
538
|
+
"input": args,
|
|
539
|
+
}
|
|
540
|
+
)
|
|
541
|
+
else:
|
|
542
|
+
blocks.append(block)
|
|
543
|
+
yield {
|
|
544
|
+
"harness": harness,
|
|
545
|
+
"type": "assistant",
|
|
546
|
+
"timestamp": ts,
|
|
547
|
+
"sessionId": session_id,
|
|
548
|
+
"cwd": cwd,
|
|
549
|
+
"message": {"content": blocks, **_pi_family_turn_meta(msg)},
|
|
550
|
+
}
|
|
551
|
+
elif role == "toolResult":
|
|
552
|
+
content = msg.get("content")
|
|
553
|
+
parts: list[str] = []
|
|
554
|
+
for block in content if isinstance(content, list) else []:
|
|
555
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
556
|
+
block_text = block.get("text", "")
|
|
557
|
+
if isinstance(block_text, str):
|
|
558
|
+
parts.append(block_text)
|
|
559
|
+
text = "\n".join(parts)
|
|
560
|
+
yield {
|
|
561
|
+
"harness": harness,
|
|
562
|
+
"type": "user",
|
|
563
|
+
"timestamp": ts,
|
|
564
|
+
"sessionId": session_id,
|
|
565
|
+
"cwd": cwd,
|
|
566
|
+
"message": {
|
|
567
|
+
"content": [
|
|
568
|
+
{
|
|
569
|
+
"type": "tool_result",
|
|
570
|
+
"tool_use_id": msg.get("toolCallId"),
|
|
571
|
+
"content": text,
|
|
572
|
+
"is_error": "true" if msg.get("isError") else "false",
|
|
573
|
+
}
|
|
574
|
+
]
|
|
575
|
+
},
|
|
576
|
+
}
|
|
577
|
+
elif role == "user":
|
|
578
|
+
yield {
|
|
579
|
+
"harness": harness,
|
|
580
|
+
"type": "user",
|
|
581
|
+
"timestamp": ts,
|
|
582
|
+
"sessionId": session_id,
|
|
583
|
+
"cwd": cwd,
|
|
584
|
+
"message": {"content": msg.get("content")},
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def omp_normalize(path: str) -> Iterator[dict[str, object]]:
|
|
589
|
+
return _pi_family_normalize(path, "omp")
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def pi_normalize(path: str) -> Iterator[dict[str, object]]:
|
|
593
|
+
return _pi_family_normalize(path, "pi")
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def cursor_discover() -> list[str]:
|
|
597
|
+
root = os.path.join(configured_path("CURSOR_HOME", "~/.cursor"), "projects")
|
|
598
|
+
if not os.path.isdir(root):
|
|
599
|
+
return []
|
|
600
|
+
out: list[str] = []
|
|
601
|
+
for dirpath, _dirs, files in os.walk(root):
|
|
602
|
+
if "agent-transcripts" not in dirpath.split(os.sep):
|
|
603
|
+
continue
|
|
604
|
+
out.extend(os.path.join(dirpath, f) for f in files if f.endswith(".jsonl"))
|
|
605
|
+
return out
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
_CURSOR_TIMESTAMP_RE = re.compile(
|
|
609
|
+
r"<timestamp>\w+, (\w+ \d+, \d+, \d+:\d+ [AP]M) \(UTC([+-]\d+)\)</timestamp>"
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _cursor_parse_timestamp(text: str) -> str | None:
|
|
614
|
+
"""Parse a <timestamp>Weekday, Mon D, YYYY, H:MM AM (UTC±N)</timestamp> tag to ISO-8601 UTC."""
|
|
615
|
+
m = _CURSOR_TIMESTAMP_RE.search(text)
|
|
616
|
+
if not m:
|
|
617
|
+
return None
|
|
618
|
+
dt_str, offset = m.groups()
|
|
619
|
+
try:
|
|
620
|
+
# The UTC offset is applied manually below, so parse a wall-clock time
|
|
621
|
+
# and tag it UTC; a %z-aware parse would double-count the offset.
|
|
622
|
+
dt = datetime.strptime(dt_str, "%b %d, %Y, %I:%M %p").replace(tzinfo=UTC)
|
|
623
|
+
except ValueError:
|
|
624
|
+
return None
|
|
625
|
+
dt -= timedelta(hours=int(offset))
|
|
626
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _cursor_session_id(path: str) -> str:
|
|
630
|
+
return os.path.splitext(os.path.basename(path))[0]
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _cursor_resolve_slug(slug: str, fs_root: str = "/") -> str:
|
|
634
|
+
"""Decode a dash-encoded absolute path, preferring the longest existing dir.
|
|
635
|
+
|
|
636
|
+
Cursor project slugs replace path separators with dashes
|
|
637
|
+
(``Users-paul-Dev-easy-cheese``). A directory whose own name contains a
|
|
638
|
+
dash is ambiguous if every dash is treated as a separator. Walk
|
|
639
|
+
left-to-right and at each step take the longest prefix of remaining
|
|
640
|
+
segments that exists under ``fs_root``; fall back to a naive split for
|
|
641
|
+
any unresolved tail.
|
|
642
|
+
"""
|
|
643
|
+
parts = [p for p in slug.split("-") if p]
|
|
644
|
+
if not parts:
|
|
645
|
+
return "/"
|
|
646
|
+
prefix = fs_root.rstrip("/")
|
|
647
|
+
i = 0
|
|
648
|
+
while i < len(parts):
|
|
649
|
+
matched = None
|
|
650
|
+
j = i
|
|
651
|
+
for j in range(len(parts), i, -1):
|
|
652
|
+
candidate = "-".join(parts[i:j])
|
|
653
|
+
trial = f"{prefix}/{candidate}" if prefix else f"/{candidate}"
|
|
654
|
+
if os.path.isdir(trial):
|
|
655
|
+
matched = trial
|
|
656
|
+
break
|
|
657
|
+
if matched is None:
|
|
658
|
+
rest = "/".join(parts[i:])
|
|
659
|
+
return f"{prefix}/{rest}" if prefix else f"/{rest}"
|
|
660
|
+
prefix = matched
|
|
661
|
+
i = j
|
|
662
|
+
return prefix or "/"
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _cursor_project_cwd(path: str) -> str:
|
|
666
|
+
"""Decode the project-slug directory into a cwd via filesystem resolve."""
|
|
667
|
+
root = os.path.join(configured_path("CURSOR_HOME", "~/.cursor"), "projects")
|
|
668
|
+
rel = os.path.relpath(path, root)
|
|
669
|
+
slug = rel.split(os.sep, 1)[0]
|
|
670
|
+
return _cursor_resolve_slug(slug)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _cursor_lineage(path: str) -> tuple[bool, str | None]:
|
|
674
|
+
"""Return (is_sidechain, parent_uuid) from an agent-transcripts path."""
|
|
675
|
+
parts = os.path.normpath(path).split(os.sep)
|
|
676
|
+
try:
|
|
677
|
+
i = parts.index("agent-transcripts")
|
|
678
|
+
except ValueError:
|
|
679
|
+
return False, None
|
|
680
|
+
if i + 3 < len(parts) and parts[i + 2] == "subagents":
|
|
681
|
+
return True, parts[i + 1]
|
|
682
|
+
return False, None
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _cursor_remap_mcp(name: _JsonValue, tool_input: dict[str, _JsonValue]) -> _JsonValue:
|
|
686
|
+
if name != "CallMcpTool":
|
|
687
|
+
return name
|
|
688
|
+
server = tool_input.get("server")
|
|
689
|
+
tool_name = tool_input.get("toolName")
|
|
690
|
+
if isinstance(server, str) and server and isinstance(tool_name, str) and tool_name:
|
|
691
|
+
return f"mcp__{server}__{tool_name}"
|
|
692
|
+
return name
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _cursor_stop_reason(entry: dict[str, _JsonValue]) -> _JsonValue:
|
|
696
|
+
status = entry.get("status")
|
|
697
|
+
err = entry.get("error")
|
|
698
|
+
if status == "error" and isinstance(err, str) and "abort" in err.lower():
|
|
699
|
+
return "aborted"
|
|
700
|
+
return status
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def cursor_normalize(path: str) -> Iterator[dict[str, object]]:
|
|
704
|
+
"""Cursor agent-transcript JSONL -> canonical envelope.
|
|
705
|
+
|
|
706
|
+
One file per transcript (session id = the uuid filename); subagent
|
|
707
|
+
transcripts live in a sibling ``subagents/`` dir with their own uuid/file.
|
|
708
|
+
``cwd`` is decoded from the project-slug directory name (longest existing
|
|
709
|
+
path wins). Subagent files set ``isSidechain`` + ``parentUuid``. There are
|
|
710
|
+
no per-call timestamps or tool_result blocks: the most recent <timestamp>
|
|
711
|
+
seen in a user turn is carried forward onto later tool_use rows, and
|
|
712
|
+
tool_use ids are synthesized (``session:line:block``) since results can
|
|
713
|
+
never be joined. ``CallMcpTool`` calls with both ``server`` and
|
|
714
|
+
``toolName`` are remapped to ``mcp__<server>__<toolName>`` so they land
|
|
715
|
+
in ``mcp_calls``; incomplete wrappers keep the native name.
|
|
716
|
+
"""
|
|
717
|
+
session_id = _cursor_session_id(path)
|
|
718
|
+
cwd = _cursor_project_cwd(path)
|
|
719
|
+
is_sidechain, parent_uuid = _cursor_lineage(path)
|
|
720
|
+
timestamp = None
|
|
721
|
+
for line_no, entry in enumerate(_iter_jsonl(path)):
|
|
722
|
+
if not isinstance(entry, dict):
|
|
723
|
+
continue
|
|
724
|
+
if entry.get("type") == "turn_ended":
|
|
725
|
+
yield {
|
|
726
|
+
"harness": "cursor",
|
|
727
|
+
"type": "assistant",
|
|
728
|
+
"timestamp": timestamp,
|
|
729
|
+
"sessionId": session_id,
|
|
730
|
+
"cwd": cwd,
|
|
731
|
+
"isSidechain": is_sidechain,
|
|
732
|
+
"parentUuid": parent_uuid,
|
|
733
|
+
"message": {"stop_reason": _cursor_stop_reason(entry)},
|
|
734
|
+
}
|
|
735
|
+
continue
|
|
736
|
+
role = entry.get("role")
|
|
737
|
+
msg = entry.get("message")
|
|
738
|
+
if not isinstance(msg, dict):
|
|
739
|
+
continue
|
|
740
|
+
blocks = msg.get("content")
|
|
741
|
+
if not isinstance(blocks, list):
|
|
742
|
+
continue
|
|
743
|
+
if role == "user":
|
|
744
|
+
for block in blocks:
|
|
745
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
746
|
+
block_text = block.get("text") or ""
|
|
747
|
+
if not isinstance(block_text, str):
|
|
748
|
+
continue
|
|
749
|
+
ts = _cursor_parse_timestamp(block_text)
|
|
750
|
+
if ts:
|
|
751
|
+
timestamp = ts
|
|
752
|
+
continue
|
|
753
|
+
if role != "assistant":
|
|
754
|
+
continue
|
|
755
|
+
out_blocks: list[dict[str, _JsonValue]] = []
|
|
756
|
+
for block_idx, block in enumerate(blocks):
|
|
757
|
+
if not isinstance(block, dict) or block.get("type") != "tool_use":
|
|
758
|
+
continue
|
|
759
|
+
name = block.get("name")
|
|
760
|
+
tool_input = block.get("input")
|
|
761
|
+
if not isinstance(tool_input, dict):
|
|
762
|
+
tool_input = {}
|
|
763
|
+
name = _cursor_remap_mcp(name, tool_input)
|
|
764
|
+
out_blocks.append(
|
|
765
|
+
{
|
|
766
|
+
"type": "tool_use",
|
|
767
|
+
"id": f"{session_id}:{line_no}:{block_idx}",
|
|
768
|
+
"name": name,
|
|
769
|
+
"input": tool_input,
|
|
770
|
+
}
|
|
771
|
+
)
|
|
772
|
+
if out_blocks:
|
|
773
|
+
yield {
|
|
774
|
+
"harness": "cursor",
|
|
775
|
+
"type": "assistant",
|
|
776
|
+
"timestamp": timestamp,
|
|
777
|
+
"sessionId": session_id,
|
|
778
|
+
"cwd": cwd,
|
|
779
|
+
"isSidechain": is_sidechain,
|
|
780
|
+
"parentUuid": parent_uuid,
|
|
781
|
+
"message": {"content": out_blocks},
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def copilot_discover() -> list[str]:
|
|
786
|
+
# GitHub Copilot CLI persists no local session transcript we can find
|
|
787
|
+
# (~/.copilot holds skills/ + mcp-config.json only). See harness-coverage.md.
|
|
788
|
+
return []
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
ADAPTERS = [
|
|
792
|
+
("claude", claude_discover, claude_normalize),
|
|
793
|
+
("codex", codex_discover, codex_normalize),
|
|
794
|
+
("omp", omp_discover, omp_normalize),
|
|
795
|
+
("pi", pi_discover, pi_normalize),
|
|
796
|
+
("cursor", cursor_discover, cursor_normalize),
|
|
797
|
+
("copilot", copilot_discover, None),
|
|
798
|
+
]
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
# --------------------------------------------------------------------------
|
|
802
|
+
# Pipeline
|
|
803
|
+
# --------------------------------------------------------------------------
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _log(message: str) -> None:
|
|
807
|
+
print(message, file=sys.stderr)
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def stage_harnesses() -> list[str]:
|
|
811
|
+
"""Run every adapter; write canonical rows to per-harness staging JSONL.
|
|
812
|
+
|
|
813
|
+
Returns the list of harness names that produced at least one row. Adapters
|
|
814
|
+
that discover no logs (or have no normalizer) are reported and skipped.
|
|
815
|
+
"""
|
|
816
|
+
if os.path.isdir(STAGE_DIR):
|
|
817
|
+
shutil.rmtree(STAGE_DIR)
|
|
818
|
+
os.makedirs(STAGE_DIR, exist_ok=True)
|
|
819
|
+
|
|
820
|
+
loaded: list[str] = []
|
|
821
|
+
for name, discover, normalize in ADAPTERS:
|
|
822
|
+
sources = discover()
|
|
823
|
+
if not sources or normalize is None:
|
|
824
|
+
_log(f" {name}: no accessible logs — skipped")
|
|
825
|
+
continue
|
|
826
|
+
out_path = os.path.join(STAGE_DIR, f"{name}.jsonl")
|
|
827
|
+
count = 0
|
|
828
|
+
with open(out_path, "w", encoding="utf-8") as out:
|
|
829
|
+
for src in sources:
|
|
830
|
+
for entry in normalize(src):
|
|
831
|
+
_ = out.write(json.dumps(entry) + "\n")
|
|
832
|
+
count += 1
|
|
833
|
+
if count:
|
|
834
|
+
loaded.append(name)
|
|
835
|
+
_log(f" {name}: {count} canonical entries")
|
|
836
|
+
else:
|
|
837
|
+
os.remove(out_path)
|
|
838
|
+
_log(f" {name}: no accessible logs — skipped")
|
|
839
|
+
return loaded
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def columns_struct() -> str:
|
|
843
|
+
return "{" + ", ".join(f"{k}: '{v}'" for k, v in RAW_COLUMNS.items()) + "}"
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _do_ingest_and_swap() -> float:
|
|
847
|
+
_log("Discovering + normalizing harness sessions...")
|
|
848
|
+
loaded = stage_harnesses()
|
|
849
|
+
if not loaded:
|
|
850
|
+
raise IngestError("No accessible sessions from any harness. Nothing to ingest.")
|
|
851
|
+
|
|
852
|
+
if os.path.exists(DB_TMP_PATH):
|
|
853
|
+
if os.path.isdir(DB_TMP_PATH):
|
|
854
|
+
raise IngestError(f"{DB_TMP_PATH} is a directory, expected a file.")
|
|
855
|
+
os.remove(DB_TMP_PATH)
|
|
856
|
+
|
|
857
|
+
_log("Loading canonical rows into DuckDB...")
|
|
858
|
+
t0 = time.time()
|
|
859
|
+
stage_glob = os.path.join(STAGE_DIR, "*.jsonl")
|
|
860
|
+
|
|
861
|
+
# Step 1: union every harness's canonical JSONL into raw_entries.
|
|
862
|
+
run_sql(f"""
|
|
863
|
+
CREATE TABLE raw_entries AS
|
|
864
|
+
SELECT *
|
|
865
|
+
FROM read_json(
|
|
866
|
+
'{_sql_quote(stage_glob)}',
|
|
867
|
+
format='newline_delimited',
|
|
868
|
+
union_by_name=true,
|
|
869
|
+
ignore_errors=true,
|
|
870
|
+
columns={columns_struct()}
|
|
871
|
+
);
|
|
872
|
+
""")
|
|
873
|
+
|
|
874
|
+
# Step 2: tool_uses (flattened from assistant content blocks).
|
|
875
|
+
_log(" Creating tool_uses...")
|
|
876
|
+
run_sql("""
|
|
877
|
+
CREATE TABLE tool_uses AS
|
|
878
|
+
WITH content_blocks AS (
|
|
879
|
+
SELECT
|
|
880
|
+
unnest(json_extract(json_extract(message, '$.content'), '$[*]')) AS block,
|
|
881
|
+
harness, timestamp, sessionId, cwd, gitBranch
|
|
882
|
+
FROM raw_entries
|
|
883
|
+
WHERE type = 'assistant'
|
|
884
|
+
AND message IS NOT NULL
|
|
885
|
+
AND json_extract(message, '$.content') IS NOT NULL
|
|
886
|
+
AND json_type(json_extract(message, '$.content')) = 'ARRAY'
|
|
887
|
+
)
|
|
888
|
+
SELECT
|
|
889
|
+
harness,
|
|
890
|
+
json_extract_string(block, '$.name') AS tool_name,
|
|
891
|
+
json_extract_string(block, '$.id') AS tool_use_id,
|
|
892
|
+
json_extract(block, '$.input') AS input,
|
|
893
|
+
json_extract_string(block, '$.input.command') AS bash_cmd,
|
|
894
|
+
json_extract_string(block, '$.input.skill') AS skill_name,
|
|
895
|
+
json_extract_string(block, '$.input.args') AS skill_args,
|
|
896
|
+
json_extract_string(block, '$.input.subagent_type') AS agent_type,
|
|
897
|
+
json_extract_string(block, '$.input.description') AS agent_desc,
|
|
898
|
+
json_extract_string(block, '$.input.mode') AS agent_mode,
|
|
899
|
+
json_extract_string(block, '$.input.pattern') AS grep_pattern,
|
|
900
|
+
json_extract_string(block, '$.input.file_path') AS file_path,
|
|
901
|
+
json_extract_string(block, '$.input.query') AS query,
|
|
902
|
+
timestamp, sessionId, cwd, gitBranch
|
|
903
|
+
FROM content_blocks
|
|
904
|
+
WHERE json_extract_string(block, '$.type') = 'tool_use';
|
|
905
|
+
""")
|
|
906
|
+
|
|
907
|
+
# Step 3: tool_results (from user message content blocks).
|
|
908
|
+
# Claude omits is_error on most successful results (measured: ~99.5% of
|
|
909
|
+
# absent-flag blocks carry non-error content), so an absent flag backfills
|
|
910
|
+
# to explicit 'false' rather than a NULL every string compare treats as
|
|
911
|
+
# success silently. is_error_explicit preserves whether the source block
|
|
912
|
+
# carried the flag, so coverage stays measurable (issue #704).
|
|
913
|
+
_log(" Creating tool_results...")
|
|
914
|
+
run_sql("""
|
|
915
|
+
CREATE TABLE tool_results AS
|
|
916
|
+
WITH content_blocks AS (
|
|
917
|
+
SELECT
|
|
918
|
+
unnest(json_extract(json_extract(message, '$.content'), '$[*]')) AS block,
|
|
919
|
+
harness, timestamp, sessionId
|
|
920
|
+
FROM raw_entries
|
|
921
|
+
WHERE type = 'user'
|
|
922
|
+
AND message IS NOT NULL
|
|
923
|
+
AND json_type(json_extract(message, '$.content')) = 'ARRAY'
|
|
924
|
+
)
|
|
925
|
+
SELECT
|
|
926
|
+
harness,
|
|
927
|
+
json_extract_string(block, '$.tool_use_id') AS tool_use_id,
|
|
928
|
+
substr(json_extract_string(block, '$.content'), 1, 500) AS content,
|
|
929
|
+
coalesce(json_extract_string(block, '$.is_error'), 'false') AS is_error,
|
|
930
|
+
json_extract_string(block, '$.is_error') IS NOT NULL AS is_error_explicit,
|
|
931
|
+
timestamp, sessionId
|
|
932
|
+
FROM content_blocks
|
|
933
|
+
WHERE json_extract_string(block, '$.type') = 'tool_result';
|
|
934
|
+
""")
|
|
935
|
+
|
|
936
|
+
# Step 4: stop_events.
|
|
937
|
+
_log(" Creating stop_events...")
|
|
938
|
+
run_sql("""
|
|
939
|
+
CREATE TABLE stop_events AS
|
|
940
|
+
SELECT
|
|
941
|
+
harness,
|
|
942
|
+
json_extract_string(message, '$.stop_reason') AS stop_reason,
|
|
943
|
+
timestamp, sessionId, cwd, gitBranch
|
|
944
|
+
FROM raw_entries
|
|
945
|
+
WHERE type = 'assistant'
|
|
946
|
+
AND message IS NOT NULL
|
|
947
|
+
AND json_extract_string(message, '$.stop_reason')
|
|
948
|
+
IN ('end_turn', 'stop_sequence', 'max_tokens', 'success', 'error', 'aborted');
|
|
949
|
+
""")
|
|
950
|
+
|
|
951
|
+
# Step 4b: model_turns (one row per assistant turn that names a model).
|
|
952
|
+
# Claude writes one raw entry per content block of a message, and repeats
|
|
953
|
+
# `usage` on each entry, so entries group by `message.id`: tool calls sum,
|
|
954
|
+
# usage takes one value. omp and other harnesses carry no `message.id` and
|
|
955
|
+
# keep one row per entry. TRY_CAST keeps one malformed metric from
|
|
956
|
+
# aborting the whole ingest. Latency and prompt_tokens are omp-only.
|
|
957
|
+
# Codex token rows carry no content, so tool_calls stays NULL (not measured).
|
|
958
|
+
# context_tokens is the prompt size the model read: Claude's input_tokens
|
|
959
|
+
# excludes cache reads and creation, so they are added; Codex's
|
|
960
|
+
# input_tokens already includes cached tokens.
|
|
961
|
+
_log(" Creating model_turns...")
|
|
962
|
+
run_sql("""
|
|
963
|
+
CREATE TABLE model_turns AS
|
|
964
|
+
WITH fragments AS (
|
|
965
|
+
SELECT
|
|
966
|
+
harness, sessionId, cwd, timestamp, message,
|
|
967
|
+
coalesce(json_extract_string(message, '$.id'),
|
|
968
|
+
'row:' || row_number() OVER ()) AS turn_key,
|
|
969
|
+
CASE WHEN harness = 'codex' THEN NULL ELSE
|
|
970
|
+
(SELECT count(*)
|
|
971
|
+
FROM unnest(json_extract(json_extract(message, '$.content'), '$[*]')) AS c(block)
|
|
972
|
+
WHERE json_extract_string(block, '$.type') = 'tool_use') END AS tool_calls
|
|
973
|
+
FROM raw_entries
|
|
974
|
+
WHERE type IN ('assistant', 'token_usage')
|
|
975
|
+
AND message IS NOT NULL
|
|
976
|
+
AND json_extract_string(message, '$.model') IS NOT NULL
|
|
977
|
+
)
|
|
978
|
+
SELECT
|
|
979
|
+
harness,
|
|
980
|
+
any_value(json_extract_string(message, '$.model')) AS model,
|
|
981
|
+
arg_max(json_extract_string(message, '$.stop_reason'), timestamp) AS stop_reason,
|
|
982
|
+
max(json_extract_string(message, '$.error_message')) AS error_message,
|
|
983
|
+
max(TRY_CAST(json_extract(message, '$.usage.input_tokens') AS BIGINT)) AS input_tokens,
|
|
984
|
+
max(TRY_CAST(json_extract(message, '$.usage.output_tokens') AS BIGINT)) AS output_tokens,
|
|
985
|
+
max(TRY_CAST(json_extract(message, '$.usage.cache_read_input_tokens') AS BIGINT))
|
|
986
|
+
AS cache_read_tokens,
|
|
987
|
+
max(TRY_CAST(json_extract(message, '$.usage.cache_creation_input_tokens') AS BIGINT))
|
|
988
|
+
AS cache_creation_tokens,
|
|
989
|
+
CASE WHEN harness = 'claude'
|
|
990
|
+
THEN max(TRY_CAST(json_extract(message, '$.usage.input_tokens') AS BIGINT))
|
|
991
|
+
+ coalesce(max(TRY_CAST(json_extract(message, '$.usage.cache_read_input_tokens') AS BIGINT)), 0)
|
|
992
|
+
+ coalesce(max(TRY_CAST(json_extract(message, '$.usage.cache_creation_input_tokens') AS BIGINT)), 0)
|
|
993
|
+
ELSE max(TRY_CAST(json_extract(message, '$.usage.input_tokens') AS BIGINT))
|
|
994
|
+
END AS context_tokens,
|
|
995
|
+
max(TRY_CAST(json_extract(message, '$.prompt_tokens') AS BIGINT)) AS prompt_tokens,
|
|
996
|
+
max(TRY_CAST(json_extract(message, '$.duration_ms') AS DOUBLE)) AS duration_ms,
|
|
997
|
+
max(TRY_CAST(json_extract(message, '$.ttft_ms') AS DOUBLE)) AS ttft_ms,
|
|
998
|
+
CAST(sum(tool_calls) AS BIGINT) AS tool_calls,
|
|
999
|
+
min(timestamp) AS timestamp, sessionId, any_value(cwd) AS cwd
|
|
1000
|
+
FROM fragments
|
|
1001
|
+
GROUP BY harness, sessionId, turn_key;
|
|
1002
|
+
""")
|
|
1003
|
+
|
|
1004
|
+
# Step 5: agent_spawns.
|
|
1005
|
+
_log(" Creating agent_spawns...")
|
|
1006
|
+
run_sql("""
|
|
1007
|
+
CREATE TABLE agent_spawns AS
|
|
1008
|
+
SELECT
|
|
1009
|
+
harness,
|
|
1010
|
+
coalesce(agent_type, 'general-purpose') AS agent_type,
|
|
1011
|
+
agent_desc AS description,
|
|
1012
|
+
agent_mode AS mode,
|
|
1013
|
+
tool_use_id AS anchor_id,
|
|
1014
|
+
timestamp, sessionId, cwd
|
|
1015
|
+
FROM tool_uses
|
|
1016
|
+
WHERE tool_name IN ('Agent', 'Task');
|
|
1017
|
+
""")
|
|
1018
|
+
|
|
1019
|
+
# Step 6: skill_invocations.
|
|
1020
|
+
_log(" Creating skill_invocations...")
|
|
1021
|
+
run_sql("""
|
|
1022
|
+
CREATE TABLE skill_invocations AS
|
|
1023
|
+
SELECT
|
|
1024
|
+
harness, skill_name, skill_args AS args, tool_use_id AS anchor_id,
|
|
1025
|
+
timestamp, sessionId, cwd
|
|
1026
|
+
FROM tool_uses
|
|
1027
|
+
WHERE tool_name = 'Skill';
|
|
1028
|
+
""")
|
|
1029
|
+
|
|
1030
|
+
# Step 7: mcp_calls.
|
|
1031
|
+
_log(" Creating mcp_calls...")
|
|
1032
|
+
run_sql("""
|
|
1033
|
+
CREATE TABLE mcp_calls AS
|
|
1034
|
+
SELECT * FROM tool_uses WHERE tool_name LIKE 'mcp__%';
|
|
1035
|
+
""")
|
|
1036
|
+
|
|
1037
|
+
# Step 8: sessions summary.
|
|
1038
|
+
_log(" Creating sessions...")
|
|
1039
|
+
run_sql("""
|
|
1040
|
+
CREATE TABLE sessions AS
|
|
1041
|
+
SELECT
|
|
1042
|
+
harness, sessionId,
|
|
1043
|
+
min(timestamp) AS first_seen,
|
|
1044
|
+
max(timestamp) AS last_seen,
|
|
1045
|
+
cwd AS project,
|
|
1046
|
+
gitBranch AS branch,
|
|
1047
|
+
arg_max(version, timestamp) FILTER (WHERE version IS NOT NULL) AS version,
|
|
1048
|
+
max(parentSessionId) AS parent_session_id,
|
|
1049
|
+
count(*) FILTER (WHERE type IS DISTINCT FROM 'token_usage') AS entry_count
|
|
1050
|
+
FROM raw_entries
|
|
1051
|
+
WHERE sessionId IS NOT NULL
|
|
1052
|
+
AND timestamp IS NOT NULL
|
|
1053
|
+
GROUP BY harness, sessionId, cwd, gitBranch;
|
|
1054
|
+
""")
|
|
1055
|
+
|
|
1056
|
+
# Step 9: stop_hooks (claude-only fields, harness threaded through).
|
|
1057
|
+
_log(" Creating stop_hooks...")
|
|
1058
|
+
run_sql("""
|
|
1059
|
+
CREATE TABLE stop_hooks AS
|
|
1060
|
+
SELECT
|
|
1061
|
+
harness, timestamp, sessionId, hookCount, hookInfos, hookErrors,
|
|
1062
|
+
preventedContinuation, stopReason, hasOutput, level
|
|
1063
|
+
FROM raw_entries
|
|
1064
|
+
WHERE type = 'system' AND subtype = 'stop_hook_summary';
|
|
1065
|
+
""")
|
|
1066
|
+
|
|
1067
|
+
# Step 10: permission_denials.
|
|
1068
|
+
_log(" Creating permission_denials...")
|
|
1069
|
+
run_sql("""
|
|
1070
|
+
CREATE TABLE permission_denials AS
|
|
1071
|
+
SELECT harness, content, sessionId, timestamp
|
|
1072
|
+
FROM tool_results
|
|
1073
|
+
WHERE content LIKE 'Permission to use % has been denied%'
|
|
1074
|
+
OR content LIKE 'Hook PreToolUse:% denied this tool%'
|
|
1075
|
+
OR content LIKE '%The user doesn''t want to proceed%';
|
|
1076
|
+
""")
|
|
1077
|
+
|
|
1078
|
+
_log(" Creating indexes...")
|
|
1079
|
+
run_sql("""
|
|
1080
|
+
CREATE INDEX idx_tool_uses_name ON tool_uses(tool_name);
|
|
1081
|
+
CREATE INDEX idx_tool_uses_session ON tool_uses(sessionId);
|
|
1082
|
+
CREATE INDEX idx_tool_uses_harness ON tool_uses(harness);
|
|
1083
|
+
CREATE INDEX idx_tool_results_error ON tool_results(is_error);
|
|
1084
|
+
CREATE INDEX idx_sessions_id ON sessions(sessionId);
|
|
1085
|
+
""")
|
|
1086
|
+
|
|
1087
|
+
run_sql(
|
|
1088
|
+
f"""
|
|
1089
|
+
CREATE TABLE schema_info AS SELECT {SCHEMA_VERSION} AS version;
|
|
1090
|
+
"""
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
os.replace(DB_TMP_PATH, DB_PATH)
|
|
1094
|
+
return time.time() - t0
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def _summary() -> dict[str, object]:
|
|
1098
|
+
counts = query_rows(
|
|
1099
|
+
"""
|
|
1100
|
+
SELECT
|
|
1101
|
+
(SELECT count(*) FROM tool_uses) AS tool_uses,
|
|
1102
|
+
(SELECT count(*) FROM tool_results) AS tool_results,
|
|
1103
|
+
(SELECT count(DISTINCT harness) FROM tool_uses) AS harnesses,
|
|
1104
|
+
(SELECT count(*) FROM agent_spawns) AS agent_spawns,
|
|
1105
|
+
(SELECT count(*) FROM skill_invocations) AS skill_invocations,
|
|
1106
|
+
(SELECT count(*) FROM mcp_calls) AS mcp_calls,
|
|
1107
|
+
(SELECT count(*) FROM sessions) AS sessions,
|
|
1108
|
+
(SELECT count(*) FROM permission_denials) AS permission_denials;
|
|
1109
|
+
"""
|
|
1110
|
+
)
|
|
1111
|
+
# Coverage (issue #704): measurement quality per harness, so consumers can
|
|
1112
|
+
# see how much of the call volume has joined results and how many error
|
|
1113
|
+
# flags were explicit in the source vs backfilled. A low results_joined_pct
|
|
1114
|
+
# means per-tool error rates for that harness are floors.
|
|
1115
|
+
coverage = query_rows(
|
|
1116
|
+
"""
|
|
1117
|
+
WITH result_ids AS (SELECT DISTINCT tool_use_id FROM tool_results)
|
|
1118
|
+
SELECT
|
|
1119
|
+
u.harness,
|
|
1120
|
+
count(*) AS tool_uses,
|
|
1121
|
+
round(100.0 * avg(CASE WHEN r.tool_use_id IS NOT NULL THEN 1 ELSE 0 END), 1)
|
|
1122
|
+
AS results_joined_pct,
|
|
1123
|
+
(SELECT count(*) FROM tool_results tr WHERE tr.harness = u.harness)
|
|
1124
|
+
AS tool_results,
|
|
1125
|
+
(SELECT round(100.0 * avg(CASE WHEN tr.is_error_explicit THEN 1 ELSE 0 END), 1)
|
|
1126
|
+
FROM tool_results tr WHERE tr.harness = u.harness)
|
|
1127
|
+
AS explicit_error_flag_pct
|
|
1128
|
+
FROM tool_uses u
|
|
1129
|
+
LEFT JOIN result_ids r ON u.tool_use_id = r.tool_use_id
|
|
1130
|
+
GROUP BY u.harness
|
|
1131
|
+
ORDER BY u.harness;
|
|
1132
|
+
"""
|
|
1133
|
+
)
|
|
1134
|
+
return {"counts": counts[0] if counts else {}, "coverage": coverage}
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def build_database(*, force: bool = False) -> float | None:
|
|
1138
|
+
"""Build or refresh the database; return the build seconds, or None when it is cached.
|
|
1139
|
+
|
|
1140
|
+
A database that is newer than the TTL and carries the current schema
|
|
1141
|
+
version is left alone unless ``force`` is set. An `IngestError` reports
|
|
1142
|
+
every expected failure, including a failed directory, lock, or stage step.
|
|
1143
|
+
"""
|
|
1144
|
+
_ = os.umask(0o077)
|
|
1145
|
+
require_duckdb()
|
|
1146
|
+
|
|
1147
|
+
if not force and db_is_fresh():
|
|
1148
|
+
return None
|
|
1149
|
+
|
|
1150
|
+
try:
|
|
1151
|
+
os.makedirs(DB_DIR, exist_ok=True)
|
|
1152
|
+
lock_fd = os.open(f"{DB_PATH}.lock", os.O_CREAT | os.O_RDWR)
|
|
1153
|
+
try:
|
|
1154
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
|
1155
|
+
# A waiting run checks freshness again: the lock holder may have just rebuilt.
|
|
1156
|
+
if not force and db_is_fresh():
|
|
1157
|
+
return None
|
|
1158
|
+
try:
|
|
1159
|
+
return _do_ingest_and_swap()
|
|
1160
|
+
finally:
|
|
1161
|
+
shutil.rmtree(STAGE_DIR, ignore_errors=True)
|
|
1162
|
+
finally:
|
|
1163
|
+
os.close(lock_fd)
|
|
1164
|
+
except OSError as error:
|
|
1165
|
+
raise IngestError(f"Cannot build the session database: {error}") from error
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def ingest(force: bool = False) -> dict[str, object]:
|
|
1169
|
+
"""Build or refresh the database; return a JSON-ready summary."""
|
|
1170
|
+
elapsed = build_database(force=force)
|
|
1171
|
+
if elapsed is None:
|
|
1172
|
+
return _cached()
|
|
1173
|
+
return {
|
|
1174
|
+
"status": "ingested",
|
|
1175
|
+
"database": DB_PATH,
|
|
1176
|
+
"schema_version": SCHEMA_VERSION,
|
|
1177
|
+
"elapsed_seconds": round(elapsed, 1),
|
|
1178
|
+
**_summary(),
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def _cached() -> dict[str, object]:
|
|
1183
|
+
age_minutes = (time.time() - os.path.getmtime(DB_PATH)) / 60
|
|
1184
|
+
return {
|
|
1185
|
+
"status": "cached",
|
|
1186
|
+
"database": DB_PATH,
|
|
1187
|
+
"schema_version": SCHEMA_VERSION,
|
|
1188
|
+
"age_minutes": round(age_minutes),
|
|
1189
|
+
"note": "database is newer than the one-hour cache; pass --force to re-ingest",
|
|
1190
|
+
}
|