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.
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.agents/skills/yolo/SKILL.md +7 -5
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/PKG-INFO +33 -3
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/README.md +32 -2
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/docs/architecture.md +3 -3
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/docs/provider-support.md +35 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/docs/roadmap.md +1 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/pyproject.toml +1 -1
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/adapters/__init__.py +4 -0
- cli_consumption-0.0.9/src/cli_consumption/adapters/aider.py +347 -0
- cli_consumption-0.0.9/src/cli_consumption/adapters/goose.py +581 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/cli.py +6 -0
- cli_consumption-0.0.9/tests/test_aider_adapter.py +189 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/tests/test_cli.py +80 -0
- cli_consumption-0.0.9/tests/test_goose_adapter.py +269 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/uv.lock +1 -1
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.agents/skills/add-cli-adapter/SKILL.md +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.agents/skills/add-cli-adapter/agents/openai.yaml +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.agents/skills/audit-usage-privacy/SKILL.md +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.agents/skills/audit-usage-privacy/agents/openai.yaml +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.agents/skills/evolve-storage-schema/SKILL.md +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.agents/skills/evolve-storage-schema/agents/openai.yaml +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.agents/skills/yeet-github/SKILL.md +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.agents/skills/yeet-github/agents/openai.yaml +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.agents/skills/yolo/agents/openai.yaml +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.github/workflows/ci.yml +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.github/workflows/release.yaml +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.gitignore +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.pre-commit-config.yaml +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/.python-version +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/AGENTS.md +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/CONTRIBUTING.md +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/LICENSE +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/NOTICE +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/docs/privacy.md +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/__init__.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/__main__.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/adapters/base.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/adapters/claude.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/adapters/codex.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/adapters/gemini.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/adapters/kilo.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/adapters/opencode.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/adapters/pi.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/api.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/dashboard.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/exporting.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/models.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/py.typed +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/storage.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/src/cli_consumption/sync.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/tests/conftest.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/tests/test_api.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/tests/test_claude_adapter.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/tests/test_codex_adapter.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/tests/test_gemini_adapter.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/tests/test_kilo_adapter.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/tests/test_opencode_adapter.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/tests/test_pi_adapter.py +0 -0
- {cli_consumption-0.0.7 → cli_consumption-0.0.9}/tests/test_storage_and_exports.py +0 -0
- {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.
|
|
14
|
-
3.
|
|
15
|
-
4.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
20
|
-
Gemini CLI, Kilo Code, OpenCode, and Pi expose the core dimensions
|
|
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
|
|
@@ -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
|