cli-consumption 0.0.6__tar.gz → 0.0.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/.agents/skills/yolo/SKILL.md +7 -5
  2. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/PKG-INFO +33 -3
  3. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/README.md +32 -2
  4. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/docs/architecture.md +3 -3
  5. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/docs/provider-support.md +35 -0
  6. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/docs/roadmap.md +1 -0
  7. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/pyproject.toml +1 -1
  8. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/src/cli_consumption/adapters/__init__.py +4 -0
  9. cli_consumption-0.0.8/src/cli_consumption/adapters/aider.py +347 -0
  10. cli_consumption-0.0.8/src/cli_consumption/adapters/gemini.py +397 -0
  11. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/src/cli_consumption/cli.py +6 -0
  12. cli_consumption-0.0.8/tests/test_aider_adapter.py +189 -0
  13. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/tests/test_cli.py +58 -0
  14. cli_consumption-0.0.8/tests/test_gemini_adapter.py +275 -0
  15. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/uv.lock +1 -1
  16. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/.agents/skills/add-cli-adapter/SKILL.md +0 -0
  17. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/.agents/skills/add-cli-adapter/agents/openai.yaml +0 -0
  18. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/.agents/skills/audit-usage-privacy/SKILL.md +0 -0
  19. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/.agents/skills/audit-usage-privacy/agents/openai.yaml +0 -0
  20. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/.agents/skills/evolve-storage-schema/SKILL.md +0 -0
  21. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/.agents/skills/evolve-storage-schema/agents/openai.yaml +0 -0
  22. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/.agents/skills/yeet-github/SKILL.md +0 -0
  23. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/.agents/skills/yeet-github/agents/openai.yaml +0 -0
  24. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/.agents/skills/yolo/agents/openai.yaml +0 -0
  25. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/.github/workflows/ci.yml +0 -0
  26. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/.github/workflows/release.yaml +0 -0
  27. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/.gitignore +0 -0
  28. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/.pre-commit-config.yaml +0 -0
  29. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/.python-version +0 -0
  30. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/AGENTS.md +0 -0
  31. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/CONTRIBUTING.md +0 -0
  32. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/LICENSE +0 -0
  33. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/NOTICE +0 -0
  34. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/docs/privacy.md +0 -0
  35. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/src/cli_consumption/__init__.py +0 -0
  36. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/src/cli_consumption/__main__.py +0 -0
  37. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/src/cli_consumption/adapters/base.py +0 -0
  38. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/src/cli_consumption/adapters/claude.py +0 -0
  39. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/src/cli_consumption/adapters/codex.py +0 -0
  40. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/src/cli_consumption/adapters/kilo.py +0 -0
  41. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/src/cli_consumption/adapters/opencode.py +0 -0
  42. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/src/cli_consumption/adapters/pi.py +0 -0
  43. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/src/cli_consumption/api.py +0 -0
  44. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/src/cli_consumption/dashboard.py +0 -0
  45. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/src/cli_consumption/exporting.py +0 -0
  46. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/src/cli_consumption/models.py +0 -0
  47. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/src/cli_consumption/py.typed +0 -0
  48. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/src/cli_consumption/storage.py +0 -0
  49. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/src/cli_consumption/sync.py +0 -0
  50. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/tests/conftest.py +0 -0
  51. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/tests/test_api.py +0 -0
  52. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/tests/test_claude_adapter.py +0 -0
  53. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/tests/test_codex_adapter.py +0 -0
  54. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/tests/test_kilo_adapter.py +0 -0
  55. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/tests/test_opencode_adapter.py +0 -0
  56. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/tests/test_pi_adapter.py +0 -0
  57. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/tests/test_storage_and_exports.py +0 -0
  58. {cli_consumption-0.0.6 → cli_consumption-0.0.8}/tests/test_sync.py +0 -0
@@ -10,9 +10,11 @@ Run each phase in order from the repository root. The implicit pull request is t
10
10
  ## 1. Publish
11
11
 
12
12
  1. Read all repository instructions and the product specification referenced by `AGENTS.md`.
13
- 2. Load and follow `$yeet-github` in full to inspect, validate, commit, push, and open a draft pull request, except do not request its approval before pushing or publishing: the explicit `$yolo` invocation already provides that approval.
14
- 3. Retain the exact source and target branches, pushed SHA, pull-request number, and URL.
15
- 4. Stop after any failure or ambiguous external result. Never merge or clean up after a partial failure.
13
+ 2. Read `.agents/local-environment.md` when present and follow its GitHub authentication instructions.
14
+ 3. Load and follow `$yeet-github` in full to inspect, validate, commit, push, and open a draft pull request, except do not request its approval before pushing or publishing: the explicit `$yolo` invocation already provides that approval.
15
+ 4. Use `gh` directly for GitHub API and pull-request operations. When Git transport is unavoidable and the remote is HTTPS, authenticate it explicitly through the current `gh` session, for example `git -c credential.helper='!gh auth git-credential' push -u origin <source-branch>`; never rely on ambient HTTPS Git credentials.
16
+ 5. Retain the exact source and target branches, pushed SHA, pull-request number, and URL.
17
+ 6. Stop after any failure or ambiguous external result. Never merge or clean up after a partial failure.
16
18
 
17
19
  ## 2. Merge
18
20
 
@@ -29,10 +31,10 @@ Run each phase in order from the repository root. The implicit pull request is t
29
31
 
30
32
  1. Reconfirm the merged state and capture the merge commit SHA.
31
33
  2. Refuse cleanup if new tracked, staged, or untracked work appeared after the source commit.
32
- 3. Run `git fetch --prune origin`.
34
+ 3. Fetch with the authentication method required by `.agents/local-environment.md`; for an HTTPS GitHub remote, use `git -c credential.helper='!gh auth git-credential' fetch --prune origin`.
33
35
  4. Switch to the target branch and update it only by fast-forward with `git switch <target-branch>` and `git pull --ff-only origin <target-branch>`.
34
36
  5. Delete the exact local source branch if it remains. `git branch -D <source-branch>` is allowed only after verified squash merge.
35
- 6. If the exact unprotected remote source branch remains, delete it with `git push origin --delete <source-branch>` without requesting additional approval.
37
+ 6. If the exact unprotected remote source branch remains, delete it without requesting additional approval. For an HTTPS GitHub remote, use `git -c credential.helper='!gh auth git-credential' push origin --delete <source-branch>`.
36
38
  7. Run `git status --short --branch` and report the final state.
37
39
 
38
40
  ## Guardrails
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cli-consumption
3
- Version: 0.0.6
3
+ Version: 0.0.8
4
4
  Summary: Analyze and consolidate AI coding CLI consumption across machines.
5
5
  Project-URL: Homepage, https://github.com/Guillaume-Lombardo/cli-consumption
6
6
  Project-URL: Documentation, https://github.com/Guillaume-Lombardo/cli-consumption#readme
@@ -33,8 +33,8 @@ models, tokens, tools, conversations, and turns. It can analyze one workstation,
33
33
  consolidate copied data from several machines, or send metadata-only snapshots to a
34
34
  central collector.
35
35
 
36
- Codex, Kilo Code, Pi, OpenCode, and the core Claude Code local transcript format are
37
- supported.
36
+ Codex, Aider, Gemini CLI, Kilo Code, Pi, OpenCode, and the core Claude Code
37
+ local transcript format are supported.
38
38
 
39
39
  The collector deliberately excludes prompts, responses, tool arguments, and
40
40
  credentials. See [Privacy](docs/privacy.md) before sharing a database or export.
@@ -120,6 +120,20 @@ Select Pi to read `~/.pi/agent/sessions/` instead:
120
120
  uv run cli-consumption collect --provider pi --database usage.sqlite
121
121
  ```
122
122
 
123
+ Select Gemini CLI to read `~/.gemini/tmp/*/chats/` instead:
124
+
125
+ ```bash
126
+ uv run cli-consumption collect --provider gemini --database usage.sqlite
127
+ ```
128
+
129
+ Select Aider to read a local analytics log configured at
130
+ `~/.aider/analytics.jsonl` instead:
131
+
132
+ ```bash
133
+ export AIDER_ANALYTICS_LOG="$HOME/.aider/analytics.jsonl"
134
+ uv run cli-consumption collect --provider aider --database usage.sqlite
135
+ ```
136
+
123
137
  Use `all` to detect and collect every supported provider present on the machine:
124
138
 
125
139
  ```bash
@@ -184,6 +198,22 @@ uv run cli-consumption collect --provider pi \
184
198
  --source laptop=/data/pi/laptop
185
199
  ```
186
200
 
201
+ Copied Gemini CLI sources point to the configuration directory containing `tmp/`:
202
+
203
+ ```bash
204
+ uv run cli-consumption collect --provider gemini \
205
+ --source desktop=/data/gemini/desktop \
206
+ --source laptop=/data/gemini/laptop
207
+ ```
208
+
209
+ Copied Aider sources point to a directory containing `analytics.jsonl`:
210
+
211
+ ```bash
212
+ uv run cli-consumption collect --provider aider \
213
+ --source desktop=/data/aider/desktop \
214
+ --source laptop=/data/aider/laptop
215
+ ```
216
+
187
217
  ## SQLite and PostgreSQL
188
218
 
189
219
  A file path selects SQLite. A SQLAlchemy URL selects PostgreSQL:
@@ -5,8 +5,8 @@ models, tokens, tools, conversations, and turns. It can analyze one workstation,
5
5
  consolidate copied data from several machines, or send metadata-only snapshots to a
6
6
  central collector.
7
7
 
8
- Codex, Kilo Code, Pi, OpenCode, and the core Claude Code local transcript format are
9
- supported.
8
+ Codex, Aider, Gemini CLI, Kilo Code, Pi, OpenCode, and the core Claude Code
9
+ local transcript format are supported.
10
10
 
11
11
  The collector deliberately excludes prompts, responses, tool arguments, and
12
12
  credentials. See [Privacy](docs/privacy.md) before sharing a database or export.
@@ -92,6 +92,20 @@ Select Pi to read `~/.pi/agent/sessions/` instead:
92
92
  uv run cli-consumption collect --provider pi --database usage.sqlite
93
93
  ```
94
94
 
95
+ Select Gemini CLI to read `~/.gemini/tmp/*/chats/` instead:
96
+
97
+ ```bash
98
+ uv run cli-consumption collect --provider gemini --database usage.sqlite
99
+ ```
100
+
101
+ Select Aider to read a local analytics log configured at
102
+ `~/.aider/analytics.jsonl` instead:
103
+
104
+ ```bash
105
+ export AIDER_ANALYTICS_LOG="$HOME/.aider/analytics.jsonl"
106
+ uv run cli-consumption collect --provider aider --database usage.sqlite
107
+ ```
108
+
95
109
  Use `all` to detect and collect every supported provider present on the machine:
96
110
 
97
111
  ```bash
@@ -156,6 +170,22 @@ uv run cli-consumption collect --provider pi \
156
170
  --source laptop=/data/pi/laptop
157
171
  ```
158
172
 
173
+ Copied Gemini CLI sources point to the configuration directory containing `tmp/`:
174
+
175
+ ```bash
176
+ uv run cli-consumption collect --provider gemini \
177
+ --source desktop=/data/gemini/desktop \
178
+ --source laptop=/data/gemini/laptop
179
+ ```
180
+
181
+ Copied Aider sources point to a directory containing `analytics.jsonl`:
182
+
183
+ ```bash
184
+ uv run cli-consumption collect --provider aider \
185
+ --source desktop=/data/aider/desktop \
186
+ --source laptop=/data/aider/laptop
187
+ ```
188
+
159
189
  ## SQLite and PostgreSQL
160
190
 
161
191
  A file path selects SQLite. A SQLAlchemy URL selects PostgreSQL:
@@ -16,9 +16,9 @@ provider files -> adapter -> metadata-only snapshot -> SQL storage -> dashboard/
16
16
 
17
17
  - `adapters`: parse a CLI's local data into conversations, turns, model calls, tool
18
18
  calls, context-pressure samples, bounded turn settings, compactions, and content-free
19
- work-item intervals. Codex exposes the complete analytics contract; Claude Code,
20
- Kilo Code, OpenCode, and Pi expose the core dimensions available in their local
21
- stores.
19
+ work-item intervals. Codex exposes the complete analytics contract; Aider, Claude
20
+ Code, Gemini CLI, Kilo Code, OpenCode, and Pi expose the core dimensions available in
21
+ their local stores.
22
22
  - `models`: define the transport boundary shared by offline and API ingestion.
23
23
  - `storage`: owns the normalized schema, idempotent replacement rules, SQLite, and
24
24
  PostgreSQL engine creation.
@@ -2,8 +2,10 @@
2
2
 
3
3
  | Provider | Status | Initial source |
4
4
  | --- | --- | --- |
5
+ | Aider | Supported (core) | Opt-in local analytics JSONL |
5
6
  | Codex | Supported | Local rollout JSONL and optional metadata-only subagent state |
6
7
  | Claude Code | Supported (core) | Local project transcript JSONL |
8
+ | Gemini CLI | Supported (core) | Local automatic chat history JSON and JSONL |
7
9
  | Kilo Code | Supported (core) | Local SQLite session store |
8
10
  | OpenCode | Supported (core) | Local SQLite v2 session store |
9
11
  | Pi | Supported (core) | Local session JSONL v1-v3 |
@@ -36,6 +38,23 @@ is not billing data. This first increment does not collect subagent transcripts,
36
38
  context-window sizes, effort/service-tier settings, TTFT, provider-reported duration,
37
39
  or technical work-item intervals.
38
40
 
41
+ Gemini CLI reads automatic session history from
42
+ `~/.gemini/tmp/<project-hash>/chats/session-*.jsonl` and the legacy `.json` form. It
43
+ replays append-only message updates, metadata checkpoints, and rewinds before extracting
44
+ user turns, Gemini model calls, token usage, and tool names. Prompts, responses, thoughts,
45
+ summaries, memory scratchpads, tool arguments/results/status details, directories, and
46
+ project hashes are discarded.
47
+
48
+ Gemini reports prompt, cached prompt, visible candidate, thought, tool-prompt, and total
49
+ token counters. Normalized input is the prompt count, cached input is its bounded subset,
50
+ and normalized output combines candidate and thought tokens. Provider totals above the
51
+ attributed input/output sum remain unattributed; tool-prompt tokens are treated as a
52
+ prompt subset rather than added twice. This adapter does not derive a project name from
53
+ Gemini's one-way project hash, collect nested subagent sessions, compaction timestamps,
54
+ context-window sizes, configuration labels, tool outcomes, or provider-reported
55
+ durations. Gemini CLI's internal history schema can change without notice, and local
56
+ token events are not billing data.
57
+
39
58
  OpenCode reads `opencode.db` from its XDG data directory (normally
40
59
  `~/.local/share/opencode/`). It extracts v2 session messages, model references, token
41
60
  usage, tool names, and compaction timestamps while discarding message text, reasoning,
@@ -80,6 +99,22 @@ session directories automatically, context-window sizes, costs, branch relations
80
99
  or provider-reported durations. Pi's JSONL schema can change without notice, and its
81
100
  local token events are not billing data.
82
101
 
102
+ Aider reads an explicitly configured local analytics log named `analytics.jsonl` (for
103
+ example, `AIDER_ANALYTICS_LOG=~/.aider/analytics.jsonl`). It groups events from
104
+ `launched` through `exit`, extracts message-send attempts, model identifiers, and
105
+ prompt/completion/total token counters, and discards the analytics user UUID, costs,
106
+ exception text, command events, edit formats, system properties, and arbitrary event
107
+ properties. Conversation identifiers are stable hashes of the transient UUID and
108
+ launch time; the UUID itself is never emitted.
109
+
110
+ Aider analytics do not expose prompts or responses, but logging is not enabled at a
111
+ fixed location by default. The adapter cannot attribute projects, tool calls, cached
112
+ or reasoning tokens, compactions, context windows, or provider-reported durations.
113
+ Turns represent Aider message-send attempts because the log has no durable user-turn
114
+ identifier. Unknown custom model names may already be represented as
115
+ `provider/REDACTED` by Aider. Its analytics event schema can change without notice,
116
+ and local token events are not billing data.
117
+
83
118
  Provider formats can change without notice. Unknown fields are ignored; malformed
84
119
  provider records are counted and skipped. Compatibility fixes should add a fixture for
85
120
  both the old and new format whenever possible.
@@ -6,6 +6,7 @@
6
6
  - Core Claude Code local transcript collector
7
7
  - Core Pi local session collector
8
8
  - Core Kilo Code local SQLite collector
9
+ - Core Gemini CLI local chat-history collector
9
10
  - Offline multi-machine deduplication
10
11
  - SQLite and PostgreSQL storage
11
12
  - Metadata-only central collection API
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cli-consumption"
7
- version = "0.0.6"
7
+ version = "0.0.8"
8
8
  description = "Analyze and consolidate AI coding CLI consumption across machines."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -1,12 +1,16 @@
1
+ from cli_consumption.adapters.aider import AiderAdapter
1
2
  from cli_consumption.adapters.claude import ClaudeAdapter
2
3
  from cli_consumption.adapters.codex import CodexAdapter
4
+ from cli_consumption.adapters.gemini import GeminiAdapter
3
5
  from cli_consumption.adapters.kilo import KiloAdapter
4
6
  from cli_consumption.adapters.opencode import OpenCodeAdapter
5
7
  from cli_consumption.adapters.pi import PiAdapter
6
8
 
7
9
  __all__ = [
10
+ "AiderAdapter",
8
11
  "ClaudeAdapter",
9
12
  "CodexAdapter",
13
+ "GeminiAdapter",
10
14
  "KiloAdapter",
11
15
  "OpenCodeAdapter",
12
16
  "PiAdapter",
@@ -0,0 +1,347 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import math
6
+ import re
7
+ from dataclasses import dataclass
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from cli_consumption.models import Snapshot, empty_tokens
13
+
14
+ MAX_BIGINT = 9_223_372_036_854_775_807
15
+ SAFE_LABEL = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:/+-]*")
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class _Candidate:
20
+ machine: str
21
+ external_id: str
22
+ events: list[dict[str, Any]]
23
+ digest: str
24
+
25
+
26
+ class AiderAdapter:
27
+ """Read Aider analytics logs while discarding arbitrary event properties."""
28
+
29
+ name = "aider"
30
+
31
+ def collect(
32
+ self,
33
+ sources: list[tuple[str, Path]],
34
+ project_mappings: list[tuple[str, str]] | None = None,
35
+ ) -> Snapshot:
36
+ del project_mappings # Aider analytics do not record a project path.
37
+ selected: dict[str, _Candidate] = {}
38
+ duplicates = malformed = 0
39
+ for machine, home in sources:
40
+ path = home / "analytics.jsonl"
41
+ if not path.is_file():
42
+ raise ValueError(f"Missing Aider analytics log: {path}")
43
+ sessions, invalid = _read_sessions(path)
44
+ malformed += invalid
45
+ for external_id, events, digest in sessions:
46
+ candidate = _Candidate(machine, external_id, events, digest)
47
+ previous = selected.get(external_id)
48
+ if previous is None:
49
+ selected[external_id] = candidate
50
+ continue
51
+ duplicates += 1
52
+ if _rank(candidate) > _rank(previous):
53
+ selected[external_id] = candidate
54
+
55
+ snapshot = Snapshot(
56
+ provider=self.name,
57
+ duplicate_conversations=duplicates,
58
+ malformed_records=malformed,
59
+ )
60
+ for candidate in sorted(selected.values(), key=lambda item: item.external_id):
61
+ self._normalize(snapshot, candidate)
62
+ return snapshot
63
+
64
+ def _normalize(self, snapshot: Snapshot, source: _Candidate) -> None:
65
+ conversation_id = f"aider:{source.external_id}"
66
+ timestamps = [
67
+ timestamp
68
+ for event in source.events
69
+ if (timestamp := _timestamp(event.get("time"))) is not None
70
+ ]
71
+ turns: list[dict[str, Any]] = []
72
+ turn_models: list[set[str]] = []
73
+ active: int | None = None
74
+ calls: list[tuple[int | None, datetime | None, str, dict[str, int]]] = []
75
+
76
+ for event in source.events:
77
+ name = event.get("event")
78
+ timestamp = _timestamp(event.get("time"))
79
+ if name == "message_send_starting":
80
+ if active is not None:
81
+ _finish_turn(turns[active], timestamp, "aborted")
82
+ active = len(turns)
83
+ turns.append(_new_turn(conversation_id, active + 1, timestamp))
84
+ turn_models.append(set())
85
+ continue
86
+ if name == "message_send_exception":
87
+ if active is not None:
88
+ _finish_turn(turns[active], timestamp, "aborted")
89
+ active = None
90
+ continue
91
+ if name == "exit":
92
+ if active is not None:
93
+ _finish_turn(turns[active], timestamp, "aborted")
94
+ active = None
95
+ continue
96
+ if name != "message_send":
97
+ continue
98
+
99
+ properties = event.get("properties")
100
+ properties = properties if isinstance(properties, dict) else {}
101
+ model = _label(properties.get("main_model"), 255) or "unknown"
102
+ tokens = _usage(properties)
103
+ calls.append((active, timestamp, model, tokens))
104
+ if active is not None:
105
+ _finish_turn(turns[active], timestamp, "completed")
106
+ turn_models[active].add(model)
107
+ active = None
108
+
109
+ ended_at = max(timestamps, default=None)
110
+ if active is not None:
111
+ _finish_turn(turns[active], ended_at, "in-progress")
112
+
113
+ totals = empty_tokens()
114
+ models: set[str] = set()
115
+ for sequence, (turn_index, timestamp, model, tokens) in enumerate(calls, 1):
116
+ models.add(model)
117
+ _add_tokens(totals, tokens)
118
+ turn = turns[turn_index] if turn_index is not None else None
119
+ if turn is not None:
120
+ turn["model_calls"] += 1
121
+ _add_tokens(turn, tokens)
122
+ snapshot.model_calls.append(
123
+ {
124
+ "id": f"{conversation_id}:model:{sequence}",
125
+ "conversation_id": conversation_id,
126
+ "turn_id": turn["id"] if turn else None,
127
+ "sequence": sequence,
128
+ "timestamp": _iso(timestamp),
129
+ "model": model,
130
+ **tokens,
131
+ }
132
+ )
133
+
134
+ for index, turn in enumerate(turns):
135
+ snapshot.turns.append(turn)
136
+ observed_models = turn_models[index]
137
+ snapshot.turn_settings.append(
138
+ {
139
+ "id": f"{conversation_id}:settings:{index + 1}",
140
+ "conversation_id": conversation_id,
141
+ "turn_id": turn["id"],
142
+ "model": next(iter(observed_models))
143
+ if len(observed_models) == 1
144
+ else None,
145
+ "effort": None,
146
+ "collaboration_mode": None,
147
+ "service_tier": None,
148
+ "context_window_tokens": None,
149
+ }
150
+ )
151
+
152
+ started_at = min(timestamps, default=None)
153
+ snapshot.conversations.append(
154
+ {
155
+ "id": conversation_id,
156
+ "provider": self.name,
157
+ "external_id": source.external_id,
158
+ "source_machine": source.machine,
159
+ "project": "outside-project",
160
+ "project_source": "none",
161
+ "started_at": _iso(started_at),
162
+ "ended_at": _iso(ended_at),
163
+ "duration_seconds": (
164
+ (ended_at - started_at).total_seconds()
165
+ if started_at and ended_at
166
+ else None
167
+ ),
168
+ "source": "local-analytics-jsonl",
169
+ "models": sorted(models),
170
+ "iterations": len(turns),
171
+ "model_calls": len(calls),
172
+ "tool_calls": 0,
173
+ "compactions": 0,
174
+ "event_count": len(source.events),
175
+ "content_hash": source.digest,
176
+ **totals,
177
+ }
178
+ )
179
+
180
+
181
+ def _read_sessions(
182
+ path: Path,
183
+ ) -> tuple[list[tuple[str, list[dict[str, Any]], str]], int]:
184
+ sessions: list[tuple[str, list[dict[str, Any]], str]] = []
185
+ active: list[dict[str, Any]] | None = None
186
+ malformed = 0
187
+ ordinals: dict[tuple[str, int], int] = {}
188
+ with path.open("rb") as handle:
189
+ for line in handle:
190
+ if not line.strip():
191
+ continue
192
+ try:
193
+ event = json.loads(line)
194
+ except (json.JSONDecodeError, UnicodeDecodeError):
195
+ malformed += 1
196
+ continue
197
+ if not isinstance(event, dict) or not isinstance(event.get("event"), str):
198
+ malformed += 1
199
+ continue
200
+ if _epoch(event.get("time")) is None:
201
+ malformed += 1
202
+ continue
203
+ if event["event"] == "launched":
204
+ if active:
205
+ sessions.append(_session(active, ordinals))
206
+ active = [event]
207
+ continue
208
+ if active is None:
209
+ continue
210
+ active.append(event)
211
+ if event["event"] == "exit":
212
+ sessions.append(_session(active, ordinals))
213
+ active = None
214
+ if active:
215
+ sessions.append(_session(active, ordinals))
216
+ return sessions, malformed
217
+
218
+
219
+ def _session(
220
+ events: list[dict[str, Any]], ordinals: dict[tuple[str, int], int]
221
+ ) -> tuple[str, list[dict[str, Any]], str]:
222
+ launched = events[0]
223
+ user_id = launched.get("user_id")
224
+ user_id = user_id if isinstance(user_id, str) else "anonymous"
225
+ started = _epoch(launched.get("time")) or 0
226
+ key = (user_id, started)
227
+ ordinal = ordinals.get(key, 0) + 1
228
+ ordinals[key] = ordinal
229
+ stable = hashlib.sha256(f"{user_id}\0{started}\0{ordinal}".encode()).hexdigest()[
230
+ :32
231
+ ]
232
+ approved = [
233
+ {
234
+ "event": event.get("event"),
235
+ "time": _epoch(event.get("time")),
236
+ "properties": _approved_properties(event),
237
+ }
238
+ for event in events
239
+ ]
240
+ digest = hashlib.sha256(
241
+ json.dumps(approved, sort_keys=True, separators=(",", ":")).encode()
242
+ ).hexdigest()
243
+ return stable, events, digest
244
+
245
+
246
+ def _approved_properties(event: dict[str, Any]) -> dict[str, Any]:
247
+ if event.get("event") != "message_send":
248
+ return {}
249
+ properties = event.get("properties")
250
+ if not isinstance(properties, dict):
251
+ return {}
252
+ return {
253
+ key: properties.get(key)
254
+ for key in ("main_model", "prompt_tokens", "completion_tokens", "total_tokens")
255
+ }
256
+
257
+
258
+ def _new_turn(
259
+ conversation_id: str, sequence: int, timestamp: datetime | None
260
+ ) -> dict[str, Any]:
261
+ return {
262
+ "id": f"{conversation_id}:turn:{sequence}",
263
+ "conversation_id": conversation_id,
264
+ "external_id": f"turn-{sequence}",
265
+ "started_at": _iso(timestamp),
266
+ "ended_at": None,
267
+ "status": "in-progress",
268
+ "duration_ms": None,
269
+ "time_to_first_token_ms": None,
270
+ "model_calls": 0,
271
+ "tool_calls": 0,
272
+ **empty_tokens(),
273
+ }
274
+
275
+
276
+ def _finish_turn(turn: dict[str, Any], timestamp: datetime | None, status: str) -> None:
277
+ turn["ended_at"] = _iso(timestamp)
278
+ turn["status"] = status
279
+
280
+
281
+ def _usage(properties: dict[str, Any]) -> dict[str, int]:
282
+ input_tokens = _counter(properties.get("prompt_tokens"))
283
+ output_tokens = _counter(properties.get("completion_tokens"))
284
+ attributed = _sum(input_tokens, output_tokens)
285
+ total = max(attributed, _counter(properties.get("total_tokens")))
286
+ return {
287
+ "input_tokens": input_tokens,
288
+ "uncached_input_tokens": input_tokens,
289
+ "cached_input_tokens": 0,
290
+ "cache_write_input_tokens": 0,
291
+ "output_tokens": output_tokens,
292
+ "reasoning_output_tokens": 0,
293
+ "visible_output_tokens": output_tokens,
294
+ "total_tokens": total,
295
+ "unattributed_tokens": total - attributed,
296
+ }
297
+
298
+
299
+ def _counter(value: object) -> int:
300
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
301
+ return 0
302
+ if not math.isfinite(value) or value < 0 or value > MAX_BIGINT:
303
+ return 0
304
+ return int(value)
305
+
306
+
307
+ def _sum(*values: int) -> int:
308
+ return min(MAX_BIGINT, sum(values))
309
+
310
+
311
+ def _add_tokens(target: dict[str, Any], values: dict[str, int]) -> None:
312
+ for key, value in values.items():
313
+ target[key] = _sum(int(target[key]), value)
314
+
315
+
316
+ def _epoch(value: object) -> int | None:
317
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
318
+ return None
319
+ if not math.isfinite(value) or value < 0 or value > 253_402_300_799:
320
+ return None
321
+ return int(value)
322
+
323
+
324
+ def _timestamp(value: object) -> datetime | None:
325
+ epoch = _epoch(value)
326
+ return datetime.fromtimestamp(epoch, tz=UTC) if epoch is not None else None
327
+
328
+
329
+ def _iso(value: datetime | None) -> str | None:
330
+ return value.isoformat().replace("+00:00", "Z") if value else None
331
+
332
+
333
+ def _label(value: object, limit: int) -> str | None:
334
+ if (
335
+ not isinstance(value, str)
336
+ or len(value) > limit
337
+ or not SAFE_LABEL.fullmatch(value)
338
+ ):
339
+ return None
340
+ return value
341
+
342
+
343
+ def _rank(candidate: _Candidate) -> tuple[int, int, str, str]:
344
+ ended = max(
345
+ (_epoch(event.get("time")) or 0 for event in candidate.events), default=0
346
+ )
347
+ return len(candidate.events), ended, candidate.digest, candidate.machine