cli-consumption 0.0.7__tar.gz → 0.0.9__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 (60) hide show
  1. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.agents/skills/yolo/SKILL.md +7 -5
  2. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/PKG-INFO +33 -3
  3. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/README.md +32 -2
  4. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/docs/architecture.md +3 -3
  5. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/docs/provider-support.md +35 -0
  6. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/docs/roadmap.md +1 -0
  7. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/pyproject.toml +1 -1
  8. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/adapters/__init__.py +4 -0
  9. cli_consumption-0.0.9/src/cli_consumption/adapters/aider.py +347 -0
  10. cli_consumption-0.0.9/src/cli_consumption/adapters/goose.py +581 -0
  11. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/cli.py +6 -0
  12. cli_consumption-0.0.9/tests/test_aider_adapter.py +189 -0
  13. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/tests/test_cli.py +80 -0
  14. cli_consumption-0.0.9/tests/test_goose_adapter.py +269 -0
  15. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/uv.lock +1 -1
  16. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.agents/skills/add-cli-adapter/SKILL.md +0 -0
  17. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.agents/skills/add-cli-adapter/agents/openai.yaml +0 -0
  18. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.agents/skills/audit-usage-privacy/SKILL.md +0 -0
  19. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.agents/skills/audit-usage-privacy/agents/openai.yaml +0 -0
  20. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.agents/skills/evolve-storage-schema/SKILL.md +0 -0
  21. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.agents/skills/evolve-storage-schema/agents/openai.yaml +0 -0
  22. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.agents/skills/yeet-github/SKILL.md +0 -0
  23. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.agents/skills/yeet-github/agents/openai.yaml +0 -0
  24. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.agents/skills/yolo/agents/openai.yaml +0 -0
  25. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.github/workflows/ci.yml +0 -0
  26. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.github/workflows/release.yaml +0 -0
  27. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.gitignore +0 -0
  28. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.pre-commit-config.yaml +0 -0
  29. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.python-version +0 -0
  30. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/AGENTS.md +0 -0
  31. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/CONTRIBUTING.md +0 -0
  32. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/LICENSE +0 -0
  33. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/NOTICE +0 -0
  34. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/docs/privacy.md +0 -0
  35. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/__init__.py +0 -0
  36. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/__main__.py +0 -0
  37. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/adapters/base.py +0 -0
  38. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/adapters/claude.py +0 -0
  39. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/adapters/codex.py +0 -0
  40. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/adapters/gemini.py +0 -0
  41. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/adapters/kilo.py +0 -0
  42. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/adapters/opencode.py +0 -0
  43. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/adapters/pi.py +0 -0
  44. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/api.py +0 -0
  45. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/dashboard.py +0 -0
  46. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/exporting.py +0 -0
  47. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/models.py +0 -0
  48. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/py.typed +0 -0
  49. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/storage.py +0 -0
  50. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/sync.py +0 -0
  51. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/tests/conftest.py +0 -0
  52. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/tests/test_api.py +0 -0
  53. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/tests/test_claude_adapter.py +0 -0
  54. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/tests/test_codex_adapter.py +0 -0
  55. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/tests/test_gemini_adapter.py +0 -0
  56. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/tests/test_kilo_adapter.py +0 -0
  57. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/tests/test_opencode_adapter.py +0 -0
  58. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/tests/test_pi_adapter.py +0 -0
  59. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/tests/test_storage_and_exports.py +0 -0
  60. {cli_consumption-0.0.7 → cli_consumption-0.0.9}/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.7
3
+ Version: 0.0.9
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, Gemini CLI, Kilo Code, Pi, OpenCode, and the core Claude Code local transcript
37
- format are supported.
36
+ Codex, Aider, Gemini CLI, Goose, 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.
@@ -114,6 +114,12 @@ Select Kilo Code to read `~/.local/share/kilo/kilo.db` instead:
114
114
  uv run cli-consumption collect --provider kilo --database usage.sqlite
115
115
  ```
116
116
 
117
+ Select Goose to read `~/.local/share/goose/sessions/sessions.db` instead:
118
+
119
+ ```bash
120
+ uv run cli-consumption collect --provider goose --database usage.sqlite
121
+ ```
122
+
117
123
  Select Pi to read `~/.pi/agent/sessions/` instead:
118
124
 
119
125
  ```bash
@@ -126,6 +132,14 @@ Select Gemini CLI to read `~/.gemini/tmp/*/chats/` instead:
126
132
  uv run cli-consumption collect --provider gemini --database usage.sqlite
127
133
  ```
128
134
 
135
+ Select Aider to read a local analytics log configured at
136
+ `~/.aider/analytics.jsonl` instead:
137
+
138
+ ```bash
139
+ export AIDER_ANALYTICS_LOG="$HOME/.aider/analytics.jsonl"
140
+ uv run cli-consumption collect --provider aider --database usage.sqlite
141
+ ```
142
+
129
143
  Use `all` to detect and collect every supported provider present on the machine:
130
144
 
131
145
  ```bash
@@ -182,6 +196,14 @@ uv run cli-consumption collect --provider kilo \
182
196
  --source laptop=/data/kilo/laptop
183
197
  ```
184
198
 
199
+ Copied Goose sources point to the sessions directory containing `sessions.db`:
200
+
201
+ ```bash
202
+ uv run cli-consumption collect --provider goose \
203
+ --source desktop=/data/goose/desktop \
204
+ --source laptop=/data/goose/laptop
205
+ ```
206
+
185
207
  Copied Pi sources point to the agent directory containing `sessions/`:
186
208
 
187
209
  ```bash
@@ -198,6 +220,14 @@ uv run cli-consumption collect --provider gemini \
198
220
  --source laptop=/data/gemini/laptop
199
221
  ```
200
222
 
223
+ Copied Aider sources point to a directory containing `analytics.jsonl`:
224
+
225
+ ```bash
226
+ uv run cli-consumption collect --provider aider \
227
+ --source desktop=/data/aider/desktop \
228
+ --source laptop=/data/aider/laptop
229
+ ```
230
+
201
231
  ## SQLite and PostgreSQL
202
232
 
203
233
  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, Gemini CLI, Kilo Code, Pi, OpenCode, and the core Claude Code local transcript
9
- format are supported.
8
+ Codex, Aider, Gemini CLI, Goose, 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.
@@ -86,6 +86,12 @@ Select Kilo Code to read `~/.local/share/kilo/kilo.db` instead:
86
86
  uv run cli-consumption collect --provider kilo --database usage.sqlite
87
87
  ```
88
88
 
89
+ Select Goose to read `~/.local/share/goose/sessions/sessions.db` instead:
90
+
91
+ ```bash
92
+ uv run cli-consumption collect --provider goose --database usage.sqlite
93
+ ```
94
+
89
95
  Select Pi to read `~/.pi/agent/sessions/` instead:
90
96
 
91
97
  ```bash
@@ -98,6 +104,14 @@ Select Gemini CLI to read `~/.gemini/tmp/*/chats/` instead:
98
104
  uv run cli-consumption collect --provider gemini --database usage.sqlite
99
105
  ```
100
106
 
107
+ Select Aider to read a local analytics log configured at
108
+ `~/.aider/analytics.jsonl` instead:
109
+
110
+ ```bash
111
+ export AIDER_ANALYTICS_LOG="$HOME/.aider/analytics.jsonl"
112
+ uv run cli-consumption collect --provider aider --database usage.sqlite
113
+ ```
114
+
101
115
  Use `all` to detect and collect every supported provider present on the machine:
102
116
 
103
117
  ```bash
@@ -154,6 +168,14 @@ uv run cli-consumption collect --provider kilo \
154
168
  --source laptop=/data/kilo/laptop
155
169
  ```
156
170
 
171
+ Copied Goose sources point to the sessions directory containing `sessions.db`:
172
+
173
+ ```bash
174
+ uv run cli-consumption collect --provider goose \
175
+ --source desktop=/data/goose/desktop \
176
+ --source laptop=/data/goose/laptop
177
+ ```
178
+
157
179
  Copied Pi sources point to the agent directory containing `sessions/`:
158
180
 
159
181
  ```bash
@@ -170,6 +192,14 @@ uv run cli-consumption collect --provider gemini \
170
192
  --source laptop=/data/gemini/laptop
171
193
  ```
172
194
 
195
+ Copied Aider sources point to a directory containing `analytics.jsonl`:
196
+
197
+ ```bash
198
+ uv run cli-consumption collect --provider aider \
199
+ --source desktop=/data/aider/desktop \
200
+ --source laptop=/data/aider/laptop
201
+ ```
202
+
173
203
  ## SQLite and PostgreSQL
174
204
 
175
205
  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
- Gemini CLI, Kilo Code, OpenCode, and Pi expose the core dimensions available in
21
- their local stores.
19
+ work-item intervals. Codex exposes the complete analytics contract; Aider, Claude
20
+ Code, Gemini CLI, Goose, Kilo Code, OpenCode, and Pi expose the core dimensions
21
+ available in 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,9 +2,11 @@
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 |
7
8
  | Gemini CLI | Supported (core) | Local automatic chat history JSON and JSONL |
9
+ | Goose | Supported (core) | Local SQLite session store v16 |
8
10
  | Kilo Code | Supported (core) | Local SQLite session store |
9
11
  | OpenCode | Supported (core) | Local SQLite v2 session store |
10
12
  | Pi | Supported (core) | Local session JSONL v1-v3 |
@@ -54,6 +56,23 @@ context-window sizes, configuration labels, tool outcomes, or provider-reported
54
56
  durations. Gemini CLI's internal history schema can change without notice, and local
55
57
  token events are not billing data.
56
58
 
59
+ Goose reads `sessions.db` from its sessions directory (normally
60
+ `~/.local/share/goose/sessions/`). The adapter was qualified against Goose v1.47.0
61
+ and schema v16. It extracts visible user turns, per-request model and token usage,
62
+ tool names, and compaction markers while discarding session names, prompts, responses,
63
+ thinking, tool arguments/results, paths, recipes, arbitrary metadata, errors, costs,
64
+ and cost-source labels. Working directories are inspected only for explicit project
65
+ mappings.
66
+
67
+ Goose reports input tokens with cache reads and writes as subsets. Normalized uncached
68
+ input subtracts both cache subsets, and model labels combine the session provider with
69
+ the usage-ledger model. Usage-ledger timestamps have one-second resolution, so model
70
+ calls are attributed to the latest visible user turn at or before the ledger event.
71
+ This adapter does not read legacy pre-1.10 JSONL sessions or pre-v16 SQLite schemas,
72
+ collect parent/subagent relationships, context-window sizes, reasoning tokens,
73
+ provider-reported latency, or cost. Goose's internal schema can change without notice,
74
+ and local token events are not billing data.
75
+
57
76
  OpenCode reads `opencode.db` from its XDG data directory (normally
58
77
  `~/.local/share/opencode/`). It extracts v2 session messages, model references, token
59
78
  usage, tool names, and compaction timestamps while discarding message text, reasoning,
@@ -98,6 +117,22 @@ session directories automatically, context-window sizes, costs, branch relations
98
117
  or provider-reported durations. Pi's JSONL schema can change without notice, and its
99
118
  local token events are not billing data.
100
119
 
120
+ Aider reads an explicitly configured local analytics log named `analytics.jsonl` (for
121
+ example, `AIDER_ANALYTICS_LOG=~/.aider/analytics.jsonl`). It groups events from
122
+ `launched` through `exit`, extracts message-send attempts, model identifiers, and
123
+ prompt/completion/total token counters, and discards the analytics user UUID, costs,
124
+ exception text, command events, edit formats, system properties, and arbitrary event
125
+ properties. Conversation identifiers are stable hashes of the transient UUID and
126
+ launch time; the UUID itself is never emitted.
127
+
128
+ Aider analytics do not expose prompts or responses, but logging is not enabled at a
129
+ fixed location by default. The adapter cannot attribute projects, tool calls, cached
130
+ or reasoning tokens, compactions, context windows, or provider-reported durations.
131
+ Turns represent Aider message-send attempts because the log has no durable user-turn
132
+ identifier. Unknown custom model names may already be represented as
133
+ `provider/REDACTED` by Aider. Its analytics event schema can change without notice,
134
+ and local token events are not billing data.
135
+
101
136
  Provider formats can change without notice. Unknown fields are ignored; malformed
102
137
  provider records are counted and skipped. Compatibility fixes should add a fixture for
103
138
  both the old and new format whenever possible.
@@ -7,6 +7,7 @@
7
7
  - Core Pi local session collector
8
8
  - Core Kilo Code local SQLite collector
9
9
  - Core Gemini CLI local chat-history collector
10
+ - Core Goose local SQLite collector
10
11
  - Offline multi-machine deduplication
11
12
  - SQLite and PostgreSQL storage
12
13
  - 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.7"
7
+ version = "0.0.9"
8
8
  description = "Analyze and consolidate AI coding CLI consumption across machines."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -1,14 +1,18 @@
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
3
4
  from cli_consumption.adapters.gemini import GeminiAdapter
5
+ from cli_consumption.adapters.goose import GooseAdapter
4
6
  from cli_consumption.adapters.kilo import KiloAdapter
5
7
  from cli_consumption.adapters.opencode import OpenCodeAdapter
6
8
  from cli_consumption.adapters.pi import PiAdapter
7
9
 
8
10
  __all__ = [
11
+ "AiderAdapter",
9
12
  "ClaudeAdapter",
10
13
  "CodexAdapter",
11
14
  "GeminiAdapter",
15
+ "GooseAdapter",
12
16
  "KiloAdapter",
13
17
  "OpenCodeAdapter",
14
18
  "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