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.
@@ -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
+ }