aimpg 0.2.0__tar.gz → 0.3.1__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.
- {aimpg-0.2.0 → aimpg-0.3.1}/PKG-INFO +33 -12
- {aimpg-0.2.0 → aimpg-0.3.1}/README.md +32 -11
- {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/attribution.py +34 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/cli.py +57 -4
- aimpg-0.3.1/aimpg/codex_logs.py +142 -0
- aimpg-0.3.1/aimpg/cursor_usage.py +97 -0
- aimpg-0.3.1/aimpg/durable.py +119 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/factors.json +35 -15
- {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/gitkept.py +5 -0
- aimpg-0.3.1/aimpg/ledger.py +61 -0
- aimpg-0.3.1/aimpg/live.py +400 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/model.py +1 -1
- {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/receipt.py +30 -4
- aimpg-0.3.1/docs/STRATEGY.md +66 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/pyproject.toml +1 -1
- {aimpg-0.2.0 → aimpg-0.3.1}/tests/conftest.py +9 -0
- aimpg-0.3.1/tests/test_codex_logs.py +59 -0
- aimpg-0.3.1/tests/test_cursor_usage.py +120 -0
- aimpg-0.3.1/tests/test_durable.py +77 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_energy.py +8 -0
- aimpg-0.3.1/tests/test_live.py +132 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/uv.lock +1 -1
- {aimpg-0.2.0 → aimpg-0.3.1}/.github/workflows/publish.yml +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/.github/workflows/test.yml +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/.gitignore +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/LICENSE +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/TODOS.md +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/__init__.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/coach.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/cost.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/energy.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/equivalence.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/equivalences.json +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/logs.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/prices.json +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/replay/__init__.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/replay/cli.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/replay/fake_agent.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/replay/hints.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/replay/picker.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/replay/proxy.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/replay/run.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/replay/sandbox.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/replay/select.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/replay/setups.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/replay/stats.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/replay/workspace.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/share.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/docs/designs/aimpg-design.md +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/evals/attribution_eval.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/evals/label.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/spikes/agent/RESULTS.md +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/spikes/agent/allowlist_proxy.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/spikes/agent/make_profile.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/spikes/egress/RESULTS.md +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/spikes/egress/allowlist_proxy.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/spikes/egress/replay.sb +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/tests/gitrepo.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_attribution.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_coach.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_cost_and_equivalence.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_eval_scoring.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_gitkept.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_logs.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_perf.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_replay_cli.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_replay_e2e.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_replay_hints.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_replay_picker.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_replay_proxy.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_replay_sandbox.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_replay_stats.py +0 -0
- {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_share.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: aimpg
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Real-world energy per solved task for AI coding agents
|
|
5
5
|
Project-URL: Homepage, https://github.com/kumarganduri/aimpg
|
|
6
6
|
Project-URL: Issues, https://github.com/kumarganduri/aimpg/issues
|
|
@@ -28,28 +28,47 @@ uvx aimpg report
|
|
|
28
28
|
|
|
29
29
|
```
|
|
30
30
|
YOUR AI CODING
|
|
31
|
-
Energy
|
|
32
|
-
≈ boiling a litre of water in a kettle
|
|
33
|
-
Money $1,
|
|
34
|
-
Output
|
|
31
|
+
Energy 14.46 kWh – 130.59 kWh
|
|
32
|
+
≈ boiling a litre of water in a kettle 140–1,300 times · driving an electric car 85–770 km · 6.6–59.8 kg CO₂
|
|
33
|
+
Money $1,284 API-equivalent (Anthropic's published prices)
|
|
34
|
+
Output 353 commits made with AI help
|
|
35
35
|
|
|
36
|
-
A typical kept commit:
|
|
36
|
+
A typical kept commit: 36.6 Wh – 313.0 Wh ≈ 2–18 full phone charges · $2.05
|
|
37
37
|
|
|
38
38
|
WHAT WOULD HAVE SAVED THE MOST (measured on your logs; upper bounds that overlap)
|
|
39
|
-
1. Start a fresh session after each commit: up to
|
|
40
|
-
|
|
39
|
+
1. Start a fresh session after each commit: up to 45% less energy, $397
|
|
40
|
+
45% of your AI energy went to re-reading conversation from before your last commit.
|
|
41
41
|
→ After committing, start a new session (or /clear) for the next task.
|
|
42
|
-
2. Use a mid-size model for routine work: up to 16% less energy, $
|
|
42
|
+
2. Use a mid-size model for routine work: up to 16% less energy, $333
|
|
43
43
|
94% of your AI energy ran on the largest models (Opus/Fable class).
|
|
44
44
|
→ Check a cheaper model is good enough on your own commits: `aimpg replay models`.
|
|
45
|
+
|
|
46
|
+
WORKING CHANGES (commits that shipped and lasted 30 days)
|
|
47
|
+
Available on 2026-10-22: aimpg is keeping your history so it can tell.
|
|
45
48
|
```
|
|
46
49
|
|
|
47
50
|
*(Real output from the author's last 30 days.)*
|
|
48
51
|
|
|
49
52
|
## What you can do with it
|
|
50
53
|
|
|
54
|
+
### 0. See it live while you work
|
|
55
|
+
```bash
|
|
56
|
+
uv tool install aimpg && aimpg live install # shows the change, asks y/N, keeps a backup
|
|
57
|
+
```
|
|
58
|
+
```
|
|
59
|
+
⚡ task $1.96 · usual $2.05 · ctx 160k (92% from before your last commit) · /clear ≈ -92% per request · 5h 63%
|
|
60
|
+
```
|
|
61
|
+
After the agent commits, one line for you only (never sent to the model): *"that commit cost $2.40 over 31 AI requests. 92% of the context is from earlier work: /clear before the next task saves ~92% per request."* Undo with `aimpg live uninstall`.
|
|
62
|
+
|
|
63
|
+
### Using Cursor?
|
|
64
|
+
Cursor keeps its token counts online. Download them at **cursor.com/dashboard → Usage → Export CSV**, then:
|
|
65
|
+
```bash
|
|
66
|
+
aimpg report --cursor-usage ~/Downloads/usage-events.csv --cursor-repo ~/code/myproject
|
|
67
|
+
```
|
|
68
|
+
Team export? Add `--cursor-user "Your Name"` to count only your rows.
|
|
69
|
+
|
|
51
70
|
### 1. See your AI footprint in terms you can picture
|
|
52
|
-
`aimpg report` reads the Claude Code logs already on your machine and ties every AI request to the git commit it produced. Energy is shown as an honest range and translated into kettles, phone charges, EV kilometres and CO₂. Money is the API-equivalent cost at Anthropic's published prices (for subscribers, what the same work would cost on the API).
|
|
71
|
+
`aimpg report` reads the Claude Code (and Codex CLI) logs already on your machine and ties every AI request to the git commit it produced. Energy is shown as an honest range and translated into kettles, phone charges, EV kilometres and CO₂. Money is the API-equivalent cost at Anthropic's published prices (for subscribers, what the same work would cost on the API).
|
|
53
72
|
|
|
54
73
|
### 2. Get tips measured on your own habits, not generic advice
|
|
55
74
|
The receipt replays your own logs under "what if" rules and tells you what would have saved the most, in Wh and dollars. For example: how much energy went into re-reading old conversation in long sessions, or into running the biggest model for routine fixes.
|
|
@@ -99,13 +118,15 @@ One row per AI-assisted commit: date, repo, energy range, CO₂ range and cost.
|
|
|
99
118
|
|
|
100
119
|
**Matching requests to commits:** when the agent runs `git commit`, the commit lands inside that tool call, so the match is exact. Within a session, requests since the previous commit belong to the next one. Work before a 2h+ break is shown separately as *lead-up*. Hand-made commits are matched only if you authored them and they touch files the session edited. On the author's history, a hand-labeled check matched 20/20 commits correctly (`evals/`).
|
|
101
120
|
|
|
102
|
-
**
|
|
121
|
+
**Working changes:** aimpg keeps a small history of what each commit cost (never prompts or code), because Claude Code deletes its own logs after 30 days. Once a commit is 30 days old, it is judged: still on the main branch and not reverted or largely rewritten counts as a *working change*. The receipt then shows **cost per working change** and a **waste ratio** (AI spend on work that didn't last).
|
|
122
|
+
|
|
123
|
+
**Energy:** a physical formula, not a price proxy. Prefill compute for new input, one KV-cache re-read per output token (so long contexts cost more), plus datacenter overhead. Model sizes aren't public, so every number is a range. Overheads follow Google's full-stack measurement of a median Gemini prompt (chips, host CPU and memory, idle capacity, cooling ≈ 1.7× the chips alone), and a chat-sized prompt on a mid-size model comes out at 0.017–0.34 Wh, bracketing Google's disclosed 0.24 Wh ([arXiv 2508.15734](https://arxiv.org/pdf/2508.15734)). Sources for every factor are in [`aimpg/factors.json`](aimpg/factors.json), [`aimpg/prices.json`](aimpg/prices.json) and [`aimpg/equivalences.json`](aimpg/equivalences.json).
|
|
103
124
|
|
|
104
125
|
**Replays:** each run starts from the code just before your commit, in a fresh, history-free copy, inside a macOS sandbox. The only network allowed is the model API, through an allowlisting proxy. Your own tests judge the result, and edited tests are always restored before judging. Every token count is cross-checked against Claude Code's own totals. Details are in [docs/designs/aimpg-design.md](docs/designs/aimpg-design.md).
|
|
105
126
|
|
|
106
127
|
## Limits (honest list)
|
|
107
128
|
|
|
108
|
-
- Claude Code logs
|
|
129
|
+
- Claude Code and Codex CLI logs are read automatically. Cursor keeps token usage on its servers, so it needs its usage export (below), and that export doesn't say which folder the work was in: give `--cursor-repo` and requests are matched to your next own commit there by time alone (shown as "by time only"). Codex's model prices aren't in the table yet; its requests count toward energy but are listed as unpriced. Replays need macOS and an Anthropic API key (about $0.10–0.30 per run).
|
|
109
130
|
- Energy is an estimate with a wide range; dollars are close (within about 7% of Claude Code's own session totals on the author's logs).
|
|
110
131
|
- Tips are upper bounds and overlap; they can't be added together.
|
|
111
132
|
- Replays from commit messages alone are hard (12% solved on the author's repo); `--task-mode tests` shows the agent the tests, which makes tasks easier than real work but keeps comparisons fair.
|
|
@@ -8,28 +8,47 @@ uvx aimpg report
|
|
|
8
8
|
|
|
9
9
|
```
|
|
10
10
|
YOUR AI CODING
|
|
11
|
-
Energy
|
|
12
|
-
≈ boiling a litre of water in a kettle
|
|
13
|
-
Money $1,
|
|
14
|
-
Output
|
|
11
|
+
Energy 14.46 kWh – 130.59 kWh
|
|
12
|
+
≈ boiling a litre of water in a kettle 140–1,300 times · driving an electric car 85–770 km · 6.6–59.8 kg CO₂
|
|
13
|
+
Money $1,284 API-equivalent (Anthropic's published prices)
|
|
14
|
+
Output 353 commits made with AI help
|
|
15
15
|
|
|
16
|
-
A typical kept commit:
|
|
16
|
+
A typical kept commit: 36.6 Wh – 313.0 Wh ≈ 2–18 full phone charges · $2.05
|
|
17
17
|
|
|
18
18
|
WHAT WOULD HAVE SAVED THE MOST (measured on your logs; upper bounds that overlap)
|
|
19
|
-
1. Start a fresh session after each commit: up to
|
|
20
|
-
|
|
19
|
+
1. Start a fresh session after each commit: up to 45% less energy, $397
|
|
20
|
+
45% of your AI energy went to re-reading conversation from before your last commit.
|
|
21
21
|
→ After committing, start a new session (or /clear) for the next task.
|
|
22
|
-
2. Use a mid-size model for routine work: up to 16% less energy, $
|
|
22
|
+
2. Use a mid-size model for routine work: up to 16% less energy, $333
|
|
23
23
|
94% of your AI energy ran on the largest models (Opus/Fable class).
|
|
24
24
|
→ Check a cheaper model is good enough on your own commits: `aimpg replay models`.
|
|
25
|
+
|
|
26
|
+
WORKING CHANGES (commits that shipped and lasted 30 days)
|
|
27
|
+
Available on 2026-10-22: aimpg is keeping your history so it can tell.
|
|
25
28
|
```
|
|
26
29
|
|
|
27
30
|
*(Real output from the author's last 30 days.)*
|
|
28
31
|
|
|
29
32
|
## What you can do with it
|
|
30
33
|
|
|
34
|
+
### 0. See it live while you work
|
|
35
|
+
```bash
|
|
36
|
+
uv tool install aimpg && aimpg live install # shows the change, asks y/N, keeps a backup
|
|
37
|
+
```
|
|
38
|
+
```
|
|
39
|
+
⚡ task $1.96 · usual $2.05 · ctx 160k (92% from before your last commit) · /clear ≈ -92% per request · 5h 63%
|
|
40
|
+
```
|
|
41
|
+
After the agent commits, one line for you only (never sent to the model): *"that commit cost $2.40 over 31 AI requests. 92% of the context is from earlier work: /clear before the next task saves ~92% per request."* Undo with `aimpg live uninstall`.
|
|
42
|
+
|
|
43
|
+
### Using Cursor?
|
|
44
|
+
Cursor keeps its token counts online. Download them at **cursor.com/dashboard → Usage → Export CSV**, then:
|
|
45
|
+
```bash
|
|
46
|
+
aimpg report --cursor-usage ~/Downloads/usage-events.csv --cursor-repo ~/code/myproject
|
|
47
|
+
```
|
|
48
|
+
Team export? Add `--cursor-user "Your Name"` to count only your rows.
|
|
49
|
+
|
|
31
50
|
### 1. See your AI footprint in terms you can picture
|
|
32
|
-
`aimpg report` reads the Claude Code logs already on your machine and ties every AI request to the git commit it produced. Energy is shown as an honest range and translated into kettles, phone charges, EV kilometres and CO₂. Money is the API-equivalent cost at Anthropic's published prices (for subscribers, what the same work would cost on the API).
|
|
51
|
+
`aimpg report` reads the Claude Code (and Codex CLI) logs already on your machine and ties every AI request to the git commit it produced. Energy is shown as an honest range and translated into kettles, phone charges, EV kilometres and CO₂. Money is the API-equivalent cost at Anthropic's published prices (for subscribers, what the same work would cost on the API).
|
|
33
52
|
|
|
34
53
|
### 2. Get tips measured on your own habits, not generic advice
|
|
35
54
|
The receipt replays your own logs under "what if" rules and tells you what would have saved the most, in Wh and dollars. For example: how much energy went into re-reading old conversation in long sessions, or into running the biggest model for routine fixes.
|
|
@@ -79,13 +98,15 @@ One row per AI-assisted commit: date, repo, energy range, CO₂ range and cost.
|
|
|
79
98
|
|
|
80
99
|
**Matching requests to commits:** when the agent runs `git commit`, the commit lands inside that tool call, so the match is exact. Within a session, requests since the previous commit belong to the next one. Work before a 2h+ break is shown separately as *lead-up*. Hand-made commits are matched only if you authored them and they touch files the session edited. On the author's history, a hand-labeled check matched 20/20 commits correctly (`evals/`).
|
|
81
100
|
|
|
82
|
-
**
|
|
101
|
+
**Working changes:** aimpg keeps a small history of what each commit cost (never prompts or code), because Claude Code deletes its own logs after 30 days. Once a commit is 30 days old, it is judged: still on the main branch and not reverted or largely rewritten counts as a *working change*. The receipt then shows **cost per working change** and a **waste ratio** (AI spend on work that didn't last).
|
|
102
|
+
|
|
103
|
+
**Energy:** a physical formula, not a price proxy. Prefill compute for new input, one KV-cache re-read per output token (so long contexts cost more), plus datacenter overhead. Model sizes aren't public, so every number is a range. Overheads follow Google's full-stack measurement of a median Gemini prompt (chips, host CPU and memory, idle capacity, cooling ≈ 1.7× the chips alone), and a chat-sized prompt on a mid-size model comes out at 0.017–0.34 Wh, bracketing Google's disclosed 0.24 Wh ([arXiv 2508.15734](https://arxiv.org/pdf/2508.15734)). Sources for every factor are in [`aimpg/factors.json`](aimpg/factors.json), [`aimpg/prices.json`](aimpg/prices.json) and [`aimpg/equivalences.json`](aimpg/equivalences.json).
|
|
83
104
|
|
|
84
105
|
**Replays:** each run starts from the code just before your commit, in a fresh, history-free copy, inside a macOS sandbox. The only network allowed is the model API, through an allowlisting proxy. Your own tests judge the result, and edited tests are always restored before judging. Every token count is cross-checked against Claude Code's own totals. Details are in [docs/designs/aimpg-design.md](docs/designs/aimpg-design.md).
|
|
85
106
|
|
|
86
107
|
## Limits (honest list)
|
|
87
108
|
|
|
88
|
-
- Claude Code logs
|
|
109
|
+
- Claude Code and Codex CLI logs are read automatically. Cursor keeps token usage on its servers, so it needs its usage export (below), and that export doesn't say which folder the work was in: give `--cursor-repo` and requests are matched to your next own commit there by time alone (shown as "by time only"). Codex's model prices aren't in the table yet; its requests count toward energy but are listed as unpriced. Replays need macOS and an Anthropic API key (about $0.10–0.30 per run).
|
|
89
110
|
- Energy is an estimate with a wide range; dollars are close (within about 7% of Claude Code's own session totals on the author's logs).
|
|
90
111
|
- Tips are upper bounds and overlap; they can't be added together.
|
|
91
112
|
- Replays from commit messages alone are hard (12% solved on the author's repo); `--task-mode tests` shows the agent the tests, which makes tasks easier than real work but keeps comparisons fair.
|
|
@@ -39,6 +39,7 @@ from aimpg.model import CommitCall, ParseResult, Request, Task
|
|
|
39
39
|
|
|
40
40
|
PAD = 2.0 # seconds; git timestamps are whole seconds
|
|
41
41
|
GRACE = 2 * 3600.0
|
|
42
|
+
TIME_ONLY = "cursor:" # session prefix of sources matched by time alone
|
|
42
43
|
BREAK = 2 * 3600.0 # a pause this long between requests starts a new work burst # hand commits often land a while after the session goes quiet
|
|
43
44
|
|
|
44
45
|
NOT_IN_REPO = "not in a git repo (or repo moved/deleted)"
|
|
@@ -149,6 +150,9 @@ def attribute(
|
|
|
149
150
|
exact = set(tasks)
|
|
150
151
|
emails: dict[str, str] = {}
|
|
151
152
|
for session, rest in leftovers.items():
|
|
153
|
+
if session.startswith(TIME_ONLY):
|
|
154
|
+
_time_tier(session, rest, session_repos, commits_by_repo, exact, emails, task_for, result)
|
|
155
|
+
continue
|
|
152
156
|
matches: list[tuple[str, Commit]] = []
|
|
153
157
|
for repo in sorted(session_repos.get(session, set())):
|
|
154
158
|
if repo not in emails:
|
|
@@ -169,6 +173,36 @@ def attribute(
|
|
|
169
173
|
return result
|
|
170
174
|
|
|
171
175
|
|
|
176
|
+
def _time_tier(session, rest, session_repos, commits_by_repo, exact, emails, task_for, result) -> None:
|
|
177
|
+
"""Sources with no file or commit evidence (Cursor's usage export): each
|
|
178
|
+
request goes to your next own commit in the repo, if one follows within 2h."""
|
|
179
|
+
repos = sorted(session_repos.get(session, set()))
|
|
180
|
+
if not repos:
|
|
181
|
+
result.unattributed.setdefault(NOT_IN_REPO, []).extend(rest)
|
|
182
|
+
return
|
|
183
|
+
repo = repos[0]
|
|
184
|
+
if repo not in emails:
|
|
185
|
+
emails[repo] = user_email(repo)
|
|
186
|
+
own = sorted(
|
|
187
|
+
(c for c in commits_by_repo.get(repo, {}).values()
|
|
188
|
+
if emails[repo] and c.author_email == emails[repo] and (repo, c.sha) not in exact
|
|
189
|
+
and rest[0].ts - PAD <= c.ts <= rest[-1].ts + GRACE),
|
|
190
|
+
key=lambda c: c.ts,
|
|
191
|
+
)
|
|
192
|
+
i = 0
|
|
193
|
+
for commit in own:
|
|
194
|
+
j = i
|
|
195
|
+
while j < len(rest) and rest[j].ts <= commit.ts + PAD:
|
|
196
|
+
j += 1
|
|
197
|
+
if j > i and commit.ts - rest[j - 1].ts <= GRACE:
|
|
198
|
+
_add_bursts(task_for(repo, commit, "time"), rest[i:j], 1.0)
|
|
199
|
+
elif j > i:
|
|
200
|
+
result.unattributed.setdefault(NO_COMMIT_YET, []).extend(rest[i:j])
|
|
201
|
+
i = j
|
|
202
|
+
if i < len(rest):
|
|
203
|
+
result.unattributed.setdefault(NO_COMMIT_YET, []).extend(rest[i:])
|
|
204
|
+
|
|
205
|
+
|
|
172
206
|
def _add_bursts(task: Task, segment: list[Request], weight: float) -> None:
|
|
173
207
|
"""The final work burst before a commit is its direct cost; earlier bursts are lead-up.
|
|
174
208
|
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
pr AI energy + cost of the current branch's commits (markdown; --post to the PR)
|
|
5
5
|
export one CSV row per AI-assisted commit, for teams and sustainability reports
|
|
6
6
|
replay compare agent setups and models on your own past commits
|
|
7
|
+
live install/uninstall the live status line + post-commit note in Claude Code
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
from __future__ import annotations
|
|
@@ -24,13 +25,25 @@ from aimpg.replay import cli as replay_cli
|
|
|
24
25
|
def _common(p: argparse.ArgumentParser, days: int) -> None:
|
|
25
26
|
p.add_argument("--days", type=int, default=days, help=f"window size in days (default {days})")
|
|
26
27
|
p.add_argument("--logs", type=Path, default=DEFAULT_ROOT, help="Claude Code projects dir")
|
|
28
|
+
p.add_argument("--codex-logs", type=Path, default=None, help="Codex sessions dir (default ~/.codex/sessions)")
|
|
29
|
+
p.add_argument("--cursor-usage", type=Path, action="append", default=[], metavar="CSV",
|
|
30
|
+
help="Cursor usage export (cursor.com/dashboard → Usage → Export CSV); repeatable")
|
|
31
|
+
p.add_argument("--cursor-repo", type=Path, default=None, help="repo the Cursor work was in (matches it to your commits by time)")
|
|
32
|
+
p.add_argument("--cursor-user", default=None, help="only this User's rows (team exports)")
|
|
27
33
|
p.add_argument("--fetch", action="store_true", help="git fetch each repo first (uses the network)")
|
|
28
34
|
|
|
29
35
|
|
|
30
36
|
def _analyze(args):
|
|
37
|
+
from aimpg import codex_logs, cursor_usage
|
|
38
|
+
|
|
31
39
|
now = time.time()
|
|
32
40
|
since = now - args.days * DAY
|
|
33
|
-
|
|
41
|
+
cursor_repo = str(args.cursor_repo.expanduser().resolve()) if args.cursor_repo else ""
|
|
42
|
+
parsed = codex_logs.merge(
|
|
43
|
+
parse_logs(iter_log_files(args.logs)),
|
|
44
|
+
codex_logs.parse_codex(codex_logs.iter_files(args.codex_logs)),
|
|
45
|
+
cursor_usage.parse_cursor([p.expanduser() for p in args.cursor_usage], cursor_repo, args.cursor_user),
|
|
46
|
+
)
|
|
34
47
|
return parsed, attribute(parsed, since, now, refresh=args.fetch), since, now
|
|
35
48
|
|
|
36
49
|
|
|
@@ -51,17 +64,52 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
51
64
|
export.add_argument("--no-subjects", action="store_true", help="leave commit messages out (privacy)")
|
|
52
65
|
export.add_argument("--no-authors", action="store_true", help="leave author emails out (privacy)")
|
|
53
66
|
|
|
67
|
+
live = sub.add_parser("live", help="live status line + post-commit note inside Claude Code")
|
|
68
|
+
live.add_argument("action", choices=("install", "uninstall"))
|
|
69
|
+
sub.add_parser("statusline", help=argparse.SUPPRESS) # called by Claude Code
|
|
70
|
+
hook = sub.add_parser("hook", help=argparse.SUPPRESS) # called by Claude Code
|
|
71
|
+
hook.add_argument("event", choices=("post-commit",))
|
|
72
|
+
|
|
54
73
|
replay_cli.add_parser(sub)
|
|
55
74
|
args = parser.parse_args(argv)
|
|
56
75
|
if args.command == "replay":
|
|
57
76
|
return replay_cli.main(args)
|
|
77
|
+
if args.command in ("statusline", "hook", "live"):
|
|
78
|
+
from aimpg import live as live_mod
|
|
79
|
+
|
|
80
|
+
if args.command == "statusline":
|
|
81
|
+
return live_mod.run_statusline(live_mod.previous_statusline())
|
|
82
|
+
if args.command == "hook":
|
|
83
|
+
return live_mod.run_post_commit_hook()
|
|
84
|
+
if args.action == "install":
|
|
85
|
+
import shutil
|
|
86
|
+
|
|
87
|
+
exe = shutil.which("aimpg")
|
|
88
|
+
if exe is None:
|
|
89
|
+
print("Install aimpg permanently first so Claude Code can call it: uv tool install aimpg")
|
|
90
|
+
return 1
|
|
91
|
+
print(live_mod.install(exe=exe))
|
|
92
|
+
else:
|
|
93
|
+
print(live_mod.uninstall())
|
|
94
|
+
return 0
|
|
58
95
|
|
|
59
96
|
if args.days <= 0:
|
|
60
97
|
parser.error("--days must be positive")
|
|
61
|
-
|
|
98
|
+
from aimpg import codex_logs
|
|
99
|
+
from aimpg.cursor_usage import CursorExportError
|
|
100
|
+
|
|
101
|
+
if not (args.logs.is_dir() or args.cursor_usage or (args.codex_logs or codex_logs.DEFAULT_ROOT).is_dir()):
|
|
62
102
|
print(f"No Claude Code logs found at {args.logs}. Nothing to report.")
|
|
63
103
|
return 0
|
|
64
|
-
|
|
104
|
+
for path in args.cursor_usage:
|
|
105
|
+
if not path.expanduser().is_file():
|
|
106
|
+
print(f"Cursor export not found: {path}")
|
|
107
|
+
return 1
|
|
108
|
+
try:
|
|
109
|
+
parsed, attribution, since, now = _analyze(args)
|
|
110
|
+
except (CursorExportError, UnicodeDecodeError) as exc:
|
|
111
|
+
print(f"Can't read the Cursor export: {exc}")
|
|
112
|
+
return 1
|
|
65
113
|
|
|
66
114
|
if args.command == "export":
|
|
67
115
|
rows = share.csv_rows(attribution, subjects=not args.no_subjects, authors=not args.no_authors)
|
|
@@ -85,7 +133,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
85
133
|
print("\nPosted to the pull request.")
|
|
86
134
|
return 0
|
|
87
135
|
|
|
88
|
-
|
|
136
|
+
from aimpg import durable, ledger
|
|
137
|
+
from aimpg.receipt import task_energy
|
|
138
|
+
|
|
139
|
+
ledger.record(task_energy(attribution.tasks))
|
|
140
|
+
judged = durable.judge(ledger.load(), now)
|
|
141
|
+
sys.stdout.write(render(parsed, attribution, since, now, judged))
|
|
89
142
|
return 0
|
|
90
143
|
|
|
91
144
|
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Read OpenAI Codex CLI session logs (~/.codex/sessions/**/rollout-*.jsonl).
|
|
2
|
+
|
|
3
|
+
Verified on real files (codex-cli 0.159):
|
|
4
|
+
* `event_msg` / `token_count` events carry `info.last_token_usage` for each
|
|
5
|
+
model call: input_tokens (includes cached), cached_input_tokens,
|
|
6
|
+
cache_write_input_tokens, output_tokens, reasoning_output_tokens.
|
|
7
|
+
* `turn_context` carries the model (e.g. gpt-6-luna) and cwd; `session_meta`
|
|
8
|
+
carries the session id and starting cwd.
|
|
9
|
+
|
|
10
|
+
Not yet verified on a real file (no local session ran shell commands): how a
|
|
11
|
+
`git commit` tool call is recorded. Detection is tolerant: any response_item
|
|
12
|
+
tool call whose arguments contain `git ... commit`, closed by an output item
|
|
13
|
+
with the same call_id. Commits made by hand are still matched by the fuzzy tier.
|
|
14
|
+
|
|
15
|
+
Codex token usage is mapped onto the same buckets as Claude Code:
|
|
16
|
+
fresh_in = input − cached − cache_write · cache_read = cached
|
|
17
|
+
cache_write = cache_write_input · output = output + reasoning (generated text)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Iterable, Iterator
|
|
25
|
+
|
|
26
|
+
from aimpg.logs import _GIT_COMMIT, commit_cwd, parse_ts
|
|
27
|
+
from aimpg.model import CommitCall, ParseResult, Request, Usage
|
|
28
|
+
|
|
29
|
+
DEFAULT_ROOT = Path.home() / ".codex" / "sessions"
|
|
30
|
+
TOOL_CALLS = ("function_call", "custom_tool_call", "local_shell_call")
|
|
31
|
+
TOOL_OUTPUTS = ("function_call_output", "custom_tool_call_output", "local_shell_call_output")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def iter_files(root: Path | None = None) -> Iterator[Path]:
|
|
35
|
+
root = root or DEFAULT_ROOT # looked up at call time so tests can point it elsewhere
|
|
36
|
+
if root.is_dir():
|
|
37
|
+
yield from sorted(root.rglob("*.jsonl"))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _command_text(payload: dict) -> str:
|
|
41
|
+
for key in ("arguments", "input", "action"):
|
|
42
|
+
value = payload.get(key)
|
|
43
|
+
if isinstance(value, str):
|
|
44
|
+
try:
|
|
45
|
+
value = json.loads(value)
|
|
46
|
+
except ValueError:
|
|
47
|
+
return value
|
|
48
|
+
if isinstance(value, dict):
|
|
49
|
+
cmd = value.get("command") or value.get("cmd")
|
|
50
|
+
if isinstance(cmd, list):
|
|
51
|
+
return " ".join(str(c) for c in cmd)
|
|
52
|
+
if cmd:
|
|
53
|
+
return str(cmd)
|
|
54
|
+
return ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_codex(files: Iterable[Path]) -> ParseResult:
|
|
58
|
+
result = ParseResult()
|
|
59
|
+
stats = {"files": 0, "rows": 0, "unique_requests": 0, "corrupt_rows": 0}
|
|
60
|
+
versions: dict[str, int] = {}
|
|
61
|
+
for path in files:
|
|
62
|
+
stats["files"] += 1
|
|
63
|
+
session, cwd, model = path.stem, "", ""
|
|
64
|
+
pending: dict[str, tuple[float, str]] = {}
|
|
65
|
+
n = 0
|
|
66
|
+
with open(path, "rb") as fh:
|
|
67
|
+
for line in fh:
|
|
68
|
+
if not line.strip():
|
|
69
|
+
continue
|
|
70
|
+
stats["rows"] += 1
|
|
71
|
+
try:
|
|
72
|
+
row = json.loads(line)
|
|
73
|
+
except ValueError:
|
|
74
|
+
stats["corrupt_rows"] += 1
|
|
75
|
+
continue
|
|
76
|
+
kind, payload = row.get("type"), row.get("payload") or {}
|
|
77
|
+
ts = parse_ts(row.get("timestamp"))
|
|
78
|
+
if kind == "session_meta":
|
|
79
|
+
session = str(payload.get("session_id") or payload.get("id") or session)
|
|
80
|
+
cwd = str(payload.get("cwd") or cwd)
|
|
81
|
+
if payload.get("cli_version"):
|
|
82
|
+
versions["codex " + str(payload["cli_version"])] = versions.get("codex " + str(payload["cli_version"]), 0) + 1
|
|
83
|
+
elif kind == "turn_context":
|
|
84
|
+
model = str(payload.get("model") or model)
|
|
85
|
+
cwd = str(payload.get("cwd") or cwd)
|
|
86
|
+
elif kind == "event_msg" and payload.get("type") == "token_count" and ts is not None:
|
|
87
|
+
usage = ((payload.get("info") or {}).get("last_token_usage")) or {}
|
|
88
|
+
if not usage:
|
|
89
|
+
continue
|
|
90
|
+
cached = int(usage.get("cached_input_tokens") or 0)
|
|
91
|
+
written = int(usage.get("cache_write_input_tokens") or 0)
|
|
92
|
+
total_in = int(usage.get("input_tokens") or 0)
|
|
93
|
+
n += 1
|
|
94
|
+
result.requests.append(Request(
|
|
95
|
+
id=f"codex:{session}:{n}",
|
|
96
|
+
session_id=f"codex:{session}",
|
|
97
|
+
model=model or "codex-unknown",
|
|
98
|
+
ts=ts,
|
|
99
|
+
usage=Usage(
|
|
100
|
+
fresh_in=max(0, total_in - cached - written),
|
|
101
|
+
cache_write=written,
|
|
102
|
+
cache_read=cached,
|
|
103
|
+
output=int(usage.get("output_tokens") or 0) + int(usage.get("reasoning_output_tokens") or 0),
|
|
104
|
+
),
|
|
105
|
+
cwd=cwd,
|
|
106
|
+
))
|
|
107
|
+
elif kind == "response_item" and ts is not None:
|
|
108
|
+
ptype = payload.get("type")
|
|
109
|
+
if ptype in TOOL_CALLS:
|
|
110
|
+
command = _command_text(payload)
|
|
111
|
+
if _GIT_COMMIT.search(command):
|
|
112
|
+
repo_dir = commit_cwd(command, cwd) or cwd
|
|
113
|
+
pending[str(payload.get("call_id") or payload.get("id"))] = (ts, repo_dir)
|
|
114
|
+
elif ptype in TOOL_OUTPUTS:
|
|
115
|
+
call = pending.pop(str(payload.get("call_id")), None)
|
|
116
|
+
if call:
|
|
117
|
+
result.commit_calls.append(CommitCall(f"codex:{session}", call[1], call[0], ts))
|
|
118
|
+
stats["unique_requests"] += n
|
|
119
|
+
result.requests.sort(key=lambda r: r.ts)
|
|
120
|
+
result.commit_calls.sort(key=lambda c: c.start)
|
|
121
|
+
result.stats = stats
|
|
122
|
+
result.versions = versions
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def merge(*parts: ParseResult) -> ParseResult:
|
|
127
|
+
"""One ParseResult from several agents' logs."""
|
|
128
|
+
out = ParseResult()
|
|
129
|
+
stats: dict[str, int] = {}
|
|
130
|
+
for p in parts:
|
|
131
|
+
out.requests.extend(p.requests)
|
|
132
|
+
out.commit_calls.extend(p.commit_calls)
|
|
133
|
+
for k, v in p.files_touched.items():
|
|
134
|
+
out.files_touched.setdefault(k, set()).update(v)
|
|
135
|
+
for k, v in p.stats.items():
|
|
136
|
+
stats[k] = stats.get(k, 0) + v
|
|
137
|
+
for k, v in p.versions.items():
|
|
138
|
+
out.versions[k] = out.versions.get(k, 0) + v
|
|
139
|
+
out.requests.sort(key=lambda r: r.ts)
|
|
140
|
+
out.commit_calls.sort(key=lambda c: c.start)
|
|
141
|
+
out.stats = stats
|
|
142
|
+
return out
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Read Cursor's usage export (cursor.com/dashboard → Usage → Export CSV).
|
|
2
|
+
|
|
3
|
+
Cursor keeps token counts on its servers, not on your machine, so the export is
|
|
4
|
+
the only per-request source. Columns (checked against a public sample export):
|
|
5
|
+
|
|
6
|
+
Date, [User], Kind, Model, Max Mode, Input (w/ Cache Write),
|
|
7
|
+
Input (w/o Cache Write), Cache Read, Output Tokens, Total Tokens, Cost
|
|
8
|
+
|
|
9
|
+
The four token columns add up to Total Tokens, so "Input (w/ Cache Write)" is
|
|
10
|
+
the cache-write count and "Input (w/o Cache Write)" is fresh input.
|
|
11
|
+
|
|
12
|
+
The export doesn't say which folder a request worked in. Its requests always
|
|
13
|
+
count in the totals; with a repo given, they are matched to your own commits
|
|
14
|
+
there by time alone (the "time" tier: no file evidence, lowest confidence).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import csv
|
|
20
|
+
import re
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Iterable
|
|
23
|
+
|
|
24
|
+
from aimpg.logs import parse_ts
|
|
25
|
+
from aimpg.model import ParseResult, Request, Usage
|
|
26
|
+
|
|
27
|
+
SESSION_PREFIX = "cursor:"
|
|
28
|
+
REQUIRED = ("Date", "Model", "Input (w/ Cache Write)", "Input (w/o Cache Write)", "Cache Read", "Output Tokens")
|
|
29
|
+
_CURSOR_CLAUDE = re.compile(r"^claude-(\d+)(?:\.(\d+))?-(opus|sonnet|haiku|fable)\b")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CursorExportError(ValueError):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def normalize_model(name: str) -> str:
|
|
37
|
+
"""Cursor's model ids onto Anthropic's: claude-4.6-sonnet-medium-thinking → claude-sonnet-4-6."""
|
|
38
|
+
m = _CURSOR_CLAUDE.match(name)
|
|
39
|
+
if m:
|
|
40
|
+
major, minor, family = m.groups()
|
|
41
|
+
return f"claude-{family}-{major}" + (f"-{minor}" if minor else "")
|
|
42
|
+
if name.startswith("claude-"):
|
|
43
|
+
return name.replace(".", "-")
|
|
44
|
+
return name
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _int(value: str | None) -> int:
|
|
48
|
+
try:
|
|
49
|
+
return int(float((value or "0").replace(",", "")))
|
|
50
|
+
except ValueError:
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_cursor(paths: Iterable[Path], repo: str = "", user: str | None = None) -> ParseResult:
|
|
55
|
+
result = ParseResult()
|
|
56
|
+
seen: set[tuple] = set()
|
|
57
|
+
stats = {"files": 0, "rows": 0, "unique_requests": 0, "corrupt_rows": 0}
|
|
58
|
+
for path in paths:
|
|
59
|
+
stats["files"] += 1
|
|
60
|
+
with open(path, newline="", encoding="utf-8-sig") as fh:
|
|
61
|
+
reader = csv.DictReader(fh)
|
|
62
|
+
missing = [c for c in REQUIRED if c not in (reader.fieldnames or [])]
|
|
63
|
+
if missing:
|
|
64
|
+
raise CursorExportError(f"{path}: not a Cursor usage export (missing columns: {', '.join(missing)})")
|
|
65
|
+
for row in reader:
|
|
66
|
+
stats["rows"] += 1
|
|
67
|
+
if user is not None and row.get("User", user) != user:
|
|
68
|
+
continue
|
|
69
|
+
ts = parse_ts(row.get("Date"))
|
|
70
|
+
if ts is None:
|
|
71
|
+
stats["corrupt_rows"] += 1
|
|
72
|
+
continue
|
|
73
|
+
usage = Usage(
|
|
74
|
+
fresh_in=_int(row["Input (w/o Cache Write)"]),
|
|
75
|
+
cache_write=_int(row["Input (w/ Cache Write)"]),
|
|
76
|
+
cache_read=_int(row["Cache Read"]),
|
|
77
|
+
output=_int(row["Output Tokens"]),
|
|
78
|
+
)
|
|
79
|
+
if not (usage.ctx_len or usage.output):
|
|
80
|
+
continue # errored / not-charged events carry no tokens
|
|
81
|
+
key = (row["Date"], row["Model"], row.get("User", ""), usage)
|
|
82
|
+
if key in seen: # overlapping exports
|
|
83
|
+
continue
|
|
84
|
+
seen.add(key)
|
|
85
|
+
result.requests.append(Request(
|
|
86
|
+
id=f"cursor:{len(seen)}",
|
|
87
|
+
session_id=SESSION_PREFIX + (row.get("User") or "me"),
|
|
88
|
+
model=normalize_model(row["Model"].strip()),
|
|
89
|
+
ts=ts,
|
|
90
|
+
usage=usage,
|
|
91
|
+
cwd=repo,
|
|
92
|
+
))
|
|
93
|
+
stats["unique_requests"] = len(seen)
|
|
94
|
+
result.requests.sort(key=lambda r: r.ts)
|
|
95
|
+
result.stats = stats
|
|
96
|
+
result.versions = {"cursor export": len(seen)} if seen else {}
|
|
97
|
+
return result
|