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