claude-agent-ledger 0.1.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.
@@ -0,0 +1,35 @@
1
+ ---
2
+ name: Bug report
3
+ about: Report incorrect output, crashes, or wrong attribution
4
+ title: ''
5
+ labels: bug
6
+ ---
7
+
8
+ ## What happened
9
+
10
+ <!-- What did agent-ledger do? What did you expect it to do instead? -->
11
+
12
+ ## How to reproduce
13
+
14
+ ```bash
15
+ agent-ledger <your-exact-command>
16
+ ```
17
+
18
+ ## Output (redact sensitive paths / subagent names)
19
+
20
+ ```
21
+ <paste the output here>
22
+ ```
23
+
24
+ ## Your setup
25
+
26
+ - OS: <!-- macOS 15.3 / Ubuntu 22.04 / ... -->
27
+ - Bun version: <!-- `bun --version` -->
28
+ - agent-ledger version: <!-- `agent-ledger --version` (pre-alpha: use commit SHA) -->
29
+ - Claude Code version: <!-- `claude --version` -->
30
+
31
+ ## If you're on pay-as-you-go, the calibration ask
32
+
33
+ If you compare `agent-ledger month` against a real Anthropic invoice and
34
+ they differ by > 10%, I want to hear about it. Even one line: "agent-ledger
35
+ says $X, invoice says $Y" is incredibly useful. Thanks.
@@ -0,0 +1,29 @@
1
+ ---
2
+ name: Model pricing gap
3
+ about: A model ID shows $0 in your ledger — we're missing it from the price table
4
+ title: 'pricing: add <model-id>'
5
+ labels: pricing
6
+ ---
7
+
8
+ ## Model ID
9
+
10
+ <!-- e.g. claude-opus-4-8-20260301 -->
11
+
12
+ ## Anthropic's published rates
13
+
14
+ - Input: $<X> / 1M tokens
15
+ - Output: $<Y> / 1M tokens
16
+ - Link to Anthropic pricing page (with date):
17
+
18
+ ## Where you saw it
19
+
20
+ Sample from your log (redact the content, keep the model field):
21
+
22
+ ```json
23
+ {"message":{"model":"<model-id>", "usage":{"input_tokens":..., "output_tokens":...}}}
24
+ ```
25
+
26
+ ## Optional: PR
27
+
28
+ If you want to open the PR, the pricing table lives in `src/pricing.ts`. Add a
29
+ matching test in `test/pricing.test.ts`.
@@ -0,0 +1,19 @@
1
+ ## What this PR does
2
+
3
+ <!-- One or two sentences. -->
4
+
5
+ ## Checklist
6
+
7
+ - [ ] `bun run typecheck` passes
8
+ - [ ] `bun test` passes
9
+ - [ ] If you touched `src/pricing.ts`, there's a matching test in
10
+ `test/pricing.test.ts`
11
+ - [ ] If you touched `src/parser.ts`, the change doesn't break the nested
12
+ `subagents/` walk (see `test/parser.test.ts`)
13
+ - [ ] No runtime dependencies added (this tool intentionally has none)
14
+
15
+ ## Log snippet (if relevant)
16
+
17
+ <!-- If the change is about handling some real log shape, include a redacted
18
+ example of what it looks like. Helps me reason about whether other tools
19
+ produce the same shape. -->
@@ -0,0 +1,19 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: oven-sh/setup-bun@v2
15
+ with:
16
+ bun-version: latest
17
+ - run: bun install --frozen-lockfile
18
+ - run: bun run typecheck
19
+ - run: bun test
package/CHANGELOG.md ADDED
@@ -0,0 +1,33 @@
1
+ # Changelog
2
+
3
+ All notable changes to `agent-ledger` will be documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Added
11
+ - JSONL parser with recursive walk of `~/.claude/projects/**`, including nested
12
+ `subagents/` directories
13
+ - Per-subagent attribution via sidecar `agent-<hash>.meta.json` files
14
+ - Pricing with cache TTL split: `ephemeral_5m_input_tokens` (1.25×) vs
15
+ `ephemeral_1h_input_tokens` (2×)
16
+ - Terminal table output (default) and Markdown export (`--md`)
17
+ - JSON output (`--json`) for piping into other tools
18
+ - `--plan pro|max` to suppress dollar columns on fixed-price subscriptions
19
+ - Support for current and prior-generation model IDs (Opus 4.6/4.7,
20
+ Sonnet 4.5/4.6, Haiku 4.0/4.5)
21
+ - Family-prefix fallback for unknown future models (e.g. `claude-opus-5-*`
22
+ resolves to Opus pricing)
23
+ - `<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.
@@ -0,0 +1,42 @@
1
+ # Contributing
2
+
3
+ Thanks for looking. This project is pre-alpha and in active shape — I'm still learning
4
+ what people actually need from a Claude Code ledger.
5
+
6
+ ## Quick help
7
+
8
+ The most useful contributions right now:
9
+
10
+ 1. **Run it on your own logs and tell me what it got wrong.** Open an issue with
11
+ the shadow cost number `agent-ledger month` reports, and the actual Anthropic
12
+ invoice if you're on pay-as-you-go. I want to calibrate.
13
+ 2. **Add a model I missed.** `src/pricing.ts` has the table. Open a PR with the
14
+ model ID and the published rates, plus a test in `test/pricing.test.ts`.
15
+ 3. **Report a log format surprise.** If `agent-ledger` skips usage records or
16
+ attributes them to `(main)` when they should be a subagent, paste a redacted
17
+ log snippet into an issue.
18
+
19
+ ## Running locally
20
+
21
+ ```bash
22
+ bun install
23
+ bun test
24
+ bun run typecheck
25
+ bun run bin/agent-ledger.ts today
26
+ ```
27
+
28
+ ## Style
29
+
30
+ - TypeScript strict. `noUncheckedIndexedAccess` is on.
31
+ - Short functions. If a function needs a comment to explain what it does, the
32
+ function is probably doing too much.
33
+ - No runtime dependencies except Bun built-ins (we want a small install surface).
34
+ - Tests use `bun:test`. Fixtures live in `test/fixtures/`.
35
+
36
+ ## Out of scope (for now)
37
+
38
+ - Real-time dashboards / web UIs
39
+ - Sending data anywhere (the tool is read-only by design)
40
+ - Tracking API keys or authenticating to Anthropic
41
+
42
+ If you want those, that's a different tool — and that's fine, just not this one.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jie Xiang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,157 @@
1
+ # agent-ledger
2
+
3
+ > See exactly where your Claude Code budget goes — per subagent, per task, per day.
4
+
5
+ [![status](https://img.shields.io/badge/status-pre--alpha-orange)](#status)
6
+ [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
7
+
8
+ ---
9
+
10
+ ## Why
11
+
12
+ Claude Code's invoice at month-end tells you one number. It doesn't tell you:
13
+
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
17
+ - Whether your "cheap" Haiku agent is actually quietly expensive
18
+
19
+ If you run **one** Claude Code session, this is fine — you already know.
20
+ If you run **seven** subagents daily (reliability, release, review, iOS factory, docs, ...), you're flying blind.
21
+
22
+ `agent-ledger` reads Claude Code's local session logs and gives you a per-subagent ledger. That's it.
23
+
24
+ ## What it is
25
+
26
+ A single-file CLI. No server. No account. No data leaves your machine.
27
+
28
+ ```bash
29
+ # Install
30
+ npm install -g claude-agent-ledger
31
+ # or: bun install -g claude-agent-ledger
32
+
33
+ # Today's spend, grouped by subagent
34
+ agent-ledger today
35
+
36
+ # Last 7 days
37
+ agent-ledger week
38
+
39
+ # Last 30 days + markdown export
40
+ agent-ledger month --md > month-report.md
41
+ ```
42
+
43
+ ## Example output
44
+
45
+ ```
46
+ agent-ledger week · 2026-04-14 → 2026-04-21
47
+
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
59
+ ```
60
+
61
+ Yes those are real numbers. Yes it's a lot. See [Shadow cost vs actual bill](#shadow-cost-vs-actual-bill).
62
+
63
+ ## Shadow cost vs actual bill
64
+
65
+ The `cost` column shows what you **would** pay at Anthropic's pay-as-you-go API rates.
66
+
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:
68
+
69
+ - Understanding where time-and-token budget goes, regardless of how you pay
70
+ - Spotting runaway agents (a 10× spike is still a 10× spike, capped or not)
71
+ - Deciding when to downshift to Haiku or up to Opus per task
72
+
73
+ If you're on pay-as-you-go, the `cost` column is roughly your bill (±5% depending on tier / geography).
74
+
75
+ ## How it works
76
+
77
+ 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`).
81
+
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.
85
+
86
+ ### Cache accounting
87
+
88
+ Anthropic's prompt caching has two TTLs in current logs:
89
+
90
+ | Bucket | Multiplier | Notes |
91
+ |---|---|---|
92
+ | `ephemeral_5m_input_tokens` | 1.25× base input | 5-minute TTL (default) |
93
+ | `ephemeral_1h_input_tokens` | 2.00× base input | 1-hour TTL (long-lived sessions) |
94
+ | `cache_read_input_tokens` | 0.10× base input | Read price (90% discount, TTL-agnostic) |
95
+
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`.
97
+
98
+ ## What it's not
99
+
100
+ - Not a real-time dashboard (post-hoc log reader)
101
+ - Not a tracker for direct Anthropic API calls (those logs live elsewhere)
102
+ - Not a cost-limiter or kill-switch (read-only, by design)
103
+ - Not affiliated with Anthropic
104
+
105
+ ## Who this is for
106
+
107
+ - 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
109
+ - You're building AI Agent systems and want observability primitives, not vendor dashboards
110
+
111
+ ## Status
112
+
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).
117
+
118
+ - [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`)
122
+ - [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`)
126
+ - [ ] Cost forecasting (trailing 7-day burn rate)
127
+ - [ ] `server_tool_use` pricing (web search, file fetch)
128
+ - [ ] Homebrew tap
129
+
130
+ First tagged release: **2026-05-05**.
131
+
132
+ ## Install from source
133
+
134
+ ```bash
135
+ git clone https://github.com/XJM-free/agent-ledger.git
136
+ cd agent-ledger
137
+ bun install
138
+ bun link
139
+ agent-ledger today
140
+ ```
141
+
142
+ ## Development
143
+
144
+ ```bash
145
+ bun test
146
+ bun run typecheck
147
+ ```
148
+
149
+ ## Author
150
+
151
+ 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).
154
+
155
+ ## License
156
+
157
+ MIT.
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env bun
2
+ import { aggregate } from '../src/aggregator.ts';
3
+ import { formatMarkdown, formatTable } from '../src/format.ts';
4
+ import { parseAll } from '../src/parser.ts';
5
+ import type { LedgerReport } from '../src/types.ts';
6
+
7
+ type Period = 'today' | 'week' | 'month';
8
+ type Plan = 'payg' | 'pro' | 'max';
9
+
10
+ interface Args {
11
+ period: Period;
12
+ markdown: boolean;
13
+ json: boolean;
14
+ plan: Plan;
15
+ }
16
+
17
+ function periodRange(period: Period): { from: Date; to: Date } {
18
+ const now = new Date();
19
+ const to = new Date(now);
20
+ const from = new Date(now);
21
+
22
+ if (period === 'today') {
23
+ from.setHours(0, 0, 0, 0);
24
+ } else if (period === 'week') {
25
+ from.setDate(from.getDate() - 7);
26
+ } else {
27
+ from.setDate(from.getDate() - 30);
28
+ }
29
+ return { from, to };
30
+ }
31
+
32
+ function usage(): never {
33
+ console.error(`Usage: agent-ledger <today|week|month> [flags]
34
+
35
+ 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 $.)
40
+
41
+ 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
46
+ `);
47
+ process.exit(1);
48
+ }
49
+
50
+ function parseArgs(argv: string[]): Args {
51
+ const [period, ...rest] = argv;
52
+ if (!period || !['today', 'week', 'month'].includes(period)) usage();
53
+
54
+ let plan: Plan = 'payg';
55
+ for (let i = 0; i < rest.length; i++) {
56
+ const flag = rest[i];
57
+ if (flag === '--plan') {
58
+ const next = rest[i + 1];
59
+ if (next === 'pro' || next === 'max') {
60
+ plan = next;
61
+ i++;
62
+ } else {
63
+ usage();
64
+ }
65
+ }
66
+ }
67
+ return {
68
+ period: period as Period,
69
+ markdown: rest.includes('--md'),
70
+ json: rest.includes('--json'),
71
+ plan,
72
+ };
73
+ }
74
+
75
+ function planMask(report: LedgerReport, plan: Plan): LedgerReport {
76
+ 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
+ const zero = () => ({
80
+ inputCost: 0,
81
+ outputCost: 0,
82
+ cacheCreation5mCost: 0,
83
+ cacheCreation1hCost: 0,
84
+ cacheReadCost: 0,
85
+ totalCost: 0,
86
+ });
87
+ return {
88
+ period: report.period,
89
+ rows: report.rows.map((r) => ({ ...r, cost: zero() })),
90
+ total: { ...report.total, cost: zero() },
91
+ };
92
+ }
93
+
94
+ async function main(): Promise<void> {
95
+ const args = parseArgs(process.argv.slice(2));
96
+ const { from, to } = periodRange(args.period);
97
+ let report = await aggregate(parseAll({ from, to }), from, to);
98
+ report = planMask(report, args.plan);
99
+
100
+ let output: string;
101
+ if (args.json) {
102
+ output = JSON.stringify(report, null, 2);
103
+ } else if (args.markdown) {
104
+ output = formatMarkdown(report, args.period);
105
+ } else {
106
+ output = formatTable(report, args.period);
107
+ }
108
+ console.log(output);
109
+ }
110
+
111
+ main().catch((err) => {
112
+ console.error('agent-ledger: fatal error');
113
+ console.error(err);
114
+ process.exit(1);
115
+ });
package/bun.lock ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "lockfileVersion": 1,
3
+ "configVersion": 1,
4
+ "workspaces": {
5
+ "": {
6
+ "name": "agent-ledger",
7
+ "devDependencies": {
8
+ "@types/bun": "latest",
9
+ "typescript": "^5.6.0",
10
+ },
11
+ },
12
+ },
13
+ "packages": {
14
+ "@types/bun": ["@types/bun@1.3.12", "https://registry.npmmirror.com/@types/bun/-/bun-1.3.12.tgz", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
15
+
16
+ "@types/node": ["@types/node@25.6.0", "https://registry.npmmirror.com/@types/node/-/node-25.6.0.tgz", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
17
+
18
+ "bun-types": ["bun-types@1.3.12", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.12.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
19
+
20
+ "typescript": ["typescript@5.9.3", "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
21
+
22
+ "undici-types": ["undici-types@7.19.2", "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
23
+ }
24
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "claude-agent-ledger",
3
+ "version": "0.1.0",
4
+ "description": "See exactly where your Claude Code budget goes — per subagent, per task.",
5
+ "type": "module",
6
+ "bin": {
7
+ "agent-ledger": "./bin/agent-ledger.ts"
8
+ },
9
+ "scripts": {
10
+ "dev": "bun run bin/agent-ledger.ts",
11
+ "test": "bun test",
12
+ "build": "bun build bin/agent-ledger.ts --target=node --outdir dist",
13
+ "typecheck": "tsc --noEmit"
14
+ },
15
+ "engines": {
16
+ "bun": ">=1.1.0"
17
+ },
18
+ "keywords": [
19
+ "claude-code",
20
+ "mcp",
21
+ "ai-agents",
22
+ "cost-tracking",
23
+ "observability",
24
+ "cli"
25
+ ],
26
+ "author": "Jie Xiang <jie.xiang.jm@gmail.com>",
27
+ "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "https://github.com/XJM-free/claude-agent-ledger.git"
31
+ },
32
+ "devDependencies": {
33
+ "@types/bun": "latest",
34
+ "typescript": "^5.6.0"
35
+ }
36
+ }
@@ -0,0 +1,96 @@
1
+ import { costFor } from './pricing.ts';
2
+ import type { AggregatedRow, LedgerReport, SessionTurn } from './types.ts';
3
+
4
+ const MAIN_AGENT_LABEL = '(main)';
5
+
6
+ function emptyRow(subagent: string): AggregatedRow {
7
+ return {
8
+ subagent,
9
+ sessionCount: 0,
10
+ inputTokens: 0,
11
+ outputTokens: 0,
12
+ cacheCreation5mTokens: 0,
13
+ cacheCreation1hTokens: 0,
14
+ cacheReadTokens: 0,
15
+ cost: {
16
+ inputCost: 0,
17
+ outputCost: 0,
18
+ cacheCreation5mCost: 0,
19
+ cacheCreation1hCost: 0,
20
+ cacheReadCost: 0,
21
+ totalCost: 0,
22
+ },
23
+ };
24
+ }
25
+
26
+ function splitCache(turn: SessionTurn): { fiveMin: number; oneHour: number } {
27
+ const usage = turn.usage;
28
+ if (!usage) return { fiveMin: 0, oneHour: 0 };
29
+ if (usage.cache_creation) {
30
+ return {
31
+ fiveMin: usage.cache_creation.ephemeral_5m_input_tokens ?? 0,
32
+ oneHour: usage.cache_creation.ephemeral_1h_input_tokens ?? 0,
33
+ };
34
+ }
35
+ return { fiveMin: usage.cache_creation_input_tokens ?? 0, oneHour: 0 };
36
+ }
37
+
38
+ export async function aggregate(
39
+ turns: AsyncIterable<SessionTurn>,
40
+ from: Date,
41
+ to: Date,
42
+ ): Promise<LedgerReport> {
43
+ const rowsByAgent = new Map<string, AggregatedRow>();
44
+ const sessionsByAgent = new Map<string, Set<string>>();
45
+
46
+ for await (const turn of turns) {
47
+ if (turn.type !== 'assistant' || !turn.usage) continue;
48
+
49
+ const agent = turn.subagentType ?? MAIN_AGENT_LABEL;
50
+ const row = rowsByAgent.get(agent) ?? emptyRow(agent);
51
+
52
+ const cost = costFor(turn.usage, turn.model);
53
+ const { fiveMin, oneHour } = splitCache(turn);
54
+
55
+ row.inputTokens += turn.usage.input_tokens;
56
+ row.outputTokens += turn.usage.output_tokens;
57
+ row.cacheCreation5mTokens += fiveMin;
58
+ row.cacheCreation1hTokens += oneHour;
59
+ row.cacheReadTokens += turn.usage.cache_read_input_tokens ?? 0;
60
+ row.cost.inputCost += cost.inputCost;
61
+ row.cost.outputCost += cost.outputCost;
62
+ row.cost.cacheCreation5mCost += cost.cacheCreation5mCost;
63
+ row.cost.cacheCreation1hCost += cost.cacheCreation1hCost;
64
+ row.cost.cacheReadCost += cost.cacheReadCost;
65
+ row.cost.totalCost += cost.totalCost;
66
+
67
+ const sessions = sessionsByAgent.get(agent) ?? new Set<string>();
68
+ sessions.add(turn.sessionId);
69
+ sessionsByAgent.set(agent, sessions);
70
+
71
+ rowsByAgent.set(agent, row);
72
+ }
73
+
74
+ for (const [agent, row] of rowsByAgent) {
75
+ row.sessionCount = sessionsByAgent.get(agent)?.size ?? 0;
76
+ }
77
+
78
+ const rows = [...rowsByAgent.values()].sort((a, b) => b.cost.totalCost - a.cost.totalCost);
79
+ const total = rows.reduce<AggregatedRow>((acc, row) => {
80
+ acc.sessionCount += row.sessionCount;
81
+ acc.inputTokens += row.inputTokens;
82
+ acc.outputTokens += row.outputTokens;
83
+ acc.cacheCreation5mTokens += row.cacheCreation5mTokens;
84
+ acc.cacheCreation1hTokens += row.cacheCreation1hTokens;
85
+ acc.cacheReadTokens += row.cacheReadTokens;
86
+ acc.cost.inputCost += row.cost.inputCost;
87
+ acc.cost.outputCost += row.cost.outputCost;
88
+ acc.cost.cacheCreation5mCost += row.cost.cacheCreation5mCost;
89
+ acc.cost.cacheCreation1hCost += row.cost.cacheCreation1hCost;
90
+ acc.cost.cacheReadCost += row.cost.cacheReadCost;
91
+ acc.cost.totalCost += row.cost.totalCost;
92
+ return acc;
93
+ }, emptyRow('total'));
94
+
95
+ return { period: { from, to }, rows, total };
96
+ }
package/src/format.ts ADDED
@@ -0,0 +1,78 @@
1
+ import type { AggregatedRow, LedgerReport } from './types.ts';
2
+
3
+ const PAD_SUBAGENT = 22;
4
+
5
+ function fmtTokens(n: number): string {
6
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
7
+ if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
8
+ return String(n);
9
+ }
10
+
11
+ function fmtUsd(n: number): string {
12
+ return `$${n.toFixed(2)}`;
13
+ }
14
+
15
+ function formatRow(row: AggregatedRow): string {
16
+ const subagent = row.subagent.padEnd(PAD_SUBAGENT);
17
+ const sessions = String(row.sessionCount).padStart(4);
18
+ const io = `${fmtTokens(row.inputTokens)} / ${fmtTokens(row.outputTokens)}`.padStart(18);
19
+ const cached1h = fmtTokens(row.cacheCreation1hTokens).padStart(7);
20
+ const cached5m = fmtTokens(row.cacheCreation5mTokens).padStart(7);
21
+ 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}`;
24
+ }
25
+
26
+ 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) + ' ' +
35
+ 'cost'.padStart(9);
36
+
37
+ const sep = ' ' + '─'.repeat(PAD_SUBAGENT + 4 + 2 + 18 + 2 + 32 + 2 + 9);
38
+
39
+ return [
40
+ header,
41
+ '',
42
+ cols,
43
+ sep,
44
+ ...report.rows.map(formatRow),
45
+ sep,
46
+ formatRow({ ...report.total, subagent: 'total' }),
47
+ ].join('\n');
48
+ }
49
+
50
+ export function formatMarkdown(report: LedgerReport, label: string): string {
51
+ const header = '| subagent | sessions | input | output | cache 1h | cache 5m | cache read | cost |';
52
+ const sep = '|---|---:|---:|---:|---:|---:|---:|---:|';
53
+ const rows = report.rows.map(
54
+ (r) =>
55
+ `| ${r.subagent} | ${r.sessionCount} | ${fmtTokens(r.inputTokens)} | ${fmtTokens(
56
+ r.outputTokens,
57
+ )} | ${fmtTokens(r.cacheCreation1hTokens)} | ${fmtTokens(r.cacheCreation5mTokens)} | ${fmtTokens(
58
+ r.cacheReadTokens,
59
+ )} | ${fmtUsd(r.cost.totalCost)} |`,
60
+ );
61
+ const totalRow = `| **total** | ${report.total.sessionCount} | ${fmtTokens(
62
+ report.total.inputTokens,
63
+ )} | ${fmtTokens(report.total.outputTokens)} | ${fmtTokens(
64
+ report.total.cacheCreation1hTokens,
65
+ )} | ${fmtTokens(report.total.cacheCreation5mTokens)} | ${fmtTokens(
66
+ report.total.cacheReadTokens,
67
+ )} | **${fmtUsd(report.total.cost.totalCost)}** |`;
68
+
69
+ return [
70
+ `# agent-ledger · ${label}`,
71
+ `_${report.period.from.toISOString().slice(0, 10)} → ${report.period.to.toISOString().slice(0, 10)}_`,
72
+ '',
73
+ header,
74
+ sep,
75
+ ...rows,
76
+ totalRow,
77
+ ].join('\n');
78
+ }
package/src/parser.ts ADDED
@@ -0,0 +1,134 @@
1
+ import { readdir, stat } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { basename, dirname, join } from 'node:path';
4
+ import type { SessionTurn, TokenUsage } from './types.ts';
5
+
6
+ const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects');
7
+
8
+ export interface ParseOptions {
9
+ from?: Date;
10
+ to?: Date;
11
+ projectsDir?: string;
12
+ }
13
+
14
+ interface LogFile {
15
+ path: string;
16
+ subagentType: string | undefined;
17
+ }
18
+
19
+ // Walk the projects directory and yield every JSONL log.
20
+ // Real Claude Code layout (as of 2026-04):
21
+ // ~/.claude/projects/<encoded-project>/<session-uuid>.jsonl (main session)
22
+ // ~/.claude/projects/<encoded-project>/<session-uuid>/subagents/agent-<hash>.jsonl (subagent)
23
+ // ~/.claude/projects/<encoded-project>/<session-uuid>/subagents/agent-<hash>.meta.json
24
+ //
25
+ // The .meta.json next to each subagent .jsonl carries { "agentType": "...", "description": "..." }.
26
+ // We read it to attribute the subagent by its real name (e.g. "Reality Checker") rather than an opaque hash.
27
+ export async function* findSessionLogs(
28
+ projectsDir: string = CLAUDE_PROJECTS_DIR,
29
+ ): AsyncGenerator<LogFile> {
30
+ let projects: string[] = [];
31
+ try {
32
+ projects = await readdir(projectsDir);
33
+ } catch {
34
+ return;
35
+ }
36
+ for (const project of projects) {
37
+ yield* walkForJsonl(join(projectsDir, project));
38
+ }
39
+ }
40
+
41
+ async function* walkForJsonl(dir: string): AsyncGenerator<LogFile> {
42
+ let entries: string[] = [];
43
+ try {
44
+ entries = await readdir(dir);
45
+ } catch {
46
+ return;
47
+ }
48
+ for (const name of entries) {
49
+ if (name.startsWith('.') || name === 'memory') continue;
50
+ const full = join(dir, name);
51
+ let isDir = false;
52
+ try {
53
+ isDir = (await stat(full)).isDirectory();
54
+ } catch {
55
+ continue;
56
+ }
57
+ if (isDir) {
58
+ yield* walkForJsonl(full);
59
+ continue;
60
+ }
61
+ if (name.endsWith('.jsonl')) {
62
+ yield { path: full, subagentType: await resolveSubagentType(full) };
63
+ }
64
+ }
65
+ }
66
+
67
+ async function resolveSubagentType(jsonlPath: string): Promise<string | undefined> {
68
+ // Subagent logs live under `.../subagents/agent-<hash>.jsonl` with a sibling `.meta.json`.
69
+ if (basename(dirname(jsonlPath)) !== 'subagents') return undefined;
70
+ const metaPath = jsonlPath.replace(/\.jsonl$/, '.meta.json');
71
+ try {
72
+ const text = await Bun.file(metaPath).text();
73
+ const meta = JSON.parse(text) as { agentType?: string };
74
+ return meta.agentType;
75
+ } catch {
76
+ return undefined;
77
+ }
78
+ }
79
+
80
+ // Stream-parse one JSONL file. Skips malformed lines rather than aborting.
81
+ export async function* parseFile(
82
+ path: string,
83
+ subagentType?: string,
84
+ ): AsyncGenerator<SessionTurn> {
85
+ const file = Bun.file(path);
86
+ const text = await file.text();
87
+ const sessionId = basename(path).replace('.jsonl', '');
88
+
89
+ for (const line of text.split('\n')) {
90
+ if (!line.trim()) continue;
91
+ let obj: Record<string, unknown>;
92
+ try {
93
+ obj = JSON.parse(line);
94
+ } catch {
95
+ continue;
96
+ }
97
+ yield extractTurn(obj, sessionId, subagentType);
98
+ }
99
+ }
100
+
101
+ function extractTurn(
102
+ obj: Record<string, unknown>,
103
+ sessionId: string,
104
+ subagentType: string | undefined,
105
+ ): SessionTurn {
106
+ const msg = (obj.message ?? {}) as Record<string, unknown>;
107
+ const usage = (msg.usage ?? undefined) as TokenUsage | undefined;
108
+ const model = (msg.model ?? undefined) as string | undefined;
109
+ // If the raw turn carries its own subagentType (our fixture format), prefer it;
110
+ // otherwise fall back to the directory-derived one.
111
+ const perTurnSubagent = ((obj.subagentType as string) ?? undefined) || undefined;
112
+
113
+ return {
114
+ type: ((obj.type as string) ?? 'assistant') as SessionTurn['type'],
115
+ timestamp: (obj.timestamp as string) ?? new Date().toISOString(),
116
+ sessionId,
117
+ subagentType: perTurnSubagent ?? subagentType,
118
+ model,
119
+ usage,
120
+ raw: obj,
121
+ };
122
+ }
123
+
124
+ // Convenience: stream every turn across every log under ~/.claude/projects.
125
+ export async function* parseAll(opts: ParseOptions = {}): AsyncGenerator<SessionTurn> {
126
+ const dir = opts.projectsDir ?? CLAUDE_PROJECTS_DIR;
127
+ for await (const file of findSessionLogs(dir)) {
128
+ for await (const turn of parseFile(file.path, file.subagentType)) {
129
+ if (opts.from && new Date(turn.timestamp) < opts.from) continue;
130
+ if (opts.to && new Date(turn.timestamp) > opts.to) continue;
131
+ yield turn;
132
+ }
133
+ }
134
+ }
package/src/pricing.ts ADDED
@@ -0,0 +1,95 @@
1
+ import type { CostBreakdown, ModelId, TokenUsage } from './types.ts';
2
+
3
+ // Prices are per 1M tokens, USD. Keep in sync with
4
+ // https://www.anthropic.com/pricing as of 2026-04-21.
5
+ //
6
+ // Prompt caching (Anthropic):
7
+ // cache_creation 5-min TTL: 1.25× base input rate
8
+ // cache_creation 1-hour TTL: 2× base input rate
9
+ // cache_read (any TTL): 0.10× base input rate (90% discount)
10
+ //
11
+ // We include both current and prior-generation model IDs because real logs span
12
+ // version bumps (Claude Code does not auto-rewrite historical logs when you upgrade).
13
+ const PRICING: Record<string, { input: number; output: number }> = {
14
+ // Current generation (2026-04)
15
+ 'claude-opus-4-7': { input: 15, output: 75 },
16
+ 'claude-sonnet-4-6': { input: 3, output: 15 },
17
+ 'claude-haiku-4-5-20251001': { input: 0.8, output: 4 },
18
+ // Prior generation (still appears in logs written before model upgrades)
19
+ 'claude-opus-4-6': { input: 15, output: 75 },
20
+ 'claude-opus-4-5': { input: 15, output: 75 },
21
+ 'claude-sonnet-4-5': { input: 3, output: 15 },
22
+ 'claude-haiku-4-0': { input: 0.8, output: 4 },
23
+ };
24
+
25
+ // Internal Claude Code markers that we should treat as free.
26
+ // `<synthetic>` turns are context summaries / compaction events with no real API call.
27
+ const FREE_MODELS = new Set<string>(['<synthetic>']);
28
+
29
+ const CACHE_5M_MULTIPLIER = 1.25;
30
+ const CACHE_1H_MULTIPLIER = 2.0;
31
+ const CACHE_READ_MULTIPLIER = 0.1;
32
+
33
+ function resolveModel(model: ModelId | undefined): { input: number; output: number } {
34
+ if (!model) return { input: 0, output: 0 };
35
+ if (FREE_MODELS.has(model)) return { input: 0, output: 0 };
36
+ const exact = PRICING[model];
37
+ if (exact) return exact;
38
+ // Fuzzy family prefix match as a safety net for unknown subversions.
39
+ // e.g. "claude-opus-4-8-20260301" falls back to Opus pricing until we add it.
40
+ for (const key of Object.keys(PRICING)) {
41
+ if (model.startsWith(key)) return PRICING[key] as { input: number; output: number };
42
+ }
43
+ if (model.startsWith('claude-opus-')) return { input: 15, output: 75 };
44
+ if (model.startsWith('claude-sonnet-')) return { input: 3, output: 15 };
45
+ if (model.startsWith('claude-haiku-')) return { input: 0.8, output: 4 };
46
+ return { input: 0, output: 0 };
47
+ }
48
+
49
+ interface CacheBreakdown {
50
+ fiveMin: number;
51
+ oneHour: number;
52
+ }
53
+
54
+ function splitCacheCreation(usage: TokenUsage): CacheBreakdown {
55
+ if (usage.cache_creation) {
56
+ return {
57
+ fiveMin: usage.cache_creation.ephemeral_5m_input_tokens ?? 0,
58
+ oneHour: usage.cache_creation.ephemeral_1h_input_tokens ?? 0,
59
+ };
60
+ }
61
+ return {
62
+ fiveMin: usage.cache_creation_input_tokens ?? 0,
63
+ oneHour: 0,
64
+ };
65
+ }
66
+
67
+ export function costFor(usage: TokenUsage, model: ModelId | undefined): CostBreakdown {
68
+ const rate = resolveModel(model);
69
+ const cacheRead = usage.cache_read_input_tokens ?? 0;
70
+ const { fiveMin, oneHour } = splitCacheCreation(usage);
71
+
72
+ const perMillion = (tokens: number, price: number) => (tokens / 1_000_000) * price;
73
+
74
+ const inputCost = perMillion(usage.input_tokens, rate.input);
75
+ const outputCost = perMillion(usage.output_tokens, rate.output);
76
+ const cacheCreation5mCost = perMillion(fiveMin, rate.input * CACHE_5M_MULTIPLIER);
77
+ const cacheCreation1hCost = perMillion(oneHour, rate.input * CACHE_1H_MULTIPLIER);
78
+ const cacheReadCost = perMillion(cacheRead, rate.input * CACHE_READ_MULTIPLIER);
79
+
80
+ return {
81
+ inputCost,
82
+ outputCost,
83
+ cacheCreation5mCost,
84
+ cacheCreation1hCost,
85
+ cacheReadCost,
86
+ totalCost: inputCost + outputCost + cacheCreation5mCost + cacheCreation1hCost + cacheReadCost,
87
+ };
88
+ }
89
+
90
+ export function isKnownModel(model: ModelId | undefined): boolean {
91
+ if (!model) return false;
92
+ if (FREE_MODELS.has(model)) return true;
93
+ if (PRICING[model]) return true;
94
+ return Object.keys(PRICING).some((key) => model.startsWith(key));
95
+ }
package/src/types.ts ADDED
@@ -0,0 +1,58 @@
1
+ export type ModelId =
2
+ | 'claude-opus-4-7'
3
+ | 'claude-sonnet-4-6'
4
+ | 'claude-haiku-4-5-20251001'
5
+ | string;
6
+
7
+ export type TurnType = 'user' | 'assistant' | 'summary' | 'system';
8
+
9
+ export interface CacheCreationDetail {
10
+ ephemeral_5m_input_tokens?: number;
11
+ ephemeral_1h_input_tokens?: number;
12
+ }
13
+
14
+ export interface TokenUsage {
15
+ input_tokens: number;
16
+ output_tokens: number;
17
+ // Legacy flat counter. Real logs also carry `cache_creation` with TTL split; if present,
18
+ // prefer that for accurate pricing.
19
+ cache_creation_input_tokens?: number;
20
+ cache_read_input_tokens?: number;
21
+ cache_creation?: CacheCreationDetail;
22
+ }
23
+
24
+ export interface SessionTurn {
25
+ type: TurnType;
26
+ timestamp: string;
27
+ sessionId: string;
28
+ subagentType?: string | undefined;
29
+ model?: ModelId | undefined;
30
+ usage?: TokenUsage | undefined;
31
+ raw: unknown;
32
+ }
33
+
34
+ export interface CostBreakdown {
35
+ inputCost: number;
36
+ outputCost: number;
37
+ cacheCreation5mCost: number;
38
+ cacheCreation1hCost: number;
39
+ cacheReadCost: number;
40
+ totalCost: number;
41
+ }
42
+
43
+ export interface AggregatedRow {
44
+ subagent: string;
45
+ sessionCount: number;
46
+ inputTokens: number;
47
+ outputTokens: number;
48
+ cacheCreation5mTokens: number;
49
+ cacheCreation1hTokens: number;
50
+ cacheReadTokens: number;
51
+ cost: CostBreakdown;
52
+ }
53
+
54
+ export interface LedgerReport {
55
+ period: { from: Date; to: Date };
56
+ rows: AggregatedRow[];
57
+ total: AggregatedRow;
58
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { join } from 'node:path';
3
+ import { aggregate } from '../src/aggregator.ts';
4
+ import { parseFile } from '../src/parser.ts';
5
+
6
+ const FIXTURE = join(import.meta.dir, 'fixtures', 'sample.jsonl');
7
+
8
+ describe('aggregate()', () => {
9
+ test('groups assistant turns by subagent', async () => {
10
+ const from = new Date('2026-04-21T00:00:00Z');
11
+ const to = new Date('2026-04-21T23:59:59Z');
12
+ const report = await aggregate(parseFile(FIXTURE), from, to);
13
+
14
+ const agents = report.rows.map((r) => r.subagent);
15
+ expect(agents).toContain('ios-factory');
16
+ expect(agents).toContain('Reality Checker');
17
+ expect(agents).toContain('(main)');
18
+ });
19
+
20
+ test('skips user turns (no usage data)', async () => {
21
+ const from = new Date('2026-04-21T00:00:00Z');
22
+ const to = new Date('2026-04-21T23:59:59Z');
23
+ const report = await aggregate(parseFile(FIXTURE), from, to);
24
+
25
+ // 4 assistant turns in fixture (3 subagents, some with >1 turn each).
26
+ const totalSessions = report.total.sessionCount;
27
+ expect(totalSessions).toBeGreaterThan(0);
28
+ });
29
+
30
+ test('rows are sorted by total cost descending', async () => {
31
+ const from = new Date('2026-04-21T00:00:00Z');
32
+ const to = new Date('2026-04-21T23:59:59Z');
33
+ const report = await aggregate(parseFile(FIXTURE), from, to);
34
+
35
+ for (let i = 1; i < report.rows.length; i++) {
36
+ const prev = report.rows[i - 1]!.cost.totalCost;
37
+ const curr = report.rows[i]!.cost.totalCost;
38
+ expect(prev).toBeGreaterThanOrEqual(curr);
39
+ }
40
+ });
41
+
42
+ test('total row sums all agent rows', async () => {
43
+ const from = new Date('2026-04-21T00:00:00Z');
44
+ const to = new Date('2026-04-21T23:59:59Z');
45
+ const report = await aggregate(parseFile(FIXTURE), from, to);
46
+
47
+ const sumCost = report.rows.reduce((s, r) => s + r.cost.totalCost, 0);
48
+ expect(report.total.cost.totalCost).toBeCloseTo(sumCost, 6);
49
+ });
50
+ });
@@ -0,0 +1,2 @@
1
+ {"type":"assistant","timestamp":"2026-04-21T10:05:00Z","message":{"role":"assistant","model":"claude-sonnet-4-6","usage":{"input_tokens":4200,"output_tokens":1300,"cache_creation_input_tokens":2000,"cache_read_input_tokens":22000}}}
2
+ {"type":"assistant","timestamp":"2026-04-21T10:07:00Z","message":{"role":"assistant","model":"claude-sonnet-4-6","usage":{"input_tokens":800,"output_tokens":220,"cache_read_input_tokens":26000}}}
@@ -0,0 +1 @@
1
+ {"agentType":"Reality Checker","description":"Review migration safety"}
@@ -0,0 +1,2 @@
1
+ {"type":"user","timestamp":"2026-04-21T10:00:00Z","message":{"role":"user","content":"hello"}}
2
+ {"type":"assistant","timestamp":"2026-04-21T10:00:12Z","message":{"role":"assistant","model":"claude-opus-4-7","usage":{"input_tokens":1200,"output_tokens":420,"cache_read_input_tokens":8000}}}
@@ -0,0 +1,6 @@
1
+ {"type":"user","timestamp":"2026-04-21T10:00:00Z","message":{"role":"user","content":"Fix the release script"}}
2
+ {"type":"assistant","timestamp":"2026-04-21T10:00:12Z","subagentType":"ios-factory","message":{"role":"assistant","model":"claude-opus-4-7","usage":{"input_tokens":4200,"output_tokens":1180,"cache_creation_input_tokens":2400,"cache_read_input_tokens":18000}}}
3
+ {"type":"assistant","timestamp":"2026-04-21T10:01:02Z","subagentType":"ios-factory","message":{"role":"assistant","model":"claude-opus-4-7","usage":{"input_tokens":2100,"output_tokens":640,"cache_read_input_tokens":22000}}}
4
+ {"type":"user","timestamp":"2026-04-21T11:30:00Z","message":{"role":"user","content":"Review this PR"}}
5
+ {"type":"assistant","timestamp":"2026-04-21T11:30:08Z","subagentType":"Reality Checker","message":{"role":"assistant","model":"claude-sonnet-4-6","usage":{"input_tokens":8800,"output_tokens":1520,"cache_creation_input_tokens":1200,"cache_read_input_tokens":44000}}}
6
+ {"type":"assistant","timestamp":"2026-04-21T15:10:00Z","message":{"role":"assistant","model":"claude-haiku-4-5-20251001","usage":{"input_tokens":1200,"output_tokens":320}}}
@@ -0,0 +1,55 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { join } from 'node:path';
3
+ import { aggregate } from '../src/aggregator.ts';
4
+ import { parseAll } from '../src/parser.ts';
5
+
6
+ const PROJECTS_DIR = join(import.meta.dir, 'fixtures', 'projects');
7
+
8
+ describe('parseAll()', () => {
9
+ test('walks nested subagents/ directories and attributes subagent type', async () => {
10
+ const from = new Date('2026-04-21T00:00:00Z');
11
+ const to = new Date('2026-04-21T23:59:59Z');
12
+
13
+ const report = await aggregate(
14
+ parseAll({ from, to, projectsDir: PROJECTS_DIR }),
15
+ from,
16
+ to,
17
+ );
18
+
19
+ const agents = report.rows.map((r) => r.subagent).sort();
20
+ expect(agents).toContain('(main)');
21
+ expect(agents).toContain('Reality Checker');
22
+ });
23
+
24
+ test('main-session turns are attributed to (main), not to any subagent', async () => {
25
+ const from = new Date('2026-04-21T00:00:00Z');
26
+ const to = new Date('2026-04-21T23:59:59Z');
27
+
28
+ const report = await aggregate(
29
+ parseAll({ from, to, projectsDir: PROJECTS_DIR }),
30
+ from,
31
+ to,
32
+ );
33
+
34
+ const main = report.rows.find((r) => r.subagent === '(main)');
35
+ const realityChecker = report.rows.find((r) => r.subagent === 'Reality Checker');
36
+
37
+ expect(main).toBeDefined();
38
+ expect(realityChecker).toBeDefined();
39
+ expect(main!.sessionCount).toBe(1);
40
+ expect(realityChecker!.sessionCount).toBe(1);
41
+ });
42
+
43
+ test('respects date range filter', async () => {
44
+ const from = new Date('2030-01-01T00:00:00Z');
45
+ const to = new Date('2030-01-02T00:00:00Z');
46
+
47
+ const report = await aggregate(
48
+ parseAll({ from, to, projectsDir: PROJECTS_DIR }),
49
+ from,
50
+ to,
51
+ );
52
+
53
+ expect(report.rows.length).toBe(0);
54
+ });
55
+ });
@@ -0,0 +1,110 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { costFor, isKnownModel } from '../src/pricing.ts';
3
+
4
+ describe('costFor()', () => {
5
+ test('Opus output is $75/M', () => {
6
+ const cost = costFor(
7
+ { input_tokens: 0, output_tokens: 1_000_000 },
8
+ 'claude-opus-4-7',
9
+ );
10
+ expect(cost.outputCost).toBeCloseTo(75, 6);
11
+ });
12
+
13
+ test('Opus input is $15/M', () => {
14
+ const cost = costFor(
15
+ { input_tokens: 1_000_000, output_tokens: 0 },
16
+ 'claude-opus-4-7',
17
+ );
18
+ expect(cost.inputCost).toBeCloseTo(15, 6);
19
+ });
20
+
21
+ test('5-minute cache creation is 1.25× base input', () => {
22
+ const cost = costFor(
23
+ {
24
+ input_tokens: 0,
25
+ output_tokens: 0,
26
+ cache_creation: { ephemeral_5m_input_tokens: 1_000_000 },
27
+ },
28
+ 'claude-opus-4-7',
29
+ );
30
+ // Opus input $15 × 1.25 = $18.75 per 1M
31
+ expect(cost.cacheCreation5mCost).toBeCloseTo(18.75, 4);
32
+ expect(cost.cacheCreation1hCost).toBeCloseTo(0, 6);
33
+ });
34
+
35
+ test('1-hour cache creation is 2× base input (the TTL trap)', () => {
36
+ const cost = costFor(
37
+ {
38
+ input_tokens: 0,
39
+ output_tokens: 0,
40
+ cache_creation: { ephemeral_1h_input_tokens: 1_000_000 },
41
+ },
42
+ 'claude-opus-4-7',
43
+ );
44
+ // Opus input $15 × 2 = $30 per 1M
45
+ expect(cost.cacheCreation1hCost).toBeCloseTo(30, 4);
46
+ expect(cost.cacheCreation5mCost).toBeCloseTo(0, 6);
47
+ });
48
+
49
+ test('cache read is 10% of base input', () => {
50
+ const cost = costFor(
51
+ {
52
+ input_tokens: 0,
53
+ output_tokens: 0,
54
+ cache_read_input_tokens: 1_000_000,
55
+ },
56
+ 'claude-opus-4-7',
57
+ );
58
+ // Opus input $15 × 0.1 = $1.50 per 1M
59
+ expect(cost.cacheReadCost).toBeCloseTo(1.5, 4);
60
+ });
61
+
62
+ test('legacy flat cache_creation_input_tokens falls back to 5m TTL pricing', () => {
63
+ const cost = costFor(
64
+ {
65
+ input_tokens: 0,
66
+ output_tokens: 0,
67
+ cache_creation_input_tokens: 1_000_000,
68
+ },
69
+ 'claude-opus-4-7',
70
+ );
71
+ expect(cost.cacheCreation5mCost).toBeCloseTo(18.75, 4);
72
+ });
73
+
74
+ test('prior-generation model IDs (opus-4-6) resolve to current pricing', () => {
75
+ const cost = costFor(
76
+ { input_tokens: 1_000_000, output_tokens: 0 },
77
+ 'claude-opus-4-6',
78
+ );
79
+ expect(cost.inputCost).toBeCloseTo(15, 6);
80
+ });
81
+
82
+ test('unknown future opus variant falls back to opus family pricing', () => {
83
+ const cost = costFor(
84
+ { input_tokens: 1_000_000, output_tokens: 0 },
85
+ 'claude-opus-9-9',
86
+ );
87
+ expect(cost.inputCost).toBeCloseTo(15, 6);
88
+ });
89
+
90
+ test('<synthetic> model is free', () => {
91
+ const cost = costFor(
92
+ {
93
+ input_tokens: 1_000_000,
94
+ output_tokens: 1_000_000,
95
+ cache_read_input_tokens: 1_000_000,
96
+ },
97
+ '<synthetic>',
98
+ );
99
+ expect(cost.totalCost).toBe(0);
100
+ });
101
+
102
+ test('isKnownModel recognizes current, prior, and family matches', () => {
103
+ expect(isKnownModel('claude-opus-4-7')).toBe(true);
104
+ expect(isKnownModel('claude-opus-4-6')).toBe(true);
105
+ expect(isKnownModel('claude-sonnet-4-6')).toBe(true);
106
+ expect(isKnownModel('<synthetic>')).toBe(true);
107
+ expect(isKnownModel('something-entirely-unknown')).toBe(false);
108
+ expect(isKnownModel(undefined)).toBe(false);
109
+ });
110
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2022"],
7
+ "types": ["bun-types"],
8
+ "strict": true,
9
+ "noUncheckedIndexedAccess": true,
10
+ "noImplicitOverride": true,
11
+ "skipLibCheck": true,
12
+ "allowImportingTsExtensions": true,
13
+ "resolveJsonModule": true,
14
+ "isolatedModules": true,
15
+ "noEmit": true,
16
+ "forceConsistentCasingInFileNames": true,
17
+ "verbatimModuleSyntax": true
18
+ },
19
+ "include": ["src/**/*", "bin/**/*", "test/**/*"]
20
+ }