claude-agent-ledger 0.1.0 → 0.3.0

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.
package/CHANGELOG.md CHANGED
@@ -1,11 +1,63 @@
1
1
  # Changelog
2
2
 
3
- All notable changes to `agent-ledger` will be documented here.
3
+ All notable changes to `claude-agent-ledger` will be documented here.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.3.0] — 2026-04-22
9
+
10
+ ### Added
11
+ - `--summary` flag — single-screen dashboard with shadow cost, top subagent /
12
+ model / day, cache reuse ratio, and **leverage vs $200/mo Max plan**.
13
+ This is the killer view for "why install over /cost?"
14
+ - Auto-derived insights footer on the default table:
15
+ - Top contributor with %
16
+ - 1h cache spend call-out (when ≥5% of total)
17
+ - Cache reuse ratio (read/write) with verdict
18
+ - Monthly $ equivalent + leverage vs Max plan
19
+ - Daily-mode insights: peak day, avg/day, peak/trough variance
20
+
21
+ ### Changed
22
+ - `--by day` polish:
23
+ - Bar fixed to 40-char width so session counts align in their own column
24
+ - Brighter cyan (`\x1b[96m`) bar character
25
+ - Total row separated by horizontal rule, properly aligned
26
+ - Header line now shows period + total + sessions + group count at a glance:
27
+ `agent-ledger week 2026-04-15 → 2026-04-22 · $13,356.27 shadow · 217 sessions · 7 agents`
28
+
29
+ ## [0.2.0] — 2026-04-21
30
+
31
+ ### Added
32
+ - `--by <subagent|model|day>` flag — group rows by subagent (default), by
33
+ Claude model, or by date. The `day` mode renders an ASCII bar chart of
34
+ daily spend, useful for spotting "expensive Tuesdays."
35
+ - Server-tool pricing: `web_search_requests` and `web_fetch_requests` from
36
+ the `server_tool_use` block are now counted at $0.01/request and surface
37
+ in a footer row when present.
38
+ - ANSI color output, TTY-aware (NO_COLOR / FORCE_COLOR respected).
39
+ - Subagent / model / day labels in cyan
40
+ - Cost amounts color-coded by magnitude (red ≥$1000, yellow ≥$100,
41
+ green ≥$1, dim <$1)
42
+ - Total row bolded
43
+ - New tests for server-tool pricing, `--by model`, and `--by day`
44
+ (22 tests, up from 17).
45
+
46
+ ### Changed
47
+ - `aggregate(turns, from, to)` accepts an optional 4th arg `group: GroupKey`
48
+ (`'subagent' | 'model' | 'day'`). Default behavior unchanged.
49
+ - `AggregatedRow` now carries `webSearchRequests` and `webFetchRequests`
50
+ counters; `CostBreakdown` adds `serverToolUseCost`.
51
+
52
+ ### Not yet
53
+ - Calibration against real Anthropic invoices (need volunteers — see
54
+ `.github/ISSUE_TEMPLATE/`)
55
+ - Cost forecasting (trailing 7-day burn rate)
56
+ - Homebrew tap
57
+
58
+ ## [0.1.0] — 2026-04-21
59
+
60
+ Initial public release.
9
61
 
10
62
  ### Added
11
63
  - JSONL parser with recursive walk of `~/.claude/projects/**`, including nested
@@ -21,13 +73,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
21
73
  - Family-prefix fallback for unknown future models (e.g. `claude-opus-5-*`
22
74
  resolves to Opus pricing)
23
75
  - `<synthetic>` turns (Claude Code context compaction) correctly priced at $0
24
-
25
- ### Not yet
26
- - `--by model` breakdown
27
- - Cost forecasting (trailing 7-day burn rate)
28
- - `server_tool_use` pricing (web_search_requests, web_fetch_requests)
29
- - Homebrew tap
30
-
31
- ## [0.0.1] — 2026-04-21
32
-
33
- Initial skeleton, not yet published.
76
+ - Published to npm as `claude-agent-ledger`
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
- # agent-ledger
1
+ # claude-agent-ledger
2
2
 
3
- > See exactly where your Claude Code budget goes — per subagent, per task, per day.
3
+ > See exactly where your Claude Code budget goes — per subagent, per model, per day.
4
4
 
5
- [![status](https://img.shields.io/badge/status-pre--alpha-orange)](#status)
5
+ [![npm](https://img.shields.io/npm/v/claude-agent-ledger.svg)](https://www.npmjs.com/package/claude-agent-ledger)
6
6
  [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
7
7
 
8
8
  ---
@@ -12,50 +12,104 @@
12
12
  Claude Code's invoice at month-end tells you one number. It doesn't tell you:
13
13
 
14
14
  - Which of your subagents burned most of it
15
- - Which tasks cost $5 and which cost $0.05
16
- - Whether last week's refactor was 10× more expensive than it should have been
15
+ - Which model (Opus / Sonnet / Haiku) ate the budget
16
+ - Which day of last week was the expensive one
17
17
  - Whether your "cheap" Haiku agent is actually quietly expensive
18
18
 
19
19
  If you run **one** Claude Code session, this is fine — you already know.
20
20
  If you run **seven** subagents daily (reliability, release, review, iOS factory, docs, ...), you're flying blind.
21
21
 
22
- `agent-ledger` reads Claude Code's local session logs and gives you a per-subagent ledger. That's it.
22
+ `claude-agent-ledger` reads Claude Code's local session logs and gives you a per-subagent ledger. That's it.
23
23
 
24
- ## What it is
25
-
26
- A single-file CLI. No server. No account. No data leaves your machine.
24
+ ## Install
27
25
 
28
26
  ```bash
29
- # Install
30
27
  npm install -g claude-agent-ledger
31
28
  # or: bun install -g claude-agent-ledger
32
29
 
33
- # Today's spend, grouped by subagent
34
- agent-ledger today
30
+ agent-ledger week --summary # the dashboard (start here)
31
+ agent-ledger week --by model # which Claude model burned the budget
32
+ agent-ledger week --by day # daily bar chart with peak/avg/variance
33
+ agent-ledger month --md > report.md
34
+ ```
35
+
36
+ No server. No account. No data leaves your machine.
37
+
38
+ ## Why this and not `/cost`?
39
+
40
+ Claude Code's built-in `/cost` shows the **current session** cost. That's it.
41
+
42
+ `claude-agent-ledger` answers questions `/cost` can't:
43
+
44
+ - Which of my **10+ subagents** burned the budget? (`--by model` / default)
45
+ - Which **day** of last week was the expensive one? (`--by day`)
46
+ - What would my Claude Max usage have cost on **pay-as-you-go**? (the "shadow cost" framing)
47
+ - Is my **cache reuse ratio** healthy? (auto-derived insight)
48
+ - What's the **leverage** I'm getting from my $200/mo subscription? (e.g. 250×)
35
49
 
36
- # Last 7 days
37
- agent-ledger week
50
+ Run `agent-ledger week --summary` once and decide.
38
51
 
39
- # Last 30 days + markdown export
40
- agent-ledger month --md > month-report.md
52
+ ## Example output (real, from the author's machine)
53
+
54
+ ### `--summary` (the dashboard)
55
+
56
+ ```
57
+ agent-ledger week summary 2026-04-15 → 2026-04-22
58
+
59
+ Shadow cost $13,356.27
60
+ Sessions 217
61
+ Plan reference $200/mo (Max)
62
+ Multiplier 250× vs Max plan
63
+
64
+ Top subagent (main) $12,701.19 (95%)
65
+ Top model claude-opus-4-7 $7,184.45 (54%)
66
+ Peak day 2026-04-15 $3,679.80
67
+
68
+ Cache 1h writes 119.45M tokens · $3583.42
69
+ Cache reads 5.54B tokens · $8162.93
70
+ Cache reuse 38× reads/writes
71
+ Server tools none
41
72
  ```
42
73
 
43
- ## Example output
74
+ ### Default — by subagent
44
75
 
45
76
  ```
46
77
  agent-ledger week · 2026-04-14 → 2026-04-21
47
78
 
48
- subagent sess tokens(in/out) cache tokens (1h/5m/read) cost
49
- ────────────────────────────────────────────────────────────────────────────────────────────
50
- (main) 27 513K / 15.15M 1h:111.06M 5m: 0 r:4892.68M $11814.53
51
- general-purpose 145 112K / 876K 1h: 0 5m:19.47M r: 168.34M $548.25
52
- Swift Developer 13 4K / 574K 1h: 0 5m: 1.61M r: 47.55M $28.93
53
- Market Researcher 10 107K / 74K 1h: 0 5m: 863K r: 8.61M $7.26
54
- Explore 13 176K / 77K 1h: 0 5m: 2.60M r: 22.38M $6.47
55
- zstackws 3 232 / 20K 1h: 0 5m: 829K r: 3.52M $4.47
56
- claude-code-guide 1 2K / 2K 1h: 0 5m: 56K r: 223K $0.08
57
- ────────────────────────────────────────────────────────────────────────────────────────────
58
- total 212 915K / 16.77M 1h:111.06M 5m:25.43M r:5143.30M $12409.98
79
+ subagent sess tokens(in/out) cache tokens (1h/5m/read) cost
80
+ ───────────────────────────────────────────────────────────────────────────────────────────
81
+ (main) 28 514K / 15.87M 1h:116.10M 5m: 0 r:5229.48M $12524.97
82
+ general-purpose 148 113K / 955K 1h: 0 5m: 20.67M r: 183.94M $599.97
83
+ Swift Developer 13 4K / 574K 1h: 0 5m: 1.61M r: 47.55M $28.93
84
+ Market Researcher 10 107K / 74K 1h: 0 5m: 863K r: 8.61M $7.26
85
+ Explore 13 176K / 77K 1h: 0 5m: 2.60M r: 22.38M $6.47
86
+ zstackws 3 232 / 20K 1h: 0 5m: 829K r: 3.52M $4.47
87
+ claude-code-guide 1 2K / 2K 1h: 0 5m: 56K r: 223K $0.08
88
+ ───────────────────────────────────────────────────────────────────────────────────────────
89
+ total 216 916K / 17.57M 1h:116.10M 5m: 26.62M r:5495.71M $13172.14
90
+ ```
91
+
92
+ ### `--by model`
93
+
94
+ ```
95
+ subagent sess tokens(in/out) cache tokens (1h/5m/read) cost
96
+ ───────────────────────────────────────────────────────────────────────────────────────────
97
+ claude-opus-4-7 16 59K / 8.35M 1h: 61.15M 5m: 2.32M r:2974.59M $6967.29
98
+ claude-opus-4-6 127 544K / 8.25M 1h: 55.02M 5m: 13.30M r:2422.18M $6160.46
99
+ claude-sonnet-4-6 65 135K / 946K 1h: 0 5m: 8.81M r: 98.12M $77.07
100
+ claude-haiku-4-5 13 178K / 67K 1h: 0 5m: 2.19M r: 21.57M $4.33
101
+ ```
102
+
103
+ ### `--by day` (ASCII bar chart)
104
+
105
+ ```
106
+ 2026-04-15 $3749.84 ████████████████████████████████████ (18 sess)
107
+ 2026-04-16 $2492.99 ████████████████████████ (175 sess)
108
+ 2026-04-17 $2111.63 ████████████████████ (9 sess)
109
+ 2026-04-18 $1840.02 ██████████████████ (18 sess)
110
+ 2026-04-19 $644.97 ██████ (5 sess)
111
+ 2026-04-20 $1095.58 ███████████ (6 sess)
112
+ 2026-04-21 $1273.55 ████████████ (9 sess)
59
113
  ```
60
114
 
61
115
  Yes those are real numbers. Yes it's a lot. See [Shadow cost vs actual bill](#shadow-cost-vs-actual-bill).
@@ -64,24 +118,27 @@ Yes those are real numbers. Yes it's a lot. See [Shadow cost vs actual bill](#sh
64
118
 
65
119
  The `cost` column shows what you **would** pay at Anthropic's pay-as-you-go API rates.
66
120
 
67
- If you're on a fixed-price subscription (Claude Pro / Max / Claude Code plan), your actual bill is capped — the shadow cost is what the same usage **would** have cost through the raw API. It's useful for:
121
+ If you're on a fixed-price subscription (Claude Pro / Max / Claude Code plan), your actual bill is capped — the shadow cost is what the same usage **would** have cost through the raw API. Useful for:
68
122
 
69
123
  - Understanding where time-and-token budget goes, regardless of how you pay
70
124
  - Spotting runaway agents (a 10× spike is still a 10× spike, capped or not)
71
125
  - Deciding when to downshift to Haiku or up to Opus per task
126
+ - Justifying a Max subscription internally ("our shadow cost is $13K/wk, Max is $200/mo — that's a 65× discount")
72
127
 
73
- If you're on pay-as-you-go, the `cost` column is roughly your bill (±5% depending on tier / geography).
128
+ If you're on pay-as-you-go, the `cost` column is roughly your bill (±5–10% depending on tier / geography / `server_tool_use` calibration).
74
129
 
75
130
  ## How it works
76
131
 
77
132
  Claude Code writes every conversation turn to `~/.claude/projects/<encoded-path>/*.jsonl`.
78
- Each turn records input/output tokens, cache tokens (with TTL split), and the model used.
79
- Subagent invocations live under a nested `<session>/subagents/agent-*.jsonl`, with a sidecar
80
- `.meta.json` that carries the real subagent name (e.g. `Reality Checker`, `ios-factory`).
133
+ Each turn records input/output tokens, cache tokens (with TTL split), the model used,
134
+ and any `server_tool_use` (web_search / web_fetch) requests. Subagent invocations live
135
+ under `<session>/subagents/agent-*.jsonl`, with a sidecar `.meta.json` carrying the real
136
+ subagent name (e.g. `Reality Checker`, `ios-factory`).
81
137
 
82
- `agent-ledger` walks the tree, resolves subagent types from the sidecar metadata, and
83
- aggregates by subagent. Pricing is applied locally from `src/pricing.ts`. Nothing is sent
84
- anywhere — the tool never touches the network after install.
138
+ `claude-agent-ledger` walks the tree, resolves subagent types from the sidecar metadata,
139
+ and aggregates by your chosen key (subagent, model, or day). Pricing is applied locally
140
+ from `src/pricing.ts`. Nothing is sent anywhere — the tool never touches the network
141
+ after install.
85
142
 
86
143
  ### Cache accounting
87
144
 
@@ -93,47 +150,71 @@ Anthropic's prompt caching has two TTLs in current logs:
93
150
  | `ephemeral_1h_input_tokens` | 2.00× base input | 1-hour TTL (long-lived sessions) |
94
151
  | `cache_read_input_tokens` | 0.10× base input | Read price (90% discount, TTL-agnostic) |
95
152
 
96
- Naive trackers lump these together as "input" and either overstate (by counting all creation as 1.25×) or understate (by ignoring the 1-hour writes entirely). `agent-ledger` breaks them out explicitly — see `src/pricing.ts`.
153
+ Naive trackers lump these together as "input" and either overstate (by counting all creation as 1.25×) or understate (by ignoring the 1-hour writes entirely). `claude-agent-ledger` breaks them out explicitly — see `src/pricing.ts`.
154
+
155
+ ### Server tool pricing
156
+
157
+ `web_search_requests` and `web_fetch_requests` are counted at $0.01/request and shown in a footer when present:
158
+
159
+ ```
160
+ server tools: web_search ×7, web_fetch ×0 ($0.07)
161
+ ```
162
+
163
+ If Anthropic's actual rate differs (or web_fetch has its own price), open an issue.
164
+
165
+ ## Flags
166
+
167
+ | Flag | What it does |
168
+ |---|---|
169
+ | `--by <subagent\|model\|day>` | Group rows. Default: `subagent`. `day` renders as ASCII bar chart. |
170
+ | `--md` | Markdown table output (good for committing or sharing) |
171
+ | `--json` | Raw JSON output (pipe into jq) |
172
+ | `--plan pro\|max` | Hide dollar columns; show token utilization only |
173
+
174
+ | Env | What it does |
175
+ |---|---|
176
+ | `NO_COLOR=1` | Disable ANSI colors |
177
+ | `FORCE_COLOR=1` | Force ANSI colors when piping |
97
178
 
98
179
  ## What it's not
99
180
 
100
181
  - Not a real-time dashboard (post-hoc log reader)
101
- - Not a tracker for direct Anthropic API calls (those logs live elsewhere)
182
+ - Not a tracker for direct Anthropic API calls (those logs live server-side)
102
183
  - Not a cost-limiter or kill-switch (read-only, by design)
103
184
  - Not affiliated with Anthropic
104
185
 
105
186
  ## Who this is for
106
187
 
107
188
  - You run ≥ 2 Claude Code subagents and genuinely don't know the per-agent split
108
- - You're about to have a "wait, I spent *what*?" moment at invoice time
189
+ - You're on Claude Max and want to know what your "free" usage is worth
109
190
  - You're building AI Agent systems and want observability primitives, not vendor dashboards
110
191
 
111
192
  ## Status
112
193
 
113
- Pre-alpha. Validated on ~200K turns of real Claude Code logs on the author's machine.
114
- Pricing model matches Anthropic published rates as of 2026-04-21. Shadow cost values
115
- may drift ±5-10% from Anthropic's actual invoice in edge cases (retries, tier bumps,
116
- server_tool_use which is not yet accounted for).
194
+ `v0.2.0` shipped 2026-04-21. Validated on ~200K turns of real Claude Code logs.
195
+ Pricing matches Anthropic published rates as of 2026-04-21. Shadow cost values
196
+ may drift ±5-10% from Anthropic's actual invoice in edge cases (retries, tier
197
+ bumps, `server_tool_use` calibration). Calibration PRs welcome — see issue
198
+ templates.
117
199
 
118
200
  - [x] Log parser (JSONL streaming, recursive subagent discovery)
119
- - [x] Per-subagent aggregation (from `<session>/subagents/*.meta.json`)
120
- - [x] Terminal table output
121
- - [x] Markdown export (`--md`)
201
+ - [x] Per-subagent aggregation
202
+ - [x] Per-model aggregation (`--by model`)
203
+ - [x] Per-day aggregation with bar chart (`--by day`)
204
+ - [x] Terminal table + Markdown + JSON output
205
+ - [x] ANSI color, TTY-aware
122
206
  - [x] Cache 1h/5m TTL split pricing
123
- - [ ] `--plan pro|max` mode (mask shadow cost, show utilization only)
124
- - [ ] `--json` output mode
125
- - [ ] Per-model breakdown (`agent-ledger week --by model`)
207
+ - [x] `--plan pro|max` to mask shadow cost
208
+ - [x] `server_tool_use` pricing (web_search, web_fetch)
126
209
  - [ ] Cost forecasting (trailing 7-day burn rate)
127
- - [ ] `server_tool_use` pricing (web search, file fetch)
210
+ - [ ] Calibration against real Anthropic invoices
128
211
  - [ ] Homebrew tap
129
212
 
130
- First tagged release: **2026-05-05**.
131
-
132
213
  ## Install from source
133
214
 
134
215
  ```bash
135
- git clone https://github.com/XJM-free/agent-ledger.git
136
- cd agent-ledger
216
+ git clone https://github.com/XJM-free/claude-agent-ledger.git
217
+ cd claude-agent-ledger
137
218
  bun install
138
219
  bun link
139
220
  agent-ledger today
@@ -142,15 +223,14 @@ agent-ledger today
142
223
  ## Development
143
224
 
144
225
  ```bash
145
- bun test
226
+ bun test # 22 pass, 0 fail
146
227
  bun run typecheck
147
228
  ```
148
229
 
149
230
  ## Author
150
231
 
151
232
  Built by [@XJM-free](https://jiexiang.dev) while running seven production Claude Code
152
- subagents for infrastructure work. More context: [How I run 7 production AI agents with
153
- Claude Code](https://jiexiang.dev).
233
+ subagents for infrastructure work. More context coming on the blog.
154
234
 
155
235
  ## License
156
236
 
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env bun
2
- import { aggregate } from '../src/aggregator.ts';
3
- import { formatMarkdown, formatTable } from '../src/format.ts';
2
+ import { aggregate, type GroupKey } from '../src/aggregator.ts';
3
+ import { formatMarkdown, formatSummary, formatTable, formatTrend } from '../src/format.ts';
4
4
  import { parseAll } from '../src/parser.ts';
5
- import type { LedgerReport } from '../src/types.ts';
5
+ import type { LedgerReport, SessionTurn } from '../src/types.ts';
6
6
 
7
7
  type Period = 'today' | 'week' | 'month';
8
8
  type Plan = 'payg' | 'pro' | 'max';
@@ -12,6 +12,8 @@ interface Args {
12
12
  markdown: boolean;
13
13
  json: boolean;
14
14
  plan: Plan;
15
+ by: GroupKey;
16
+ summary: boolean;
15
17
  }
16
18
 
17
19
  function periodRange(period: Period): { from: Date; to: Date } {
@@ -33,16 +35,24 @@ function usage(): never {
33
35
  console.error(`Usage: agent-ledger <today|week|month> [flags]
34
36
 
35
37
  Flags:
36
- --md Emit a Markdown table (good for sharing / commits)
37
- --json Emit raw JSON (for piping into other tools)
38
- --plan pro|max Suppress dollar columns. Report token utilization only.
39
- (Default is pay-as-you-go, which shows shadow cost in $.)
38
+ --summary One-screen dashboard: total + top subagent + top
39
+ model + peak day + cache reuse + leverage vs $200/mo plan
40
+ --by <subagent|model|day> Group rows by subagent (default), model, or day
41
+ "day" renders as a daily ASCII bar chart
42
+ --md Markdown table output (good for sharing / commits)
43
+ --json Raw JSON output (for piping into jq)
44
+ --plan pro|max Suppress dollar columns (token utilization only)
45
+
46
+ Environment:
47
+ NO_COLOR=1 Disable ANSI colors
48
+ FORCE_COLOR=1 Force colors when piping
40
49
 
41
50
  Examples:
42
- agent-ledger today
43
- agent-ledger week --md > week.md
44
- agent-ledger month --json | jq '.total.cost.totalCost'
45
- agent-ledger week --plan max
51
+ agent-ledger week --summary # the dashboard
52
+ agent-ledger week --by day # daily bar chart
53
+ agent-ledger week --by model # which Claude model burned the budget
54
+ agent-ledger month --md > month.md
55
+ agent-ledger week --json | jq '.total.cost.totalCost'
46
56
  `);
47
57
  process.exit(1);
48
58
  }
@@ -52,6 +62,7 @@ function parseArgs(argv: string[]): Args {
52
62
  if (!period || !['today', 'week', 'month'].includes(period)) usage();
53
63
 
54
64
  let plan: Plan = 'payg';
65
+ let by: GroupKey = 'subagent';
55
66
  for (let i = 0; i < rest.length; i++) {
56
67
  const flag = rest[i];
57
68
  if (flag === '--plan') {
@@ -62,6 +73,14 @@ function parseArgs(argv: string[]): Args {
62
73
  } else {
63
74
  usage();
64
75
  }
76
+ } else if (flag === '--by') {
77
+ const next = rest[i + 1];
78
+ if (next === 'subagent' || next === 'model' || next === 'day') {
79
+ by = next;
80
+ i++;
81
+ } else {
82
+ usage();
83
+ }
65
84
  }
66
85
  }
67
86
  return {
@@ -69,19 +88,20 @@ function parseArgs(argv: string[]): Args {
69
88
  markdown: rest.includes('--md'),
70
89
  json: rest.includes('--json'),
71
90
  plan,
91
+ by,
92
+ summary: rest.includes('--summary'),
72
93
  };
73
94
  }
74
95
 
75
96
  function planMask(report: LedgerReport, plan: Plan): LedgerReport {
76
97
  if (plan === 'payg') return report;
77
- // On Pro/Max the per-call dollar number isn't what you pay, so hide it.
78
- // Keep tokens visible — those are the real utilization signal.
79
98
  const zero = () => ({
80
99
  inputCost: 0,
81
100
  outputCost: 0,
82
101
  cacheCreation5mCost: 0,
83
102
  cacheCreation1hCost: 0,
84
103
  cacheReadCost: 0,
104
+ serverToolUseCost: 0,
85
105
  totalCost: 0,
86
106
  });
87
107
  return {
@@ -91,10 +111,33 @@ function planMask(report: LedgerReport, plan: Plan): LedgerReport {
91
111
  };
92
112
  }
93
113
 
114
+ // Materialize once so --summary can re-aggregate by 3 dimensions cheaply.
115
+ async function collectTurns(opts: { from: Date; to: Date }): Promise<SessionTurn[]> {
116
+ const out: SessionTurn[] = [];
117
+ for await (const t of parseAll(opts)) out.push(t);
118
+ return out;
119
+ }
120
+
121
+ async function* iterTurns(turns: SessionTurn[]): AsyncGenerator<SessionTurn> {
122
+ for (const t of turns) yield t;
123
+ }
124
+
94
125
  async function main(): Promise<void> {
95
126
  const args = parseArgs(process.argv.slice(2));
96
127
  const { from, to } = periodRange(args.period);
97
- let report = await aggregate(parseAll({ from, to }), from, to);
128
+ const turns = await collectTurns({ from, to });
129
+
130
+ if (args.summary) {
131
+ const [bySub, byMod, byDay] = await Promise.all([
132
+ aggregate(iterTurns(turns), from, to, 'subagent'),
133
+ aggregate(iterTurns(turns), from, to, 'model'),
134
+ aggregate(iterTurns(turns), from, to, 'day'),
135
+ ]);
136
+ console.log(formatSummary(bySub, byMod, byDay, args.period));
137
+ return;
138
+ }
139
+
140
+ let report = await aggregate(iterTurns(turns), from, to, args.by);
98
141
  report = planMask(report, args.plan);
99
142
 
100
143
  let output: string;
@@ -102,6 +145,8 @@ async function main(): Promise<void> {
102
145
  output = JSON.stringify(report, null, 2);
103
146
  } else if (args.markdown) {
104
147
  output = formatMarkdown(report, args.period);
148
+ } else if (args.by === 'day') {
149
+ output = formatTrend(report, args.period);
105
150
  } else {
106
151
  output = formatTable(report, args.period);
107
152
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-agent-ledger",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "See exactly where your Claude Code budget goes — per subagent, per task.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/aggregator.ts CHANGED
@@ -2,22 +2,28 @@ import { costFor } from './pricing.ts';
2
2
  import type { AggregatedRow, LedgerReport, SessionTurn } from './types.ts';
3
3
 
4
4
  const MAIN_AGENT_LABEL = '(main)';
5
+ const UNKNOWN_MODEL_LABEL = '(no-model)';
5
6
 
6
- function emptyRow(subagent: string): AggregatedRow {
7
+ export type GroupKey = 'subagent' | 'model' | 'day';
8
+
9
+ function emptyRow(label: string): AggregatedRow {
7
10
  return {
8
- subagent,
11
+ subagent: label,
9
12
  sessionCount: 0,
10
13
  inputTokens: 0,
11
14
  outputTokens: 0,
12
15
  cacheCreation5mTokens: 0,
13
16
  cacheCreation1hTokens: 0,
14
17
  cacheReadTokens: 0,
18
+ webSearchRequests: 0,
19
+ webFetchRequests: 0,
15
20
  cost: {
16
21
  inputCost: 0,
17
22
  outputCost: 0,
18
23
  cacheCreation5mCost: 0,
19
24
  cacheCreation1hCost: 0,
20
25
  cacheReadCost: 0,
26
+ serverToolUseCost: 0,
21
27
  totalCost: 0,
22
28
  },
23
29
  };
@@ -35,19 +41,32 @@ function splitCache(turn: SessionTurn): { fiveMin: number; oneHour: number } {
35
41
  return { fiveMin: usage.cache_creation_input_tokens ?? 0, oneHour: 0 };
36
42
  }
37
43
 
44
+ function keyOf(turn: SessionTurn, group: GroupKey): string {
45
+ if (group === 'model') return turn.model ?? UNKNOWN_MODEL_LABEL;
46
+ if (group === 'day') return turn.timestamp.slice(0, 10);
47
+ return turn.subagentType ?? MAIN_AGENT_LABEL;
48
+ }
49
+
50
+ function sortKey(group: GroupKey): (a: AggregatedRow, b: AggregatedRow) => number {
51
+ // For day groupings users want chronological; otherwise descend by cost.
52
+ if (group === 'day') return (a, b) => (a.subagent < b.subagent ? -1 : a.subagent > b.subagent ? 1 : 0);
53
+ return (a, b) => b.cost.totalCost - a.cost.totalCost;
54
+ }
55
+
38
56
  export async function aggregate(
39
57
  turns: AsyncIterable<SessionTurn>,
40
58
  from: Date,
41
59
  to: Date,
60
+ group: GroupKey = 'subagent',
42
61
  ): Promise<LedgerReport> {
43
- const rowsByAgent = new Map<string, AggregatedRow>();
44
- const sessionsByAgent = new Map<string, Set<string>>();
62
+ const rowsByKey = new Map<string, AggregatedRow>();
63
+ const sessionsByKey = new Map<string, Set<string>>();
45
64
 
46
65
  for await (const turn of turns) {
47
66
  if (turn.type !== 'assistant' || !turn.usage) continue;
48
67
 
49
- const agent = turn.subagentType ?? MAIN_AGENT_LABEL;
50
- const row = rowsByAgent.get(agent) ?? emptyRow(agent);
68
+ const key = keyOf(turn, group);
69
+ const row = rowsByKey.get(key) ?? emptyRow(key);
51
70
 
52
71
  const cost = costFor(turn.usage, turn.model);
53
72
  const { fiveMin, oneHour } = splitCache(turn);
@@ -57,25 +76,28 @@ export async function aggregate(
57
76
  row.cacheCreation5mTokens += fiveMin;
58
77
  row.cacheCreation1hTokens += oneHour;
59
78
  row.cacheReadTokens += turn.usage.cache_read_input_tokens ?? 0;
79
+ row.webSearchRequests += turn.usage.server_tool_use?.web_search_requests ?? 0;
80
+ row.webFetchRequests += turn.usage.server_tool_use?.web_fetch_requests ?? 0;
60
81
  row.cost.inputCost += cost.inputCost;
61
82
  row.cost.outputCost += cost.outputCost;
62
83
  row.cost.cacheCreation5mCost += cost.cacheCreation5mCost;
63
84
  row.cost.cacheCreation1hCost += cost.cacheCreation1hCost;
64
85
  row.cost.cacheReadCost += cost.cacheReadCost;
86
+ row.cost.serverToolUseCost += cost.serverToolUseCost;
65
87
  row.cost.totalCost += cost.totalCost;
66
88
 
67
- const sessions = sessionsByAgent.get(agent) ?? new Set<string>();
89
+ const sessions = sessionsByKey.get(key) ?? new Set<string>();
68
90
  sessions.add(turn.sessionId);
69
- sessionsByAgent.set(agent, sessions);
91
+ sessionsByKey.set(key, sessions);
70
92
 
71
- rowsByAgent.set(agent, row);
93
+ rowsByKey.set(key, row);
72
94
  }
73
95
 
74
- for (const [agent, row] of rowsByAgent) {
75
- row.sessionCount = sessionsByAgent.get(agent)?.size ?? 0;
96
+ for (const [key, row] of rowsByKey) {
97
+ row.sessionCount = sessionsByKey.get(key)?.size ?? 0;
76
98
  }
77
99
 
78
- const rows = [...rowsByAgent.values()].sort((a, b) => b.cost.totalCost - a.cost.totalCost);
100
+ const rows = [...rowsByKey.values()].sort(sortKey(group));
79
101
  const total = rows.reduce<AggregatedRow>((acc, row) => {
80
102
  acc.sessionCount += row.sessionCount;
81
103
  acc.inputTokens += row.inputTokens;
@@ -83,11 +105,14 @@ export async function aggregate(
83
105
  acc.cacheCreation5mTokens += row.cacheCreation5mTokens;
84
106
  acc.cacheCreation1hTokens += row.cacheCreation1hTokens;
85
107
  acc.cacheReadTokens += row.cacheReadTokens;
108
+ acc.webSearchRequests += row.webSearchRequests;
109
+ acc.webFetchRequests += row.webFetchRequests;
86
110
  acc.cost.inputCost += row.cost.inputCost;
87
111
  acc.cost.outputCost += row.cost.outputCost;
88
112
  acc.cost.cacheCreation5mCost += row.cost.cacheCreation5mCost;
89
113
  acc.cost.cacheCreation1hCost += row.cost.cacheCreation1hCost;
90
114
  acc.cost.cacheReadCost += row.cost.cacheReadCost;
115
+ acc.cost.serverToolUseCost += row.cost.serverToolUseCost;
91
116
  acc.cost.totalCost += row.cost.totalCost;
92
117
  return acc;
93
118
  }, emptyRow('total'));
package/src/format.ts CHANGED
@@ -1,8 +1,31 @@
1
1
  import type { AggregatedRow, LedgerReport } from './types.ts';
2
2
 
3
- const PAD_SUBAGENT = 22;
3
+ const PAD_LABEL = 22;
4
+ const BAR_WIDTH = 40;
5
+ const PLAN_MAX_PRICE = 200; // monthly USD reference for shadow-cost leverage
6
+
7
+ // Color helpers — TTY-aware, NO_COLOR-respecting (https://no-color.org/).
8
+ const USE_COLOR = (() => {
9
+ if (process.env.NO_COLOR) return false;
10
+ if (process.env.FORCE_COLOR) return true;
11
+ return Boolean(process.stdout.isTTY);
12
+ })();
13
+
14
+ const c = {
15
+ dim: USE_COLOR ? '\x1b[2m' : '',
16
+ bold: USE_COLOR ? '\x1b[1m' : '',
17
+ green: USE_COLOR ? '\x1b[32m' : '',
18
+ cyan: USE_COLOR ? '\x1b[36m' : '',
19
+ cyanBright: USE_COLOR ? '\x1b[96m' : '',
20
+ yellow: USE_COLOR ? '\x1b[33m' : '',
21
+ red: USE_COLOR ? '\x1b[31m' : '',
22
+ magenta: USE_COLOR ? '\x1b[35m' : '',
23
+ gray: USE_COLOR ? '\x1b[90m' : '',
24
+ reset: USE_COLOR ? '\x1b[0m' : '',
25
+ };
4
26
 
5
27
  function fmtTokens(n: number): string {
28
+ if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B`;
6
29
  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
7
30
  if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
8
31
  return String(n);
@@ -12,43 +35,279 @@ function fmtUsd(n: number): string {
12
35
  return `$${n.toFixed(2)}`;
13
36
  }
14
37
 
15
- function formatRow(row: AggregatedRow): string {
16
- const subagent = row.subagent.padEnd(PAD_SUBAGENT);
17
- const sessions = String(row.sessionCount).padStart(4);
38
+ function fmtUsdComma(n: number): string {
39
+ return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
40
+ }
41
+
42
+ function colorCost(n: number, padded: string): string {
43
+ if (!USE_COLOR) return padded;
44
+ if (n >= 1000) return `${c.bold}${c.red}${padded}${c.reset}`;
45
+ if (n >= 100) return `${c.yellow}${padded}${c.reset}`;
46
+ if (n >= 1) return `${c.green}${padded}${c.reset}`;
47
+ return `${c.dim}${padded}${c.reset}`;
48
+ }
49
+
50
+ function pct(part: number, whole: number): string {
51
+ if (whole === 0) return '0%';
52
+ return `${((part / whole) * 100).toFixed(0)}%`;
53
+ }
54
+
55
+ function formatRow(row: AggregatedRow, isTotal = false): string {
56
+ const labelText = row.subagent.padEnd(PAD_LABEL);
57
+ const label = isTotal
58
+ ? `${c.bold}${labelText}${c.reset}`
59
+ : `${c.cyan}${labelText}${c.reset}`;
60
+ const sessions = `${c.gray}${String(row.sessionCount).padStart(4)}${c.reset}`;
18
61
  const io = `${fmtTokens(row.inputTokens)} / ${fmtTokens(row.outputTokens)}`.padStart(18);
19
62
  const cached1h = fmtTokens(row.cacheCreation1hTokens).padStart(7);
20
63
  const cached5m = fmtTokens(row.cacheCreation5mTokens).padStart(7);
21
64
  const cacheRead = fmtTokens(row.cacheReadTokens).padStart(8);
22
- const cost = fmtUsd(row.cost.totalCost).padStart(9);
23
- return ` ${subagent}${sessions} ${io} 1h:${cached1h} 5m:${cached5m} r:${cacheRead} ${cost}`;
65
+ const costStr = fmtUsd(row.cost.totalCost).padStart(9);
66
+ const cost = colorCost(row.cost.totalCost, costStr);
67
+ return ` ${label}${sessions} ${io} ${c.dim}1h:${c.reset}${cached1h} ${c.dim}5m:${c.reset}${cached5m} ${c.dim}r:${c.reset}${cacheRead} ${cost}`;
68
+ }
69
+
70
+ // Auto-derived insights. The whole point of installing the tool over /cost.
71
+ function deriveInsights(report: LedgerReport): string[] {
72
+ const lines: string[] = [];
73
+ const total = report.total.cost.totalCost;
74
+ if (total === 0) return lines;
75
+
76
+ // Top contributor with %
77
+ const top = report.rows[0];
78
+ if (top && top.cost.totalCost > 0) {
79
+ lines.push(
80
+ ` ${c.cyanBright}⚡${c.reset} top: ${c.cyan}${top.subagent}${c.reset} drives ${c.bold}${pct(top.cost.totalCost, total)}${c.reset} of spend ${c.dim}(${fmtUsd(top.cost.totalCost)} of ${fmtUsd(total)})${c.reset}`,
81
+ );
82
+ }
83
+
84
+ // 1h cache call-out (if non-trivial)
85
+ const oneHourCost =
86
+ report.total.cost.cacheCreation1hCost ?? 0;
87
+ if (oneHourCost >= total * 0.05) {
88
+ lines.push(
89
+ ` ${c.yellow}💾${c.reset} 1h cache writes: ${fmtTokens(report.total.cacheCreation1hTokens)} tokens · ${fmtUsd(oneHourCost)} ${c.dim}(${pct(oneHourCost, total)} of total)${c.reset}`,
90
+ );
91
+ }
92
+
93
+ // Cache reuse ratio — read vs write
94
+ const writeTokens =
95
+ report.total.cacheCreation1hTokens + report.total.cacheCreation5mTokens;
96
+ if (writeTokens > 0) {
97
+ const ratio = report.total.cacheReadTokens / writeTokens;
98
+ const verdict =
99
+ ratio >= 20
100
+ ? `${c.green}excellent reuse${c.reset}`
101
+ : ratio >= 5
102
+ ? `${c.yellow}OK reuse${c.reset}`
103
+ : `${c.red}low reuse${c.reset}`;
104
+ lines.push(
105
+ ` ${c.cyanBright}📥${c.reset} cache reuse: ${fmtTokens(report.total.cacheReadTokens)} reads / ${fmtTokens(writeTokens)} writes = ${c.bold}${ratio.toFixed(0)}×${c.reset} ${c.dim}(${verdict}${c.dim})${c.reset}`,
106
+ );
107
+ }
108
+
109
+ // Plan leverage
110
+ const dayCount =
111
+ Math.max(
112
+ 1,
113
+ Math.round(
114
+ (report.period.to.getTime() - report.period.from.getTime()) /
115
+ (1000 * 60 * 60 * 24),
116
+ ),
117
+ );
118
+ const monthlyEquivalent = (total / dayCount) * 30;
119
+ const leverage = monthlyEquivalent / PLAN_MAX_PRICE;
120
+ if (leverage >= 1) {
121
+ lines.push(
122
+ ` ${c.magenta}💸${c.reset} ${fmtUsdComma(monthlyEquivalent)}/mo equivalent ${c.dim}vs $${PLAN_MAX_PRICE}/mo plan = ${c.reset}${c.bold}${leverage.toFixed(0)}×${c.reset} ${c.dim}leverage${c.reset}`,
123
+ );
124
+ }
125
+
126
+ return lines;
24
127
  }
25
128
 
26
129
  export function formatTable(report: LedgerReport, label: string): string {
27
- const header = `agent-ledger ${label} · ${report.period.from.toISOString().slice(0, 10)}` +
28
- ` → ${report.period.to.toISOString().slice(0, 10)}`;
29
-
30
- const cols = ' ' +
31
- 'subagent'.padEnd(PAD_SUBAGENT) +
32
- 'sess'.padStart(4) + ' ' +
33
- 'tokens(in/out)'.padStart(18) + ' ' +
34
- 'cache tokens (1h/5m/read)'.padStart(32) + ' ' +
130
+ const summaryLine =
131
+ `${c.gray}${report.period.from.toISOString().slice(0, 10)} → ${report.period.to.toISOString().slice(0, 10)}${c.reset}` +
132
+ ` ${c.dim}·${c.reset} ` +
133
+ `${c.bold}${fmtUsdComma(report.total.cost.totalCost)}${c.reset} ${c.dim}shadow${c.reset}` +
134
+ ` ${c.dim}·${c.reset} ${report.total.sessionCount} sessions` +
135
+ ` ${c.dim}·${c.reset} ${report.rows.length} ${label.includes('day') ? 'days' : label.includes('model') ? 'models' : 'agents'}`;
136
+
137
+ const header = `${c.bold}agent-ledger ${label}${c.reset} ${summaryLine}`;
138
+
139
+ const colsRaw =
140
+ ' ' +
141
+ 'subagent'.padEnd(PAD_LABEL) +
142
+ 'sess'.padStart(4) +
143
+ ' ' +
144
+ 'tokens(in/out)'.padStart(18) +
145
+ ' ' +
146
+ 'cache tokens (1h/5m/read)'.padStart(32) +
147
+ ' ' +
35
148
  'cost'.padStart(9);
149
+ const cols = `${c.gray}${colsRaw}${c.reset}`;
36
150
 
37
- const sep = ' ' + '─'.repeat(PAD_SUBAGENT + 4 + 2 + 18 + 2 + 32 + 2 + 9);
151
+ const sepRaw = ' ' + '─'.repeat(PAD_LABEL + 4 + 2 + 18 + 2 + 32 + 2 + 9);
152
+ const sep = `${c.gray}${sepRaw}${c.reset}`;
38
153
 
39
- return [
154
+ const lines = [
40
155
  header,
41
156
  '',
42
157
  cols,
43
158
  sep,
44
- ...report.rows.map(formatRow),
159
+ ...report.rows.map((r) => formatRow(r)),
45
160
  sep,
46
- formatRow({ ...report.total, subagent: 'total' }),
47
- ].join('\n');
161
+ formatRow({ ...report.total, subagent: 'total' }, true),
162
+ ];
163
+
164
+ // Server tool footer (only when present, since most rows have 0)
165
+ if (report.total.webSearchRequests + report.total.webFetchRequests > 0) {
166
+ lines.push('');
167
+ lines.push(
168
+ ` ${c.gray}server tools:${c.reset} ` +
169
+ `web_search ×${report.total.webSearchRequests}, ` +
170
+ `web_fetch ×${report.total.webFetchRequests} ` +
171
+ `(${fmtUsd(report.total.cost.serverToolUseCost)})`,
172
+ );
173
+ }
174
+
175
+ // Insights
176
+ const insights = deriveInsights(report);
177
+ if (insights.length) {
178
+ lines.push('');
179
+ lines.push(...insights);
180
+ }
181
+
182
+ return lines.join('\n');
183
+ }
184
+
185
+ // Daily trend chart with fixed-width bars.
186
+ export function formatTrend(report: LedgerReport, label: string): string {
187
+ const max = report.rows.reduce((m, r) => Math.max(m, r.cost.totalCost), 0);
188
+
189
+ const summaryLine =
190
+ `${c.gray}${report.period.from.toISOString().slice(0, 10)} → ${report.period.to.toISOString().slice(0, 10)}${c.reset}` +
191
+ ` ${c.dim}·${c.reset} ` +
192
+ `${c.bold}${fmtUsdComma(report.total.cost.totalCost)}${c.reset} ${c.dim}shadow${c.reset}` +
193
+ ` ${c.dim}·${c.reset} ${report.total.sessionCount} sessions across ${report.rows.length} days`;
194
+
195
+ const header = `${c.bold}agent-ledger ${label} (daily)${c.reset} ${summaryLine}`;
196
+
197
+ const lines = [header, ''];
198
+ for (const row of report.rows) {
199
+ const bars = Math.round((row.cost.totalCost / Math.max(max, 0.01)) * BAR_WIDTH);
200
+ const barFilled = '█'.repeat(bars);
201
+ const barPadding = ' '.repeat(BAR_WIDTH - bars);
202
+ const bar = `${USE_COLOR ? c.cyanBright : ''}${barFilled}${USE_COLOR ? c.reset : ''}${barPadding}`;
203
+ const dateLabel = `${c.gray}${row.subagent}${c.reset}`;
204
+ const cost = colorCost(row.cost.totalCost, fmtUsd(row.cost.totalCost).padStart(10));
205
+ const sessionsRaw = `${row.sessionCount} sess`;
206
+ const sessions = `${c.dim}· ${sessionsRaw}${c.reset}`;
207
+ lines.push(` ${dateLabel} ${cost} ${bar} ${sessions}`);
208
+ }
209
+ const sepWidth = 12 + 2 + 10 + 2 + BAR_WIDTH + 2 + 10;
210
+ lines.push(` ${c.gray}${'─'.repeat(sepWidth)}${c.reset}`);
211
+ const totalLabel = `${c.bold}total${c.reset}`.padEnd(12 + (USE_COLOR ? c.bold.length + c.reset.length : 0));
212
+ const totalCost = colorCost(
213
+ report.total.cost.totalCost,
214
+ fmtUsd(report.total.cost.totalCost).padStart(10),
215
+ );
216
+ lines.push(` ${totalLabel} ${totalCost}`);
217
+
218
+ // Daily-mode insights
219
+ const insights = deriveDailyInsights(report);
220
+ if (insights.length) {
221
+ lines.push('');
222
+ lines.push(...insights);
223
+ }
224
+
225
+ return lines.join('\n');
226
+ }
227
+
228
+ function deriveDailyInsights(report: LedgerReport): string[] {
229
+ if (report.rows.length === 0) return [];
230
+ const lines: string[] = [];
231
+ const peak = [...report.rows].sort((a, b) => b.cost.totalCost - a.cost.totalCost)[0]!;
232
+ const trough = [...report.rows]
233
+ .filter((r) => r.cost.totalCost > 0)
234
+ .sort((a, b) => a.cost.totalCost - b.cost.totalCost)[0];
235
+ const avg = report.total.cost.totalCost / report.rows.length;
236
+
237
+ lines.push(
238
+ ` ${c.cyanBright}⚡${c.reset} peak: ${c.cyan}${peak.subagent}${c.reset} ${c.bold}${fmtUsd(peak.cost.totalCost)}${c.reset} ${c.dim}(${peak.sessionCount} sess)${c.reset}`,
239
+ );
240
+ lines.push(
241
+ ` ${c.gray}📊${c.reset} avg/day: ${fmtUsd(avg)}`,
242
+ );
243
+ if (trough && trough.cost.totalCost > 0) {
244
+ const variance = peak.cost.totalCost / trough.cost.totalCost;
245
+ if (variance >= 3) {
246
+ lines.push(
247
+ ` ${c.yellow}🌪${c.reset} variance: ${c.bold}${variance.toFixed(0)}×${c.reset} ${c.dim}between peak (${peak.subagent}) and trough (${trough.subagent})${c.reset}`,
248
+ );
249
+ }
250
+ }
251
+ return lines;
252
+ }
253
+
254
+ // Single-screen dashboard. Aggregates the same period across 3 dimensions.
255
+ export function formatSummary(
256
+ bySubagent: LedgerReport,
257
+ byModel: LedgerReport,
258
+ byDay: LedgerReport,
259
+ label: string,
260
+ ): string {
261
+ const total = bySubagent.total.cost.totalCost;
262
+ const dayCount = Math.max(1, byDay.rows.length);
263
+ const monthlyEquivalent = (total / dayCount) * 30;
264
+ const leverage = monthlyEquivalent / PLAN_MAX_PRICE;
265
+
266
+ const top = (rows: AggregatedRow[]) => rows[0];
267
+ const tSub = top(bySubagent.rows);
268
+ const tMod = top(byModel.rows);
269
+ const tDay = [...byDay.rows].sort((a, b) => b.cost.totalCost - a.cost.totalCost)[0];
270
+
271
+ const k = (label: string, value: string) =>
272
+ ` ${c.gray}${label.padEnd(20)}${c.reset} ${value}`;
273
+ const dollar = (n: number) => `${c.bold}${fmtUsdComma(n)}${c.reset}`;
274
+
275
+ const writeTokens =
276
+ bySubagent.total.cacheCreation1hTokens + bySubagent.total.cacheCreation5mTokens;
277
+ const reuseRatio = writeTokens > 0 ? bySubagent.total.cacheReadTokens / writeTokens : 0;
278
+
279
+ const lines = [
280
+ `${c.bold}agent-ledger ${label} summary${c.reset} ${c.gray}${bySubagent.period.from.toISOString().slice(0, 10)} → ${bySubagent.period.to.toISOString().slice(0, 10)}${c.reset}`,
281
+ '',
282
+ k('Shadow cost', dollar(total)),
283
+ k('Sessions', `${bySubagent.total.sessionCount}`),
284
+ k('Plan reference', `$${PLAN_MAX_PRICE}/mo (Max)`),
285
+ k('Multiplier', `${c.bold}${leverage.toFixed(0)}×${c.reset} ${c.dim}vs Max plan${c.reset}`),
286
+ '',
287
+ k('Top subagent', tSub ? `${c.cyan}${tSub.subagent}${c.reset} ${dollar(tSub.cost.totalCost)} ${c.dim}(${pct(tSub.cost.totalCost, total)})${c.reset}` : '—'),
288
+ k('Top model', tMod ? `${c.cyan}${tMod.subagent}${c.reset} ${dollar(tMod.cost.totalCost)} ${c.dim}(${pct(tMod.cost.totalCost, total)})${c.reset}` : '—'),
289
+ k('Peak day', tDay ? `${c.cyan}${tDay.subagent}${c.reset} ${dollar(tDay.cost.totalCost)}` : '—'),
290
+ '',
291
+ k('Cache 1h writes', `${fmtTokens(bySubagent.total.cacheCreation1hTokens)} tokens · ${fmtUsd(bySubagent.total.cost.cacheCreation1hCost)}`),
292
+ k('Cache reads', `${fmtTokens(bySubagent.total.cacheReadTokens)} tokens · ${fmtUsd(bySubagent.total.cost.cacheReadCost)}`),
293
+ k(
294
+ 'Cache reuse',
295
+ writeTokens > 0
296
+ ? `${c.bold}${reuseRatio.toFixed(0)}×${c.reset} ${c.dim}reads/writes${c.reset}`
297
+ : 'n/a',
298
+ ),
299
+ k(
300
+ 'Server tools',
301
+ bySubagent.total.webSearchRequests + bySubagent.total.webFetchRequests > 0
302
+ ? `web_search ×${bySubagent.total.webSearchRequests}, web_fetch ×${bySubagent.total.webFetchRequests} (${fmtUsd(bySubagent.total.cost.serverToolUseCost)})`
303
+ : `${c.dim}none${c.reset}`,
304
+ ),
305
+ ];
306
+ return lines.join('\n');
48
307
  }
49
308
 
50
309
  export function formatMarkdown(report: LedgerReport, label: string): string {
51
- const header = '| subagent | sessions | input | output | cache 1h | cache 5m | cache read | cost |';
310
+ const header = '| group | sessions | input | output | cache 1h | cache 5m | cache read | cost |';
52
311
  const sep = '|---|---:|---:|---:|---:|---:|---:|---:|';
53
312
  const rows = report.rows.map(
54
313
  (r) =>
@@ -66,7 +325,7 @@ export function formatMarkdown(report: LedgerReport, label: string): string {
66
325
  report.total.cacheReadTokens,
67
326
  )} | **${fmtUsd(report.total.cost.totalCost)}** |`;
68
327
 
69
- return [
328
+ const lines = [
70
329
  `# agent-ledger · ${label}`,
71
330
  `_${report.period.from.toISOString().slice(0, 10)} → ${report.period.to.toISOString().slice(0, 10)}_`,
72
331
  '',
@@ -74,5 +333,14 @@ export function formatMarkdown(report: LedgerReport, label: string): string {
74
333
  sep,
75
334
  ...rows,
76
335
  totalRow,
77
- ].join('\n');
336
+ ];
337
+
338
+ if (report.total.webSearchRequests + report.total.webFetchRequests > 0) {
339
+ lines.push('');
340
+ lines.push(
341
+ `_Server tools: web_search ×${report.total.webSearchRequests}, web_fetch ×${report.total.webFetchRequests} (${fmtUsd(report.total.cost.serverToolUseCost)})_`,
342
+ );
343
+ }
344
+
345
+ return lines.join('\n');
78
346
  }
package/src/pricing.ts CHANGED
@@ -30,6 +30,12 @@ const CACHE_5M_MULTIPLIER = 1.25;
30
30
  const CACHE_1H_MULTIPLIER = 2.0;
31
31
  const CACHE_READ_MULTIPLIER = 0.1;
32
32
 
33
+ // Anthropic server-tool pricing (https://www.anthropic.com/pricing as of 2026-04):
34
+ // Web search: $10 per 1,000 requests = $0.01 / request
35
+ // Web fetch: treated identically here pending Anthropic split (file an issue if wrong)
36
+ const WEB_SEARCH_PRICE_PER_REQUEST = 0.01;
37
+ const WEB_FETCH_PRICE_PER_REQUEST = 0.01;
38
+
33
39
  function resolveModel(model: ModelId | undefined): { input: number; output: number } {
34
40
  if (!model) return { input: 0, output: 0 };
35
41
  if (FREE_MODELS.has(model)) return { input: 0, output: 0 };
@@ -77,13 +83,26 @@ export function costFor(usage: TokenUsage, model: ModelId | undefined): CostBrea
77
83
  const cacheCreation1hCost = perMillion(oneHour, rate.input * CACHE_1H_MULTIPLIER);
78
84
  const cacheReadCost = perMillion(cacheRead, rate.input * CACHE_READ_MULTIPLIER);
79
85
 
86
+ const webSearchReqs = usage.server_tool_use?.web_search_requests ?? 0;
87
+ const webFetchReqs = usage.server_tool_use?.web_fetch_requests ?? 0;
88
+ const serverToolUseCost =
89
+ webSearchReqs * WEB_SEARCH_PRICE_PER_REQUEST +
90
+ webFetchReqs * WEB_FETCH_PRICE_PER_REQUEST;
91
+
80
92
  return {
81
93
  inputCost,
82
94
  outputCost,
83
95
  cacheCreation5mCost,
84
96
  cacheCreation1hCost,
85
97
  cacheReadCost,
86
- totalCost: inputCost + outputCost + cacheCreation5mCost + cacheCreation1hCost + cacheReadCost,
98
+ serverToolUseCost,
99
+ totalCost:
100
+ inputCost +
101
+ outputCost +
102
+ cacheCreation5mCost +
103
+ cacheCreation1hCost +
104
+ cacheReadCost +
105
+ serverToolUseCost,
87
106
  };
88
107
  }
89
108
 
package/src/types.ts CHANGED
@@ -11,6 +11,11 @@ export interface CacheCreationDetail {
11
11
  ephemeral_1h_input_tokens?: number;
12
12
  }
13
13
 
14
+ export interface ServerToolUse {
15
+ web_search_requests?: number;
16
+ web_fetch_requests?: number;
17
+ }
18
+
14
19
  export interface TokenUsage {
15
20
  input_tokens: number;
16
21
  output_tokens: number;
@@ -19,6 +24,8 @@ export interface TokenUsage {
19
24
  cache_creation_input_tokens?: number;
20
25
  cache_read_input_tokens?: number;
21
26
  cache_creation?: CacheCreationDetail;
27
+ // Anthropic server-side tool use (web_search, web_fetch). Priced per request, not per token.
28
+ server_tool_use?: ServerToolUse;
22
29
  }
23
30
 
24
31
  export interface SessionTurn {
@@ -37,6 +44,7 @@ export interface CostBreakdown {
37
44
  cacheCreation5mCost: number;
38
45
  cacheCreation1hCost: number;
39
46
  cacheReadCost: number;
47
+ serverToolUseCost: number;
40
48
  totalCost: number;
41
49
  }
42
50
 
@@ -48,6 +56,8 @@ export interface AggregatedRow {
48
56
  cacheCreation5mTokens: number;
49
57
  cacheCreation1hTokens: number;
50
58
  cacheReadTokens: number;
59
+ webSearchRequests: number;
60
+ webFetchRequests: number;
51
61
  cost: CostBreakdown;
52
62
  }
53
63
 
@@ -47,4 +47,29 @@ describe('aggregate()', () => {
47
47
  const sumCost = report.rows.reduce((s, r) => s + r.cost.totalCost, 0);
48
48
  expect(report.total.cost.totalCost).toBeCloseTo(sumCost, 6);
49
49
  });
50
+
51
+ test('--by model groups by model id', async () => {
52
+ const from = new Date('2026-04-21T00:00:00Z');
53
+ const to = new Date('2026-04-21T23:59:59Z');
54
+ const report = await aggregate(parseFile(FIXTURE), from, to, 'model');
55
+
56
+ // Fixture has assistant turns with claude-opus-4-7 model
57
+ const labels = report.rows.map((r) => r.subagent);
58
+ expect(labels.some((l) => l.includes('claude-') || l === '(no-model)')).toBe(true);
59
+ });
60
+
61
+ test('--by day groups by ISO date and sorts chronologically', async () => {
62
+ const from = new Date('2026-04-21T00:00:00Z');
63
+ const to = new Date('2026-04-21T23:59:59Z');
64
+ const report = await aggregate(parseFile(FIXTURE), from, to, 'day');
65
+
66
+ // Each row label should look like a date YYYY-MM-DD
67
+ for (const row of report.rows) {
68
+ expect(row.subagent).toMatch(/^\d{4}-\d{2}-\d{2}$/);
69
+ }
70
+ // Ascending date order
71
+ for (let i = 1; i < report.rows.length; i++) {
72
+ expect(report.rows[i - 1]!.subagent <= report.rows[i]!.subagent).toBe(true);
73
+ }
74
+ });
50
75
  });
@@ -107,4 +107,38 @@ describe('costFor()', () => {
107
107
  expect(isKnownModel('something-entirely-unknown')).toBe(false);
108
108
  expect(isKnownModel(undefined)).toBe(false);
109
109
  });
110
+
111
+ test('server_tool_use: web_search at $0.01/req', () => {
112
+ const cost = costFor(
113
+ {
114
+ input_tokens: 0,
115
+ output_tokens: 0,
116
+ server_tool_use: { web_search_requests: 100 },
117
+ },
118
+ 'claude-opus-4-7',
119
+ );
120
+ // 100 × $0.01 = $1.00
121
+ expect(cost.serverToolUseCost).toBeCloseTo(1.0, 6);
122
+ expect(cost.totalCost).toBeCloseTo(1.0, 6);
123
+ });
124
+
125
+ test('server_tool_use: web_fetch counted, summed with web_search', () => {
126
+ const cost = costFor(
127
+ {
128
+ input_tokens: 0,
129
+ output_tokens: 0,
130
+ server_tool_use: { web_search_requests: 50, web_fetch_requests: 50 },
131
+ },
132
+ 'claude-opus-4-7',
133
+ );
134
+ expect(cost.serverToolUseCost).toBeCloseTo(1.0, 6);
135
+ });
136
+
137
+ test('server_tool_use absent → no cost', () => {
138
+ const cost = costFor(
139
+ { input_tokens: 1000, output_tokens: 1000 },
140
+ 'claude-opus-4-7',
141
+ );
142
+ expect(cost.serverToolUseCost).toBe(0);
143
+ });
110
144
  });