cli-consumption 0.0.1__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,10 @@
1
+ """Analyze AI coding CLI usage without exporting conversation content."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("cli-consumption")
7
+ except PackageNotFoundError: # pragma: no cover - source tree without installation
8
+ __version__ = "0.0.0"
9
+
10
+ __all__ = ["__version__"]
@@ -0,0 +1,4 @@
1
+ from cli_consumption.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
@@ -0,0 +1,3 @@
1
+ from cli_consumption.adapters.codex import CodexAdapter
2
+
3
+ __all__ = ["CodexAdapter"]
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Protocol
5
+
6
+ from cli_consumption.models import Snapshot
7
+
8
+
9
+ class Adapter(Protocol):
10
+ """Contract implemented by every supported AI CLI."""
11
+
12
+ name: str
13
+
14
+ def collect(
15
+ self,
16
+ sources: list[tuple[str, Path]],
17
+ project_mappings: list[tuple[str, str]],
18
+ ) -> Snapshot: ...
@@ -0,0 +1,591 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import math
6
+ import re
7
+ import sqlite3
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from cli_consumption.models import TOKEN_FIELDS, Snapshot, empty_tokens
13
+
14
+ OUTSIDE_PROJECT = "outside-project"
15
+ TOOL_PATTERN = re.compile(r"(?:tools|collaboration)\.([A-Za-z][A-Za-z0-9_]*)\s*\(")
16
+ KNOWN_NESTED_TOOLS = {
17
+ "apply_patch",
18
+ "create_goal",
19
+ "exec_command",
20
+ "get_goal",
21
+ "image_gen__imagegen",
22
+ "list_mcp_resource_templates",
23
+ "list_mcp_resources",
24
+ "read_mcp_resource",
25
+ "update_goal",
26
+ "update_plan",
27
+ "view_image",
28
+ "wait",
29
+ "web__run",
30
+ "write_stdin",
31
+ }
32
+ WORK_ITEM_KINDS = {
33
+ "AgentMessage": "message",
34
+ "CollabAgentToolCall": "agent-coordination",
35
+ "CommandExecution": "command",
36
+ "ContextCompaction": "compaction",
37
+ "DynamicToolCall": "dynamic-tool",
38
+ "Extension": "extension",
39
+ "FileChange": "file-change",
40
+ "ImageView": "media",
41
+ "McpToolCall": "mcp-tool",
42
+ "Reasoning": "reasoning",
43
+ "SubAgentActivity": "subagent-activity",
44
+ "UserMessage": "user-message",
45
+ }
46
+ SAFE_DIMENSION = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:/+-]*")
47
+ MAX_BIGINT = 9_223_372_036_854_775_807
48
+
49
+
50
+ def parse_timestamp(value: str | None) -> datetime | None:
51
+ if not value:
52
+ return None
53
+ try:
54
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC)
55
+ except ValueError:
56
+ return None
57
+
58
+
59
+ def infer_project(
60
+ metadata: dict[str, Any], mappings: list[tuple[str, str]]
61
+ ) -> tuple[str, str]:
62
+ cwd = str(metadata.get("cwd") or "").rstrip("/\\")
63
+ normalized_cwd = cwd.replace("\\", "/")
64
+ for name, prefix in sorted(mappings, key=lambda item: len(item[1]), reverse=True):
65
+ normalized_prefix = prefix.replace("\\", "/").rstrip("/")
66
+ if normalized_cwd == normalized_prefix or normalized_cwd.startswith(
67
+ normalized_prefix + "/"
68
+ ):
69
+ return name, "mapping"
70
+ git = metadata.get("git")
71
+ if isinstance(git, dict):
72
+ repository = str(git.get("repository_url") or git.get("repository") or "")
73
+ slug = re.split(r"[/\\:]", repository.rstrip("/\\"))[-1]
74
+ if slug.endswith(".git"):
75
+ slug = slug[:-4]
76
+ if slug:
77
+ return slug, "git"
78
+ return OUTSIDE_PROJECT, "none"
79
+
80
+
81
+ def extract_tools(payload: dict[str, Any]) -> list[tuple[str, str]]:
82
+ outer_name = str(payload.get("name", "unknown"))
83
+ if outer_name != "exec":
84
+ return [(outer_name, outer_name)]
85
+ raw_input = payload.get("input", "")
86
+ if not isinstance(raw_input, str):
87
+ raw_input = json.dumps(raw_input, sort_keys=True)
88
+ nested = [
89
+ name
90
+ for name in TOOL_PATTERN.findall(raw_input)
91
+ if name in KNOWN_NESTED_TOOLS or name.startswith("mcp__")
92
+ ]
93
+ return [(outer_name, name) for name in nested] or [(outer_name, outer_name)]
94
+
95
+
96
+ class CodexAdapter:
97
+ """Read local Codex rollout metadata while excluding message content."""
98
+
99
+ name = "codex"
100
+
101
+ def collect(
102
+ self,
103
+ sources: list[tuple[str, Path]],
104
+ project_mappings: list[tuple[str, str]] | None = None,
105
+ ) -> Snapshot:
106
+ selected, duplicates, discovery_malformed = self._discover(sources)
107
+ snapshot = Snapshot(
108
+ provider=self.name,
109
+ duplicate_conversations=duplicates,
110
+ malformed_records=discovery_malformed,
111
+ )
112
+ mappings = project_mappings or []
113
+ for machine, path, event_count, digest in selected:
114
+ self._read_rollout(snapshot, machine, path, event_count, digest, mappings)
115
+ for machine, codex_home in sources:
116
+ snapshot.subagents.extend(
117
+ self._read_subagents(codex_home / "state_5.sqlite", machine)
118
+ )
119
+ return snapshot
120
+
121
+ def _read_subagents(
122
+ self, state_path: Path, source_machine: str
123
+ ) -> list[dict[str, Any]]:
124
+ if not state_path.is_file():
125
+ return []
126
+ connection = sqlite3.connect(f"file:{state_path}?mode=ro", uri=True)
127
+ connection.row_factory = sqlite3.Row
128
+ try:
129
+ rows = connection.execute(
130
+ """
131
+ SELECT e.parent_thread_id, e.child_thread_id, e.status,
132
+ t.created_at_ms, t.updated_at_ms, t.agent_nickname,
133
+ t.agent_role, t.tokens_used
134
+ FROM thread_spawn_edges e
135
+ LEFT JOIN threads t ON t.id = e.child_thread_id
136
+ ORDER BY t.created_at_ms, e.child_thread_id
137
+ """
138
+ ).fetchall()
139
+ except sqlite3.OperationalError:
140
+ return []
141
+ finally:
142
+ connection.close()
143
+ return [
144
+ {
145
+ "id": f"codex:{source_machine}:{row['child_thread_id']}",
146
+ "provider": self.name,
147
+ "source_machine": source_machine,
148
+ "parent_thread_id": str(row["parent_thread_id"]),
149
+ "child_thread_id": str(row["child_thread_id"]),
150
+ "status": str(row["status"] or "unknown"),
151
+ "created_at_ms": _integer_or_none(row["created_at_ms"]),
152
+ "updated_at_ms": _integer_or_none(row["updated_at_ms"]),
153
+ "agent_nickname": str(row["agent_nickname"] or ""),
154
+ "agent_role": str(row["agent_role"] or ""),
155
+ "tokens_used": _integer_or_none(row["tokens_used"]),
156
+ }
157
+ for row in rows
158
+ ]
159
+
160
+ def _discover(
161
+ self, sources: list[tuple[str, Path]]
162
+ ) -> tuple[list[tuple[str, Path, int, str]], int, int]:
163
+ selected: dict[str, tuple[str, Path, int, str]] = {}
164
+ duplicates = 0
165
+ malformed = 0
166
+ for machine, codex_home in sources:
167
+ sessions = codex_home / "sessions"
168
+ if not sessions.is_dir():
169
+ raise ValueError(f"Missing Codex sessions directory: {sessions}")
170
+ for path in sorted(sessions.rglob("*.jsonl")):
171
+ event_count = 0
172
+ conversation_id = ""
173
+ digest = hashlib.sha256()
174
+ with path.open("rb") as handle:
175
+ for raw_line in handle:
176
+ digest.update(raw_line)
177
+ try:
178
+ event = json.loads(raw_line)
179
+ except (json.JSONDecodeError, UnicodeDecodeError):
180
+ malformed += 1
181
+ continue
182
+ if not isinstance(event, dict):
183
+ malformed += 1
184
+ continue
185
+ event_count += 1
186
+ if event.get("type") == "session_meta":
187
+ conversation_id = str(
188
+ event.get("payload", {}).get("id", "")
189
+ )
190
+ conversation_id = conversation_id or path.stem
191
+ candidate = (machine, path, event_count, digest.hexdigest())
192
+ previous = selected.get(conversation_id)
193
+ if previous is None:
194
+ selected[conversation_id] = candidate
195
+ else:
196
+ duplicates += 1
197
+ if candidate[2:] > previous[2:]:
198
+ selected[conversation_id] = candidate
199
+ return list(selected.values()), duplicates, malformed
200
+
201
+ def _read_rollout(
202
+ self,
203
+ snapshot: Snapshot,
204
+ machine: str,
205
+ path: Path,
206
+ event_count: int,
207
+ digest: str,
208
+ mappings: list[tuple[str, str]],
209
+ ) -> None:
210
+ events: list[dict[str, Any]] = []
211
+ with path.open(encoding="utf-8") as handle:
212
+ for line in handle:
213
+ try:
214
+ event = json.loads(line)
215
+ except json.JSONDecodeError:
216
+ continue
217
+ if isinstance(event, dict):
218
+ events.append(event)
219
+
220
+ metadata: dict[str, Any] = next(
221
+ (
222
+ payload
223
+ for event in events
224
+ if event.get("type") == "session_meta"
225
+ and isinstance((payload := event.get("payload")), dict)
226
+ ),
227
+ {},
228
+ )
229
+ conversation_id = str(metadata.get("id") or path.stem)
230
+ record_id = f"codex:{conversation_id}"
231
+ project, project_source = infer_project(metadata, mappings)
232
+ timestamps = [
233
+ timestamp
234
+ for event in events
235
+ if (timestamp := parse_timestamp(event.get("timestamp"))) is not None
236
+ ]
237
+ started_at = min(timestamps, default=None)
238
+ ended_at = max(timestamps, default=None)
239
+ active_turn_id: str | None = None
240
+ active_model: str | None = None
241
+ turns: dict[str, dict[str, Any]] = {}
242
+ models: set[str] = set()
243
+ totals = empty_tokens()
244
+ call_sequence = 0
245
+ tool_sequence = 0
246
+ work_sequence = 0
247
+ compaction_sequence = 0
248
+ compactions = 0
249
+ setting_defaults: dict[str, str | int | None] = {
250
+ "model": None,
251
+ "effort": None,
252
+ "collaboration_mode": None,
253
+ "service_tier": None,
254
+ "context_window_tokens": None,
255
+ }
256
+ settings_by_turn: dict[str, dict[str, str | int | None]] = {}
257
+
258
+ for event in events:
259
+ timestamp = parse_timestamp(event.get("timestamp"))
260
+ payload = event.get("payload", {})
261
+ if not isinstance(payload, dict):
262
+ continue
263
+ event_type = event.get("type")
264
+ payload_type = payload.get("type")
265
+ if event_type == "compacted":
266
+ compactions += 1
267
+ compaction_sequence += 1
268
+ snapshot.compaction_events.append(
269
+ {
270
+ "id": f"{record_id}:compaction:{compaction_sequence}",
271
+ "conversation_id": record_id,
272
+ "turn_id": (
273
+ f"{record_id}:{active_turn_id}" if active_turn_id else None
274
+ ),
275
+ "sequence": compaction_sequence,
276
+ "timestamp": _iso(timestamp),
277
+ }
278
+ )
279
+ if event_type == "event_msg" and payload_type == "thread_settings_applied":
280
+ raw_settings = payload.get("thread_settings")
281
+ if isinstance(raw_settings, dict):
282
+ updates: dict[str, str | int | None] = {
283
+ "model": _safe_dimension(raw_settings.get("model"), 255),
284
+ "effort": _safe_dimension(
285
+ raw_settings.get("reasoning_effort"), 64
286
+ ),
287
+ "collaboration_mode": _collaboration_mode(
288
+ raw_settings.get("collaboration_mode")
289
+ ),
290
+ "service_tier": _safe_dimension(
291
+ raw_settings.get("service_tier"), 64
292
+ ),
293
+ }
294
+ _merge_present(setting_defaults, updates)
295
+ if active_turn_id and active_turn_id in settings_by_turn:
296
+ _merge_present(settings_by_turn[active_turn_id], updates)
297
+ if event_type == "turn_context":
298
+ active_turn_id = (
299
+ str(payload.get("turn_id") or active_turn_id or "") or None
300
+ )
301
+ active_model = (
302
+ _safe_dimension(payload.get("model"), 255) or active_model
303
+ )
304
+ if active_model:
305
+ models.add(active_model)
306
+ if active_turn_id:
307
+ settings_by_turn[active_turn_id] = {
308
+ **setting_defaults,
309
+ "model": active_model,
310
+ "effort": _safe_dimension(payload.get("effort"), 64)
311
+ or setting_defaults["effort"],
312
+ "collaboration_mode": _collaboration_mode(
313
+ payload.get("collaboration_mode")
314
+ )
315
+ or setting_defaults["collaboration_mode"],
316
+ }
317
+ if event_type == "event_msg" and payload_type == "task_started":
318
+ active_turn_id = str(payload.get("turn_id") or "") or None
319
+ if active_turn_id:
320
+ settings = settings_by_turn.setdefault(
321
+ active_turn_id, dict(setting_defaults)
322
+ )
323
+ settings["model"] = active_model or settings["model"]
324
+ context_window = _positive_integer_or_none(
325
+ payload.get("model_context_window")
326
+ )
327
+ if context_window is not None:
328
+ settings["context_window_tokens"] = context_window
329
+ turns[active_turn_id] = {
330
+ "id": f"{record_id}:{active_turn_id}",
331
+ "conversation_id": record_id,
332
+ "external_id": active_turn_id,
333
+ "started_at": _iso(timestamp),
334
+ "ended_at": None,
335
+ "status": "in-progress",
336
+ "duration_ms": None,
337
+ "time_to_first_token_ms": None,
338
+ "model_calls": 0,
339
+ "tool_calls": 0,
340
+ **empty_tokens(),
341
+ }
342
+ continue
343
+ if event_type == "event_msg" and payload_type in {
344
+ "task_complete",
345
+ "turn_aborted",
346
+ }:
347
+ turn_id = str(payload.get("turn_id") or active_turn_id or "")
348
+ if turn_id in turns:
349
+ turns[turn_id].update(
350
+ ended_at=_iso(timestamp),
351
+ status="completed"
352
+ if payload_type == "task_complete"
353
+ else "aborted",
354
+ duration_ms=_integer_or_none(payload.get("duration_ms")),
355
+ time_to_first_token_ms=_integer_or_none(
356
+ payload.get("time_to_first_token_ms")
357
+ ),
358
+ )
359
+ active_turn_id = None
360
+ continue
361
+ if event_type == "event_msg" and payload_type == "item_completed":
362
+ item = payload.get("item")
363
+ if not isinstance(item, dict):
364
+ continue
365
+ work_sequence += 1
366
+ started_at_ms = _integer_or_none(payload.get("started_at_ms"))
367
+ completed_at_ms = _integer_or_none(payload.get("completed_at_ms"))
368
+ turn_id = str(payload.get("turn_id") or active_turn_id or "") or None
369
+ snapshot.work_items.append(
370
+ {
371
+ "id": f"{record_id}:work:{work_sequence}",
372
+ "conversation_id": record_id,
373
+ "turn_id": f"{record_id}:{turn_id}" if turn_id else None,
374
+ "sequence": work_sequence,
375
+ "kind": WORK_ITEM_KINDS.get(
376
+ str(item.get("type") or ""), "other"
377
+ ),
378
+ "tool_name": _safe_dimension(item.get("tool"), 512),
379
+ "started_at_ms": started_at_ms,
380
+ "completed_at_ms": completed_at_ms,
381
+ "duration_ms": _interval_duration(
382
+ started_at_ms, completed_at_ms
383
+ ),
384
+ "status": _work_item_status(item),
385
+ }
386
+ )
387
+ continue
388
+ if event_type == "event_msg" and payload_type == "token_count":
389
+ info = payload.get("info")
390
+ usage = info.get("last_token_usage") if isinstance(info, dict) else None
391
+ if not isinstance(usage, dict):
392
+ continue
393
+ call_sequence += 1
394
+ tokens = {
395
+ field: _nonnegative_integer(usage.get(field))
396
+ for field in TOKEN_FIELDS
397
+ }
398
+ tokens.update(_derived_tokens(tokens))
399
+ for field, value in tokens.items():
400
+ totals[field] += value
401
+ turn = turns.get(active_turn_id or "")
402
+ if turn:
403
+ turn["model_calls"] += 1
404
+ for field, value in tokens.items():
405
+ turn[field] += value
406
+ snapshot.model_calls.append(
407
+ {
408
+ "id": f"{record_id}:model:{call_sequence}",
409
+ "conversation_id": record_id,
410
+ "turn_id": turn["id"] if turn else None,
411
+ "sequence": call_sequence,
412
+ "timestamp": _iso(timestamp),
413
+ "model": active_model or "unknown",
414
+ **tokens,
415
+ }
416
+ )
417
+ context_window = _positive_integer_or_none(
418
+ info.get("model_context_window")
419
+ )
420
+ if context_window is not None:
421
+ if active_turn_id and active_turn_id in settings_by_turn:
422
+ settings_by_turn[active_turn_id]["context_window_tokens"] = (
423
+ context_window
424
+ )
425
+ snapshot.context_samples.append(
426
+ {
427
+ "id": f"{record_id}:context:{call_sequence}",
428
+ "conversation_id": record_id,
429
+ "turn_id": turn["id"] if turn else None,
430
+ "sequence": call_sequence,
431
+ "timestamp": _iso(timestamp),
432
+ "input_tokens": max(0, tokens["input_tokens"]),
433
+ "context_window_tokens": context_window,
434
+ }
435
+ )
436
+ continue
437
+ if event_type == "response_item" and payload_type in {
438
+ "custom_tool_call",
439
+ "function_call",
440
+ }:
441
+ for outer_name, tool_name in extract_tools(payload):
442
+ tool_sequence += 1
443
+ turn = turns.get(active_turn_id or "")
444
+ if turn:
445
+ turn["tool_calls"] += 1
446
+ snapshot.tool_calls.append(
447
+ {
448
+ "id": f"{record_id}:tool:{tool_sequence}",
449
+ "conversation_id": record_id,
450
+ "turn_id": turn["id"] if turn else None,
451
+ "sequence": tool_sequence,
452
+ "timestamp": _iso(timestamp),
453
+ "tool_name": tool_name,
454
+ "outer_tool_name": outer_name,
455
+ }
456
+ )
457
+
458
+ for turn in turns.values():
459
+ if turn["ended_at"] is None:
460
+ turn["ended_at"] = _iso(ended_at)
461
+ snapshot.turns.append(turn)
462
+ external_turn_id = str(turn["external_id"])
463
+ settings = settings_by_turn.get(external_turn_id, setting_defaults)
464
+ snapshot.turn_settings.append(
465
+ {
466
+ "id": f"{record_id}:settings:{external_turn_id}",
467
+ "conversation_id": record_id,
468
+ "turn_id": str(turn["id"]),
469
+ "model": settings["model"],
470
+ "effort": settings["effort"],
471
+ "collaboration_mode": settings["collaboration_mode"],
472
+ "service_tier": settings["service_tier"],
473
+ "context_window_tokens": settings["context_window_tokens"],
474
+ }
475
+ )
476
+ snapshot.conversations.append(
477
+ {
478
+ "id": record_id,
479
+ "provider": self.name,
480
+ "external_id": conversation_id,
481
+ "source_machine": machine,
482
+ "project": project,
483
+ "project_source": project_source,
484
+ "started_at": _iso(started_at),
485
+ "ended_at": _iso(ended_at),
486
+ "duration_seconds": (
487
+ (ended_at - started_at).total_seconds()
488
+ if started_at is not None and ended_at is not None
489
+ else None
490
+ ),
491
+ "source": str(metadata.get("source") or ""),
492
+ "models": sorted(models),
493
+ "iterations": len(turns),
494
+ "model_calls": call_sequence,
495
+ "tool_calls": tool_sequence,
496
+ "compactions": compactions,
497
+ "event_count": event_count,
498
+ "content_hash": digest,
499
+ **totals,
500
+ }
501
+ )
502
+
503
+
504
+ def _derived_tokens(tokens: dict[str, int]) -> dict[str, int]:
505
+ return {
506
+ "uncached_input_tokens": max(
507
+ 0,
508
+ tokens["input_tokens"]
509
+ - tokens["cached_input_tokens"]
510
+ - tokens["cache_write_input_tokens"],
511
+ ),
512
+ "visible_output_tokens": max(
513
+ 0, tokens["output_tokens"] - tokens["reasoning_output_tokens"]
514
+ ),
515
+ "unattributed_tokens": max(
516
+ 0,
517
+ tokens["total_tokens"] - tokens["input_tokens"] - tokens["output_tokens"],
518
+ ),
519
+ }
520
+
521
+
522
+ def _safe_dimension(value: object, maximum: int) -> str | None:
523
+ if not isinstance(value, str):
524
+ return None
525
+ normalized = value.strip()
526
+ if not normalized or len(normalized) > maximum:
527
+ return None
528
+ return normalized if SAFE_DIMENSION.fullmatch(normalized) else None
529
+
530
+
531
+ def _merge_present(
532
+ target: dict[str, str | int | None], updates: dict[str, str | int | None]
533
+ ) -> None:
534
+ target.update((key, value) for key, value in updates.items() if value is not None)
535
+
536
+
537
+ def _collaboration_mode(value: object) -> str | None:
538
+ if isinstance(value, dict):
539
+ value = value.get("mode")
540
+ return _safe_dimension(value, 64)
541
+
542
+
543
+ def _work_item_status(item: dict[str, Any]) -> str:
544
+ exit_code = item.get("exit_code")
545
+ if (
546
+ isinstance(exit_code, int)
547
+ and not isinstance(exit_code, bool)
548
+ and exit_code != 0
549
+ ):
550
+ return "failed"
551
+ if item.get("success") is False or bool(item.get("error")):
552
+ return "failed"
553
+ status = str(item.get("status") or "").lower().replace("_", "-")
554
+ if status in {"completed", "success", "succeeded"}:
555
+ return "completed"
556
+ if status in {"failed", "error", "errored"}:
557
+ return "failed"
558
+ if status in {"in-progress", "running", "pending"}:
559
+ return "in-progress"
560
+ return "unknown"
561
+
562
+
563
+ def _interval_duration(
564
+ started_at_ms: int | None, completed_at_ms: int | None
565
+ ) -> int | None:
566
+ if started_at_ms is None or completed_at_ms is None:
567
+ return None
568
+ return max(0, completed_at_ms - started_at_ms)
569
+
570
+
571
+ def _positive_integer_or_none(value: object) -> int | None:
572
+ parsed = _integer_or_none(value)
573
+ return parsed if parsed is not None and parsed > 0 else None
574
+
575
+
576
+ def _iso(value: datetime | None) -> str | None:
577
+ return value.isoformat() if value else None
578
+
579
+
580
+ def _integer_or_none(value: object) -> int | None:
581
+ if isinstance(value, bool) or not isinstance(value, int | float):
582
+ return None
583
+ if isinstance(value, float) and not math.isfinite(value):
584
+ return None
585
+ parsed = int(value)
586
+ return parsed if -MAX_BIGINT <= parsed <= MAX_BIGINT else None
587
+
588
+
589
+ def _nonnegative_integer(value: object) -> int:
590
+ parsed = _integer_or_none(value)
591
+ return max(0, parsed or 0)