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.
Files changed (73) hide show
  1. {aimpg-0.2.0 → aimpg-0.3.1}/PKG-INFO +33 -12
  2. {aimpg-0.2.0 → aimpg-0.3.1}/README.md +32 -11
  3. {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/attribution.py +34 -0
  4. {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/cli.py +57 -4
  5. aimpg-0.3.1/aimpg/codex_logs.py +142 -0
  6. aimpg-0.3.1/aimpg/cursor_usage.py +97 -0
  7. aimpg-0.3.1/aimpg/durable.py +119 -0
  8. {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/factors.json +35 -15
  9. {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/gitkept.py +5 -0
  10. aimpg-0.3.1/aimpg/ledger.py +61 -0
  11. aimpg-0.3.1/aimpg/live.py +400 -0
  12. {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/model.py +1 -1
  13. {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/receipt.py +30 -4
  14. aimpg-0.3.1/docs/STRATEGY.md +66 -0
  15. {aimpg-0.2.0 → aimpg-0.3.1}/pyproject.toml +1 -1
  16. {aimpg-0.2.0 → aimpg-0.3.1}/tests/conftest.py +9 -0
  17. aimpg-0.3.1/tests/test_codex_logs.py +59 -0
  18. aimpg-0.3.1/tests/test_cursor_usage.py +120 -0
  19. aimpg-0.3.1/tests/test_durable.py +77 -0
  20. {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_energy.py +8 -0
  21. aimpg-0.3.1/tests/test_live.py +132 -0
  22. {aimpg-0.2.0 → aimpg-0.3.1}/uv.lock +1 -1
  23. {aimpg-0.2.0 → aimpg-0.3.1}/.github/workflows/publish.yml +0 -0
  24. {aimpg-0.2.0 → aimpg-0.3.1}/.github/workflows/test.yml +0 -0
  25. {aimpg-0.2.0 → aimpg-0.3.1}/.gitignore +0 -0
  26. {aimpg-0.2.0 → aimpg-0.3.1}/LICENSE +0 -0
  27. {aimpg-0.2.0 → aimpg-0.3.1}/TODOS.md +0 -0
  28. {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/__init__.py +0 -0
  29. {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/coach.py +0 -0
  30. {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/cost.py +0 -0
  31. {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/energy.py +0 -0
  32. {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/equivalence.py +0 -0
  33. {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/equivalences.json +0 -0
  34. {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/logs.py +0 -0
  35. {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/prices.json +0 -0
  36. {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/replay/__init__.py +0 -0
  37. {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/replay/cli.py +0 -0
  38. {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/replay/fake_agent.py +0 -0
  39. {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/replay/hints.py +0 -0
  40. {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/replay/picker.py +0 -0
  41. {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/replay/proxy.py +0 -0
  42. {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/replay/run.py +0 -0
  43. {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/replay/sandbox.py +0 -0
  44. {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/replay/select.py +0 -0
  45. {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/replay/setups.py +0 -0
  46. {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/replay/stats.py +0 -0
  47. {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/replay/workspace.py +0 -0
  48. {aimpg-0.2.0 → aimpg-0.3.1}/aimpg/share.py +0 -0
  49. {aimpg-0.2.0 → aimpg-0.3.1}/docs/designs/aimpg-design.md +0 -0
  50. {aimpg-0.2.0 → aimpg-0.3.1}/evals/attribution_eval.py +0 -0
  51. {aimpg-0.2.0 → aimpg-0.3.1}/evals/label.py +0 -0
  52. {aimpg-0.2.0 → aimpg-0.3.1}/spikes/agent/RESULTS.md +0 -0
  53. {aimpg-0.2.0 → aimpg-0.3.1}/spikes/agent/allowlist_proxy.py +0 -0
  54. {aimpg-0.2.0 → aimpg-0.3.1}/spikes/agent/make_profile.py +0 -0
  55. {aimpg-0.2.0 → aimpg-0.3.1}/spikes/egress/RESULTS.md +0 -0
  56. {aimpg-0.2.0 → aimpg-0.3.1}/spikes/egress/allowlist_proxy.py +0 -0
  57. {aimpg-0.2.0 → aimpg-0.3.1}/spikes/egress/replay.sb +0 -0
  58. {aimpg-0.2.0 → aimpg-0.3.1}/tests/gitrepo.py +0 -0
  59. {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_attribution.py +0 -0
  60. {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_coach.py +0 -0
  61. {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_cost_and_equivalence.py +0 -0
  62. {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_eval_scoring.py +0 -0
  63. {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_gitkept.py +0 -0
  64. {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_logs.py +0 -0
  65. {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_perf.py +0 -0
  66. {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_replay_cli.py +0 -0
  67. {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_replay_e2e.py +0 -0
  68. {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_replay_hints.py +0 -0
  69. {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_replay_picker.py +0 -0
  70. {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_replay_proxy.py +0 -0
  71. {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_replay_sandbox.py +0 -0
  72. {aimpg-0.2.0 → aimpg-0.3.1}/tests/test_replay_stats.py +0 -0
  73. {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.2.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 11.34 kWh – 92.22 kWh
32
- ≈ boiling a litre of water in a kettle 110–920 times · driving an electric car 67–540 km · 5.2–42.2 kg CO₂
33
- Money $1,210 API-equivalent (Anthropic's published prices)
34
- Output 328 commits made with AI help
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: 37.4 Wh – 283.5 Wh ≈ 2–17 full phone charges · $3.62
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 44% less energy, $367
40
- 44% of your AI energy went to re-reading conversation from before your last commit.
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, $316
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
- **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. 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).
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 only, for now. Replays need macOS and an Anthropic API key (about $0.10–0.30 per run).
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 11.34 kWh – 92.22 kWh
12
- ≈ boiling a litre of water in a kettle 110–920 times · driving an electric car 67–540 km · 5.2–42.2 kg CO₂
13
- Money $1,210 API-equivalent (Anthropic's published prices)
14
- Output 328 commits made with AI help
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: 37.4 Wh – 283.5 Wh ≈ 2–17 full phone charges · $3.62
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 44% less energy, $367
20
- 44% of your AI energy went to re-reading conversation from before your last commit.
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, $316
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
- **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. 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).
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 only, for now. Replays need macOS and an Anthropic API key (about $0.10–0.30 per run).
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
- parsed = parse_logs(iter_log_files(args.logs))
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
- if not args.logs.is_dir():
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
- parsed, attribution, since, now = _analyze(args)
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
- sys.stdout.write(render(parsed, attribution, since, now))
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