claude-agent-ledger 0.1.0 → 0.2.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,42 @@
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.2.0] — 2026-04-21
9
+
10
+ ### Added
11
+ - `--by <subagent|model|day>` flag — group rows by subagent (default), by
12
+ Claude model, or by date. The `day` mode renders an ASCII bar chart of
13
+ daily spend, useful for spotting "expensive Tuesdays."
14
+ - Server-tool pricing: `web_search_requests` and `web_fetch_requests` from
15
+ the `server_tool_use` block are now counted at $0.01/request and surface
16
+ in a footer row when present.
17
+ - ANSI color output, TTY-aware (NO_COLOR / FORCE_COLOR respected).
18
+ - Subagent / model / day labels in cyan
19
+ - Cost amounts color-coded by magnitude (red ≥$1000, yellow ≥$100,
20
+ green ≥$1, dim <$1)
21
+ - Total row bolded
22
+ - New tests for server-tool pricing, `--by model`, and `--by day`
23
+ (22 tests, up from 17).
24
+
25
+ ### Changed
26
+ - `aggregate(turns, from, to)` accepts an optional 4th arg `group: GroupKey`
27
+ (`'subagent' | 'model' | 'day'`). Default behavior unchanged.
28
+ - `AggregatedRow` now carries `webSearchRequests` and `webFetchRequests`
29
+ counters; `CostBreakdown` adds `serverToolUseCost`.
30
+
31
+ ### Not yet
32
+ - Calibration against real Anthropic invoices (need volunteers — see
33
+ `.github/ISSUE_TEMPLATE/`)
34
+ - Cost forecasting (trailing 7-day burn rate)
35
+ - Homebrew tap
36
+
37
+ ## [0.1.0] — 2026-04-21
38
+
39
+ Initial public release.
9
40
 
10
41
  ### Added
11
42
  - JSONL parser with recursive walk of `~/.claude/projects/**`, including nested
@@ -21,13 +52,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
21
52
  - Family-prefix fallback for unknown future models (e.g. `claude-opus-5-*`
22
53
  resolves to Opus pricing)
23
54
  - `<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.
55
+ - 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,70 @@
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 today # today's spend, by subagent
31
+ agent-ledger week --by model # last 7 days, by Claude model
32
+ agent-ledger week --by day # daily bar chart
33
+ agent-ledger month --md > report.md
34
+ ```
35
35
 
36
- # Last 7 days
37
- agent-ledger week
36
+ No server. No account. No data leaves your machine.
38
37
 
39
- # Last 30 days + markdown export
40
- agent-ledger month --md > month-report.md
41
- ```
38
+ ## Example output (real)
42
39
 
43
- ## Example output
40
+ ### Default — by subagent
44
41
 
45
42
  ```
46
43
  agent-ledger week · 2026-04-14 → 2026-04-21
47
44
 
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
45
+ subagent sess tokens(in/out) cache tokens (1h/5m/read) cost
46
+ ───────────────────────────────────────────────────────────────────────────────────────────
47
+ (main) 28 514K / 15.87M 1h:116.10M 5m: 0 r:5229.48M $12524.97
48
+ general-purpose 148 113K / 955K 1h: 0 5m: 20.67M r: 183.94M $599.97
49
+ Swift Developer 13 4K / 574K 1h: 0 5m: 1.61M r: 47.55M $28.93
50
+ Market Researcher 10 107K / 74K 1h: 0 5m: 863K r: 8.61M $7.26
51
+ Explore 13 176K / 77K 1h: 0 5m: 2.60M r: 22.38M $6.47
52
+ zstackws 3 232 / 20K 1h: 0 5m: 829K r: 3.52M $4.47
53
+ claude-code-guide 1 2K / 2K 1h: 0 5m: 56K r: 223K $0.08
54
+ ───────────────────────────────────────────────────────────────────────────────────────────
55
+ total 216 916K / 17.57M 1h:116.10M 5m: 26.62M r:5495.71M $13172.14
56
+ ```
57
+
58
+ ### `--by model`
59
+
60
+ ```
61
+ subagent sess tokens(in/out) cache tokens (1h/5m/read) cost
62
+ ───────────────────────────────────────────────────────────────────────────────────────────
63
+ claude-opus-4-7 16 59K / 8.35M 1h: 61.15M 5m: 2.32M r:2974.59M $6967.29
64
+ claude-opus-4-6 127 544K / 8.25M 1h: 55.02M 5m: 13.30M r:2422.18M $6160.46
65
+ claude-sonnet-4-6 65 135K / 946K 1h: 0 5m: 8.81M r: 98.12M $77.07
66
+ claude-haiku-4-5 13 178K / 67K 1h: 0 5m: 2.19M r: 21.57M $4.33
67
+ ```
68
+
69
+ ### `--by day` (ASCII bar chart)
70
+
71
+ ```
72
+ 2026-04-15 $3749.84 ████████████████████████████████████ (18 sess)
73
+ 2026-04-16 $2492.99 ████████████████████████ (175 sess)
74
+ 2026-04-17 $2111.63 ████████████████████ (9 sess)
75
+ 2026-04-18 $1840.02 ██████████████████ (18 sess)
76
+ 2026-04-19 $644.97 ██████ (5 sess)
77
+ 2026-04-20 $1095.58 ███████████ (6 sess)
78
+ 2026-04-21 $1273.55 ████████████ (9 sess)
59
79
  ```
60
80
 
61
81
  Yes those are real numbers. Yes it's a lot. See [Shadow cost vs actual bill](#shadow-cost-vs-actual-bill).
@@ -64,24 +84,27 @@ Yes those are real numbers. Yes it's a lot. See [Shadow cost vs actual bill](#sh
64
84
 
65
85
  The `cost` column shows what you **would** pay at Anthropic's pay-as-you-go API rates.
66
86
 
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:
87
+ 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
88
 
69
89
  - Understanding where time-and-token budget goes, regardless of how you pay
70
90
  - Spotting runaway agents (a 10× spike is still a 10× spike, capped or not)
71
91
  - Deciding when to downshift to Haiku or up to Opus per task
92
+ - Justifying a Max subscription internally ("our shadow cost is $13K/wk, Max is $200/mo — that's a 65× discount")
72
93
 
73
- If you're on pay-as-you-go, the `cost` column is roughly your bill (±5% depending on tier / geography).
94
+ 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
95
 
75
96
  ## How it works
76
97
 
77
98
  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`).
99
+ Each turn records input/output tokens, cache tokens (with TTL split), the model used,
100
+ and any `server_tool_use` (web_search / web_fetch) requests. Subagent invocations live
101
+ under `<session>/subagents/agent-*.jsonl`, with a sidecar `.meta.json` carrying the real
102
+ subagent name (e.g. `Reality Checker`, `ios-factory`).
81
103
 
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.
104
+ `claude-agent-ledger` walks the tree, resolves subagent types from the sidecar metadata,
105
+ and aggregates by your chosen key (subagent, model, or day). Pricing is applied locally
106
+ from `src/pricing.ts`. Nothing is sent anywhere — the tool never touches the network
107
+ after install.
85
108
 
86
109
  ### Cache accounting
87
110
 
@@ -93,47 +116,71 @@ Anthropic's prompt caching has two TTLs in current logs:
93
116
  | `ephemeral_1h_input_tokens` | 2.00× base input | 1-hour TTL (long-lived sessions) |
94
117
  | `cache_read_input_tokens` | 0.10× base input | Read price (90% discount, TTL-agnostic) |
95
118
 
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`.
119
+ 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`.
120
+
121
+ ### Server tool pricing
122
+
123
+ `web_search_requests` and `web_fetch_requests` are counted at $0.01/request and shown in a footer when present:
124
+
125
+ ```
126
+ server tools: web_search ×7, web_fetch ×0 ($0.07)
127
+ ```
128
+
129
+ If Anthropic's actual rate differs (or web_fetch has its own price), open an issue.
130
+
131
+ ## Flags
132
+
133
+ | Flag | What it does |
134
+ |---|---|
135
+ | `--by <subagent\|model\|day>` | Group rows. Default: `subagent`. `day` renders as ASCII bar chart. |
136
+ | `--md` | Markdown table output (good for committing or sharing) |
137
+ | `--json` | Raw JSON output (pipe into jq) |
138
+ | `--plan pro\|max` | Hide dollar columns; show token utilization only |
139
+
140
+ | Env | What it does |
141
+ |---|---|
142
+ | `NO_COLOR=1` | Disable ANSI colors |
143
+ | `FORCE_COLOR=1` | Force ANSI colors when piping |
97
144
 
98
145
  ## What it's not
99
146
 
100
147
  - Not a real-time dashboard (post-hoc log reader)
101
- - Not a tracker for direct Anthropic API calls (those logs live elsewhere)
148
+ - Not a tracker for direct Anthropic API calls (those logs live server-side)
102
149
  - Not a cost-limiter or kill-switch (read-only, by design)
103
150
  - Not affiliated with Anthropic
104
151
 
105
152
  ## Who this is for
106
153
 
107
154
  - 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
155
+ - You're on Claude Max and want to know what your "free" usage is worth
109
156
  - You're building AI Agent systems and want observability primitives, not vendor dashboards
110
157
 
111
158
  ## Status
112
159
 
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).
160
+ `v0.2.0` shipped 2026-04-21. Validated on ~200K turns of real Claude Code logs.
161
+ Pricing matches Anthropic published rates as of 2026-04-21. Shadow cost values
162
+ may drift ±5-10% from Anthropic's actual invoice in edge cases (retries, tier
163
+ bumps, `server_tool_use` calibration). Calibration PRs welcome — see issue
164
+ templates.
117
165
 
118
166
  - [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`)
167
+ - [x] Per-subagent aggregation
168
+ - [x] Per-model aggregation (`--by model`)
169
+ - [x] Per-day aggregation with bar chart (`--by day`)
170
+ - [x] Terminal table + Markdown + JSON output
171
+ - [x] ANSI color, TTY-aware
122
172
  - [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`)
173
+ - [x] `--plan pro|max` to mask shadow cost
174
+ - [x] `server_tool_use` pricing (web_search, web_fetch)
126
175
  - [ ] Cost forecasting (trailing 7-day burn rate)
127
- - [ ] `server_tool_use` pricing (web search, file fetch)
176
+ - [ ] Calibration against real Anthropic invoices
128
177
  - [ ] Homebrew tap
129
178
 
130
- First tagged release: **2026-05-05**.
131
-
132
179
  ## Install from source
133
180
 
134
181
  ```bash
135
- git clone https://github.com/XJM-free/agent-ledger.git
136
- cd agent-ledger
182
+ git clone https://github.com/XJM-free/claude-agent-ledger.git
183
+ cd claude-agent-ledger
137
184
  bun install
138
185
  bun link
139
186
  agent-ledger today
@@ -142,15 +189,14 @@ agent-ledger today
142
189
  ## Development
143
190
 
144
191
  ```bash
145
- bun test
192
+ bun test # 22 pass, 0 fail
146
193
  bun run typecheck
147
194
  ```
148
195
 
149
196
  ## Author
150
197
 
151
198
  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).
199
+ subagents for infrastructure work. More context coming on the blog.
154
200
 
155
201
  ## License
156
202
 
@@ -1,6 +1,6 @@
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, formatTable, formatTrend } from '../src/format.ts';
4
4
  import { parseAll } from '../src/parser.ts';
5
5
  import type { LedgerReport } from '../src/types.ts';
6
6
 
@@ -12,6 +12,7 @@ interface Args {
12
12
  markdown: boolean;
13
13
  json: boolean;
14
14
  plan: Plan;
15
+ by: GroupKey;
15
16
  }
16
17
 
17
18
  function periodRange(period: Period): { from: Date; to: Date } {
@@ -33,13 +34,21 @@ function usage(): never {
33
34
  console.error(`Usage: agent-ledger <today|week|month> [flags]
34
35
 
35
36
  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 $.)
37
+ --by <subagent|model|day> Group rows by subagent (default), model, or day.
38
+ "day" renders as a daily trend bar chart.
39
+ --md Emit Markdown table (good for sharing / commits)
40
+ --json Emit raw JSON (for piping into other tools)
41
+ --plan pro|max Suppress dollar columns (token utilization only)
42
+ Default is pay-as-you-go: shows shadow cost in $.
43
+
44
+ Environment:
45
+ NO_COLOR=1 Disable ANSI colors
46
+ FORCE_COLOR=1 Force colors even when piping
40
47
 
41
48
  Examples:
42
49
  agent-ledger today
50
+ agent-ledger week --by model
51
+ agent-ledger week --by day # daily bar chart
43
52
  agent-ledger week --md > week.md
44
53
  agent-ledger month --json | jq '.total.cost.totalCost'
45
54
  agent-ledger week --plan max
@@ -52,6 +61,7 @@ function parseArgs(argv: string[]): Args {
52
61
  if (!period || !['today', 'week', 'month'].includes(period)) usage();
53
62
 
54
63
  let plan: Plan = 'payg';
64
+ let by: GroupKey = 'subagent';
55
65
  for (let i = 0; i < rest.length; i++) {
56
66
  const flag = rest[i];
57
67
  if (flag === '--plan') {
@@ -62,6 +72,14 @@ function parseArgs(argv: string[]): Args {
62
72
  } else {
63
73
  usage();
64
74
  }
75
+ } else if (flag === '--by') {
76
+ const next = rest[i + 1];
77
+ if (next === 'subagent' || next === 'model' || next === 'day') {
78
+ by = next;
79
+ i++;
80
+ } else {
81
+ usage();
82
+ }
65
83
  }
66
84
  }
67
85
  return {
@@ -69,19 +87,19 @@ function parseArgs(argv: string[]): Args {
69
87
  markdown: rest.includes('--md'),
70
88
  json: rest.includes('--json'),
71
89
  plan,
90
+ by,
72
91
  };
73
92
  }
74
93
 
75
94
  function planMask(report: LedgerReport, plan: Plan): LedgerReport {
76
95
  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
96
  const zero = () => ({
80
97
  inputCost: 0,
81
98
  outputCost: 0,
82
99
  cacheCreation5mCost: 0,
83
100
  cacheCreation1hCost: 0,
84
101
  cacheReadCost: 0,
102
+ serverToolUseCost: 0,
85
103
  totalCost: 0,
86
104
  });
87
105
  return {
@@ -94,7 +112,7 @@ function planMask(report: LedgerReport, plan: Plan): LedgerReport {
94
112
  async function main(): Promise<void> {
95
113
  const args = parseArgs(process.argv.slice(2));
96
114
  const { from, to } = periodRange(args.period);
97
- let report = await aggregate(parseAll({ from, to }), from, to);
115
+ let report = await aggregate(parseAll({ from, to }), from, to, args.by);
98
116
  report = planMask(report, args.plan);
99
117
 
100
118
  let output: string;
@@ -102,6 +120,8 @@ async function main(): Promise<void> {
102
120
  output = JSON.stringify(report, null, 2);
103
121
  } else if (args.markdown) {
104
122
  output = formatMarkdown(report, args.period);
123
+ } else if (args.by === 'day') {
124
+ output = formatTrend(report, args.period);
105
125
  } else {
106
126
  output = formatTable(report, args.period);
107
127
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-agent-ledger",
3
- "version": "0.1.0",
3
+ "version": "0.2.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,6 +1,25 @@
1
1
  import type { AggregatedRow, LedgerReport } from './types.ts';
2
2
 
3
- const PAD_SUBAGENT = 22;
3
+ const PAD_LABEL = 22;
4
+
5
+ // Color helpers — TTY-aware, NO_COLOR-respecting (https://no-color.org/).
6
+ const USE_COLOR = (() => {
7
+ if (process.env.NO_COLOR) return false;
8
+ if (process.env.FORCE_COLOR) return true;
9
+ return Boolean(process.stdout.isTTY);
10
+ })();
11
+
12
+ const c = {
13
+ dim: USE_COLOR ? '\x1b[2m' : '',
14
+ bold: USE_COLOR ? '\x1b[1m' : '',
15
+ green: USE_COLOR ? '\x1b[32m' : '',
16
+ cyan: USE_COLOR ? '\x1b[36m' : '',
17
+ yellow: USE_COLOR ? '\x1b[33m' : '',
18
+ red: USE_COLOR ? '\x1b[31m' : '',
19
+ magenta: USE_COLOR ? '\x1b[35m' : '',
20
+ gray: USE_COLOR ? '\x1b[90m' : '',
21
+ reset: USE_COLOR ? '\x1b[0m' : '',
22
+ };
4
23
 
5
24
  function fmtTokens(n: number): string {
6
25
  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
@@ -12,43 +31,104 @@ function fmtUsd(n: number): string {
12
31
  return `$${n.toFixed(2)}`;
13
32
  }
14
33
 
15
- function formatRow(row: AggregatedRow): string {
16
- const subagent = row.subagent.padEnd(PAD_SUBAGENT);
17
- const sessions = String(row.sessionCount).padStart(4);
34
+ function colorCost(n: number, padded: string): string {
35
+ if (!USE_COLOR) return padded;
36
+ if (n >= 1000) return `${c.bold}${c.red}${padded}${c.reset}`;
37
+ if (n >= 100) return `${c.yellow}${padded}${c.reset}`;
38
+ if (n >= 1) return `${c.green}${padded}${c.reset}`;
39
+ return `${c.dim}${padded}${c.reset}`;
40
+ }
41
+
42
+ function formatRow(row: AggregatedRow, isTotal = false): string {
43
+ const labelText = row.subagent.padEnd(PAD_LABEL);
44
+ const label = isTotal
45
+ ? `${c.bold}${labelText}${c.reset}`
46
+ : `${c.cyan}${labelText}${c.reset}`;
47
+ const sessions = `${c.gray}${String(row.sessionCount).padStart(4)}${c.reset}`;
18
48
  const io = `${fmtTokens(row.inputTokens)} / ${fmtTokens(row.outputTokens)}`.padStart(18);
19
49
  const cached1h = fmtTokens(row.cacheCreation1hTokens).padStart(7);
20
50
  const cached5m = fmtTokens(row.cacheCreation5mTokens).padStart(7);
21
51
  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}`;
52
+ const costStr = fmtUsd(row.cost.totalCost).padStart(9);
53
+ const cost = colorCost(row.cost.totalCost, costStr);
54
+ return ` ${label}${sessions} ${io} ${c.dim}1h:${c.reset}${cached1h} ${c.dim}5m:${c.reset}${cached5m} ${c.dim}r:${c.reset}${cacheRead} ${cost}`;
24
55
  }
25
56
 
26
57
  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) + ' ' +
58
+ const header =
59
+ `${c.bold}agent-ledger ${label}${c.reset} ${c.dim}·${c.reset} ` +
60
+ `${c.gray}${report.period.from.toISOString().slice(0, 10)} → ${report.period.to.toISOString().slice(0, 10)}${c.reset}`;
61
+
62
+ const colsRaw =
63
+ ' ' +
64
+ 'subagent'.padEnd(PAD_LABEL) +
65
+ 'sess'.padStart(4) +
66
+ ' ' +
67
+ 'tokens(in/out)'.padStart(18) +
68
+ ' ' +
69
+ 'cache tokens (1h/5m/read)'.padStart(32) +
70
+ ' ' +
35
71
  'cost'.padStart(9);
72
+ const cols = `${c.gray}${colsRaw}${c.reset}`;
36
73
 
37
- const sep = ' ' + '─'.repeat(PAD_SUBAGENT + 4 + 2 + 18 + 2 + 32 + 2 + 9);
74
+ const sepRaw = ' ' + '─'.repeat(PAD_LABEL + 4 + 2 + 18 + 2 + 32 + 2 + 9);
75
+ const sep = `${c.gray}${sepRaw}${c.reset}`;
38
76
 
39
- return [
77
+ const lines = [
40
78
  header,
41
79
  '',
42
80
  cols,
43
81
  sep,
44
- ...report.rows.map(formatRow),
82
+ ...report.rows.map((r) => formatRow(r)),
45
83
  sep,
46
- formatRow({ ...report.total, subagent: 'total' }),
47
- ].join('\n');
84
+ formatRow({ ...report.total, subagent: 'total' }, true),
85
+ ];
86
+
87
+ // Server tool footer (only when present, since most rows have 0)
88
+ if (report.total.webSearchRequests + report.total.webFetchRequests > 0) {
89
+ lines.push('');
90
+ lines.push(
91
+ ` ${c.gray}server tools:${c.reset} ` +
92
+ `web_search ×${report.total.webSearchRequests}, ` +
93
+ `web_fetch ×${report.total.webFetchRequests} ` +
94
+ `(${fmtUsd(report.total.cost.serverToolUseCost)})`,
95
+ );
96
+ }
97
+
98
+ return lines.join('\n');
99
+ }
100
+
101
+ // Daily trend chart: vertical-ish ASCII bars per day.
102
+ export function formatTrend(report: LedgerReport, label: string): string {
103
+ const max = report.rows.reduce((m, r) => Math.max(m, r.cost.totalCost), 0);
104
+ const barWidth = 36;
105
+
106
+ const header =
107
+ `${c.bold}agent-ledger ${label} (daily)${c.reset} ${c.dim}·${c.reset} ` +
108
+ `${c.gray}${report.period.from.toISOString().slice(0, 10)} → ${report.period.to.toISOString().slice(0, 10)}${c.reset}`;
109
+
110
+ const lines = [header, ''];
111
+ for (const row of report.rows) {
112
+ const bars = Math.round((row.cost.totalCost / Math.max(max, 0.01)) * barWidth);
113
+ const bar =
114
+ (USE_COLOR ? c.cyan : '') + '█'.repeat(bars) + (USE_COLOR ? c.reset : '');
115
+ const dateLabel = `${c.gray}${row.subagent}${c.reset}`;
116
+ const cost = colorCost(row.cost.totalCost, fmtUsd(row.cost.totalCost).padStart(9));
117
+ const sessions = `${c.gray}(${row.sessionCount} sess)${c.reset}`;
118
+ lines.push(` ${dateLabel} ${cost} ${bar} ${sessions}`);
119
+ }
120
+ lines.push('');
121
+ lines.push(
122
+ ` ${c.bold}total${c.reset.padEnd(PAD_LABEL - 5)}${' '.repeat(2)}${colorCost(
123
+ report.total.cost.totalCost,
124
+ fmtUsd(report.total.cost.totalCost).padStart(9),
125
+ )}`,
126
+ );
127
+ return lines.join('\n');
48
128
  }
49
129
 
50
130
  export function formatMarkdown(report: LedgerReport, label: string): string {
51
- const header = '| subagent | sessions | input | output | cache 1h | cache 5m | cache read | cost |';
131
+ const header = '| group | sessions | input | output | cache 1h | cache 5m | cache read | cost |';
52
132
  const sep = '|---|---:|---:|---:|---:|---:|---:|---:|';
53
133
  const rows = report.rows.map(
54
134
  (r) =>
@@ -66,7 +146,7 @@ export function formatMarkdown(report: LedgerReport, label: string): string {
66
146
  report.total.cacheReadTokens,
67
147
  )} | **${fmtUsd(report.total.cost.totalCost)}** |`;
68
148
 
69
- return [
149
+ const lines = [
70
150
  `# agent-ledger · ${label}`,
71
151
  `_${report.period.from.toISOString().slice(0, 10)} → ${report.period.to.toISOString().slice(0, 10)}_`,
72
152
  '',
@@ -74,5 +154,14 @@ export function formatMarkdown(report: LedgerReport, label: string): string {
74
154
  sep,
75
155
  ...rows,
76
156
  totalRow,
77
- ].join('\n');
157
+ ];
158
+
159
+ if (report.total.webSearchRequests + report.total.webFetchRequests > 0) {
160
+ lines.push('');
161
+ lines.push(
162
+ `_Server tools: web_search ×${report.total.webSearchRequests}, web_fetch ×${report.total.webFetchRequests} (${fmtUsd(report.total.cost.serverToolUseCost)})_`,
163
+ );
164
+ }
165
+
166
+ return lines.join('\n');
78
167
  }
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
  });