claude-agent-ledger 0.2.0 → 0.4.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
@@ -5,6 +5,47 @@ All notable changes to `claude-agent-ledger` will be documented here.
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
+ ## [0.4.0] — 2026-04-22
9
+
10
+ ### Added
11
+ - `--by project` — group spend by Claude Code project directory.
12
+ Decodes `~/.claude/projects/-Users-xiangjie-clawbot` → `~/clawbot` so you can
13
+ see which project is burning budget.
14
+ - `--by session` — group by session ID. Surfaces the single most expensive
15
+ conversations (useful for hunting the one that went sideways).
16
+ - Summary dashboard now shows:
17
+ - `Projects` count
18
+ - `Top project` line
19
+ - `Project mix` block — top 3 projects with $ and %
20
+ - `decodeProjectId()` exported for reuse. Special-cases the `~/` home prefix,
21
+ falls back to slash-joined path for other roots.
22
+ - Row label column now auto-widens up to 40 chars for project paths.
23
+
24
+ ### Changed
25
+ - `formatTable` takes an optional `groupName` arg so the header column matches
26
+ the group (e.g. "project", "session").
27
+
28
+ ## [0.3.0] — 2026-04-22
29
+
30
+ ### Added
31
+ - `--summary` flag — single-screen dashboard with shadow cost, top subagent /
32
+ model / day, cache reuse ratio, and **leverage vs $200/mo Max plan**.
33
+ This is the killer view for "why install over /cost?"
34
+ - Auto-derived insights footer on the default table:
35
+ - Top contributor with %
36
+ - 1h cache spend call-out (when ≥5% of total)
37
+ - Cache reuse ratio (read/write) with verdict
38
+ - Monthly $ equivalent + leverage vs Max plan
39
+ - Daily-mode insights: peak day, avg/day, peak/trough variance
40
+
41
+ ### Changed
42
+ - `--by day` polish:
43
+ - Bar fixed to 40-char width so session counts align in their own column
44
+ - Brighter cyan (`\x1b[96m`) bar character
45
+ - Total row separated by horizontal rule, properly aligned
46
+ - Header line now shows period + total + sessions + group count at a glance:
47
+ `agent-ledger week 2026-04-15 → 2026-04-22 · $13,356.27 shadow · 217 sessions · 7 agents`
48
+
8
49
  ## [0.2.0] — 2026-04-21
9
50
 
10
51
  ### Added
package/README.md CHANGED
@@ -27,15 +27,61 @@ If you run **seven** subagents daily (reliability, release, review, iOS factory,
27
27
  npm install -g claude-agent-ledger
28
28
  # or: bun install -g claude-agent-ledger
29
29
 
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
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 project # which project ate your tokens
33
+ agent-ledger week --by session # which single sessions went sideways
34
+ agent-ledger week --by day # daily bar chart with peak/avg/variance
33
35
  agent-ledger month --md > report.md
34
36
  ```
35
37
 
36
38
  No server. No account. No data leaves your machine.
37
39
 
38
- ## Example output (real)
40
+ ## Why this and not `/cost`?
41
+
42
+ Claude Code's built-in `/cost` shows the **current session** cost. That's it.
43
+
44
+ `claude-agent-ledger` answers questions `/cost` can't:
45
+
46
+ - Which of my **10+ subagents** burned the budget? (default)
47
+ - Which **Claude model** burned the budget — Opus vs Sonnet vs Haiku? (`--by model`)
48
+ - Which **project** in `~/.claude/projects/` ate the tokens? (`--by project`)
49
+ - Which **single sessions** went sideways and burned $X? (`--by session`)
50
+ - Which **day** of last week was the expensive one? (`--by day`)
51
+ - What would my Claude Max usage have cost on **pay-as-you-go**? (the "shadow cost" framing)
52
+ - Is my **cache reuse ratio** healthy? (auto-derived insight)
53
+ - What's the **leverage** I'm getting from my $200/mo subscription? (e.g. 250×)
54
+
55
+ Run `agent-ledger week --summary` once and decide.
56
+
57
+ ## Example output (real, from the author's machine)
58
+
59
+ ### `--summary` (the dashboard)
60
+
61
+ ```
62
+ agent-ledger week summary 2026-04-15 → 2026-04-22
63
+
64
+ Shadow cost $13,450.05
65
+ Sessions 219
66
+ Projects 9
67
+ Plan reference $200/mo (Max)
68
+ Multiplier 252× vs Max plan
69
+
70
+ Top subagent (main) $12,787.19 (95%)
71
+ Top model claude-opus-4-7 $7,278.87 (54%)
72
+ Top project ~/clawbot $6,657.93 (50%)
73
+ Peak day 2026-04-15 $3,679.16
74
+
75
+ Project mix
76
+ ~/clawbot $6,657.93 (50%)
77
+ ~/clawbot-work $4,433.32 (33%)
78
+ ~/ZStack-zstack-workspace $1,890.02 (14%)
79
+
80
+ Cache 1h writes 119.65M tokens · $3589.46
81
+ Cache reads 5.59B tokens · $8236.15
82
+ Cache reuse 38× reads/writes
83
+ Server tools none
84
+ ```
39
85
 
40
86
  ### Default — by subagent
41
87
 
@@ -132,7 +178,8 @@ If Anthropic's actual rate differs (or web_fetch has its own price), open an iss
132
178
 
133
179
  | Flag | What it does |
134
180
  |---|---|
135
- | `--by <subagent\|model\|day>` | Group rows. Default: `subagent`. `day` renders as ASCII bar chart. |
181
+ | `--summary` | One-screen dashboard with all the headline numbers |
182
+ | `--by <subagent\|model\|day\|project\|session>` | Group rows. Default: `subagent`. `day` renders as ASCII bar chart. |
136
183
  | `--md` | Markdown table output (good for committing or sharing) |
137
184
  | `--json` | Raw JSON output (pipe into jq) |
138
185
  | `--plan pro\|max` | Hide dollar columns; show token utilization only |
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
  import { aggregate, type GroupKey } from '../src/aggregator.ts';
3
- import { formatMarkdown, formatTable, formatTrend } from '../src/format.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';
@@ -13,6 +13,7 @@ interface Args {
13
13
  json: boolean;
14
14
  plan: Plan;
15
15
  by: GroupKey;
16
+ summary: boolean;
16
17
  }
17
18
 
18
19
  function periodRange(period: Period): { from: Date; to: Date } {
@@ -34,24 +35,29 @@ function usage(): never {
34
35
  console.error(`Usage: agent-ledger <today|week|month> [flags]
35
36
 
36
37
  Flags:
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)
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|project|session>
41
+ Group rows by:
42
+ subagent (default) — agent attribution
43
+ model — opus vs sonnet vs haiku
44
+ day — daily ASCII bar chart
45
+ project — by Claude Code project (decoded path)
46
+ session — by session id (top spenders)
47
+ --md Markdown table output (good for sharing / commits)
48
+ --json Raw JSON output (for piping into jq)
41
49
  --plan pro|max Suppress dollar columns (token utilization only)
42
- Default is pay-as-you-go: shows shadow cost in $.
43
50
 
44
51
  Environment:
45
52
  NO_COLOR=1 Disable ANSI colors
46
- FORCE_COLOR=1 Force colors even when piping
53
+ FORCE_COLOR=1 Force colors when piping
47
54
 
48
55
  Examples:
49
- agent-ledger today
50
- agent-ledger week --by model
56
+ agent-ledger week --summary # the dashboard
51
57
  agent-ledger week --by day # daily bar chart
52
- agent-ledger week --md > week.md
53
- agent-ledger month --json | jq '.total.cost.totalCost'
54
- agent-ledger week --plan max
58
+ agent-ledger week --by model # which Claude model burned the budget
59
+ agent-ledger month --md > month.md
60
+ agent-ledger week --json | jq '.total.cost.totalCost'
55
61
  `);
56
62
  process.exit(1);
57
63
  }
@@ -74,7 +80,13 @@ function parseArgs(argv: string[]): Args {
74
80
  }
75
81
  } else if (flag === '--by') {
76
82
  const next = rest[i + 1];
77
- if (next === 'subagent' || next === 'model' || next === 'day') {
83
+ if (
84
+ next === 'subagent' ||
85
+ next === 'model' ||
86
+ next === 'day' ||
87
+ next === 'project' ||
88
+ next === 'session'
89
+ ) {
78
90
  by = next;
79
91
  i++;
80
92
  } else {
@@ -88,6 +100,7 @@ function parseArgs(argv: string[]): Args {
88
100
  json: rest.includes('--json'),
89
101
  plan,
90
102
  by,
103
+ summary: rest.includes('--summary'),
91
104
  };
92
105
  }
93
106
 
@@ -109,10 +122,34 @@ function planMask(report: LedgerReport, plan: Plan): LedgerReport {
109
122
  };
110
123
  }
111
124
 
125
+ // Materialize once so --summary can re-aggregate by 3 dimensions cheaply.
126
+ async function collectTurns(opts: { from: Date; to: Date }): Promise<SessionTurn[]> {
127
+ const out: SessionTurn[] = [];
128
+ for await (const t of parseAll(opts)) out.push(t);
129
+ return out;
130
+ }
131
+
132
+ async function* iterTurns(turns: SessionTurn[]): AsyncGenerator<SessionTurn> {
133
+ for (const t of turns) yield t;
134
+ }
135
+
112
136
  async function main(): Promise<void> {
113
137
  const args = parseArgs(process.argv.slice(2));
114
138
  const { from, to } = periodRange(args.period);
115
- let report = await aggregate(parseAll({ from, to }), from, to, args.by);
139
+ const turns = await collectTurns({ from, to });
140
+
141
+ if (args.summary) {
142
+ const [bySub, byMod, byDay, byProj] = await Promise.all([
143
+ aggregate(iterTurns(turns), from, to, 'subagent'),
144
+ aggregate(iterTurns(turns), from, to, 'model'),
145
+ aggregate(iterTurns(turns), from, to, 'day'),
146
+ aggregate(iterTurns(turns), from, to, 'project'),
147
+ ]);
148
+ console.log(formatSummary(bySub, byMod, byDay, byProj, args.period));
149
+ return;
150
+ }
151
+
152
+ let report = await aggregate(iterTurns(turns), from, to, args.by);
116
153
  report = planMask(report, args.plan);
117
154
 
118
155
  let output: string;
@@ -123,7 +160,7 @@ async function main(): Promise<void> {
123
160
  } else if (args.by === 'day') {
124
161
  output = formatTrend(report, args.period);
125
162
  } else {
126
- output = formatTable(report, args.period);
163
+ output = formatTable(report, args.period, args.by);
127
164
  }
128
165
  console.log(output);
129
166
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-agent-ledger",
3
- "version": "0.2.0",
3
+ "version": "0.4.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
@@ -3,8 +3,26 @@ import type { AggregatedRow, LedgerReport, SessionTurn } from './types.ts';
3
3
 
4
4
  const MAIN_AGENT_LABEL = '(main)';
5
5
  const UNKNOWN_MODEL_LABEL = '(no-model)';
6
-
7
- export type GroupKey = 'subagent' | 'model' | 'day';
6
+ const UNKNOWN_PROJECT_LABEL = '(no-project)';
7
+
8
+ export type GroupKey = 'subagent' | 'model' | 'day' | 'project' | 'session';
9
+
10
+ // Decode Claude Code's project directory name to something readable.
11
+ // Encoding rule: absolute path with `/` replaced by `-` (leading `-` for root).
12
+ // "-Users-xiangjie-clawbot" → "~/clawbot"
13
+ // "-Users-xiangjie-newlive-agent-ledger" → "~/newlive/agent-ledger" (best-effort; ambiguous on dirs containing '-')
14
+ // We special-case the "$HOME" prefix (`-Users-<user>-`) to render `~` and skip the rest of the
15
+ // "-" → "/" conversion, since that's the only place we can be sure of segment boundaries.
16
+ export function decodeProjectId(encoded: string): string {
17
+ if (!encoded.startsWith('-')) return encoded;
18
+ const parts = encoded.slice(1).split('-'); // drop leading '-'
19
+ // Match common $HOME pattern: ['Users', '<user>', ...rest]
20
+ if (parts.length >= 3 && parts[0] === 'Users') {
21
+ return '~/' + parts.slice(2).join('-');
22
+ }
23
+ // Fallback: just convert all - to /
24
+ return '/' + parts.join('/');
25
+ }
8
26
 
9
27
  function emptyRow(label: string): AggregatedRow {
10
28
  return {
@@ -44,6 +62,8 @@ function splitCache(turn: SessionTurn): { fiveMin: number; oneHour: number } {
44
62
  function keyOf(turn: SessionTurn, group: GroupKey): string {
45
63
  if (group === 'model') return turn.model ?? UNKNOWN_MODEL_LABEL;
46
64
  if (group === 'day') return turn.timestamp.slice(0, 10);
65
+ if (group === 'project') return decodeProjectId(turn.projectId ?? UNKNOWN_PROJECT_LABEL);
66
+ if (group === 'session') return turn.sessionId.slice(0, 8);
47
67
  return turn.subagentType ?? MAIN_AGENT_LABEL;
48
68
  }
49
69
 
package/src/format.ts CHANGED
@@ -1,6 +1,16 @@
1
1
  import type { AggregatedRow, LedgerReport } from './types.ts';
2
2
 
3
3
  const PAD_LABEL = 22;
4
+ const PAD_LABEL_WIDE = 40; // for project paths
5
+ const BAR_WIDTH = 40;
6
+ const PLAN_MAX_PRICE = 200; // monthly USD reference for shadow-cost leverage
7
+
8
+ // Detect when we should give labels more room (project paths can be long).
9
+ function widestLabel(rows: { subagent: string }[]): number {
10
+ let w = 0;
11
+ for (const r of rows) if (r.subagent.length > w) w = r.subagent.length;
12
+ return w;
13
+ }
4
14
 
5
15
  // Color helpers — TTY-aware, NO_COLOR-respecting (https://no-color.org/).
6
16
  const USE_COLOR = (() => {
@@ -14,6 +24,7 @@ const c = {
14
24
  bold: USE_COLOR ? '\x1b[1m' : '',
15
25
  green: USE_COLOR ? '\x1b[32m' : '',
16
26
  cyan: USE_COLOR ? '\x1b[36m' : '',
27
+ cyanBright: USE_COLOR ? '\x1b[96m' : '',
17
28
  yellow: USE_COLOR ? '\x1b[33m' : '',
18
29
  red: USE_COLOR ? '\x1b[31m' : '',
19
30
  magenta: USE_COLOR ? '\x1b[35m' : '',
@@ -22,6 +33,7 @@ const c = {
22
33
  };
23
34
 
24
35
  function fmtTokens(n: number): string {
36
+ if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B`;
25
37
  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
26
38
  if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
27
39
  return String(n);
@@ -31,6 +43,10 @@ function fmtUsd(n: number): string {
31
43
  return `$${n.toFixed(2)}`;
32
44
  }
33
45
 
46
+ function fmtUsdComma(n: number): string {
47
+ return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
48
+ }
49
+
34
50
  function colorCost(n: number, padded: string): string {
35
51
  if (!USE_COLOR) return padded;
36
52
  if (n >= 1000) return `${c.bold}${c.red}${padded}${c.reset}`;
@@ -39,8 +55,13 @@ function colorCost(n: number, padded: string): string {
39
55
  return `${c.dim}${padded}${c.reset}`;
40
56
  }
41
57
 
42
- function formatRow(row: AggregatedRow, isTotal = false): string {
43
- const labelText = row.subagent.padEnd(PAD_LABEL);
58
+ function pct(part: number, whole: number): string {
59
+ if (whole === 0) return '0%';
60
+ return `${((part / whole) * 100).toFixed(0)}%`;
61
+ }
62
+
63
+ function formatRow(row: AggregatedRow, padWidth: number, isTotal = false): string {
64
+ const labelText = row.subagent.padEnd(padWidth);
44
65
  const label = isTotal
45
66
  ? `${c.bold}${labelText}${c.reset}`
46
67
  : `${c.cyan}${labelText}${c.reset}`;
@@ -54,14 +75,79 @@ function formatRow(row: AggregatedRow, isTotal = false): string {
54
75
  return ` ${label}${sessions} ${io} ${c.dim}1h:${c.reset}${cached1h} ${c.dim}5m:${c.reset}${cached5m} ${c.dim}r:${c.reset}${cacheRead} ${cost}`;
55
76
  }
56
77
 
57
- export function formatTable(report: LedgerReport, label: string): string {
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}`;
78
+ // Auto-derived insights. The whole point of installing the tool over /cost.
79
+ function deriveInsights(report: LedgerReport): string[] {
80
+ const lines: string[] = [];
81
+ const total = report.total.cost.totalCost;
82
+ if (total === 0) return lines;
83
+
84
+ // Top contributor with %
85
+ const top = report.rows[0];
86
+ if (top && top.cost.totalCost > 0) {
87
+ lines.push(
88
+ ` ${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}`,
89
+ );
90
+ }
91
+
92
+ // 1h cache call-out (if non-trivial)
93
+ const oneHourCost =
94
+ report.total.cost.cacheCreation1hCost ?? 0;
95
+ if (oneHourCost >= total * 0.05) {
96
+ lines.push(
97
+ ` ${c.yellow}💾${c.reset} 1h cache writes: ${fmtTokens(report.total.cacheCreation1hTokens)} tokens · ${fmtUsd(oneHourCost)} ${c.dim}(${pct(oneHourCost, total)} of total)${c.reset}`,
98
+ );
99
+ }
100
+
101
+ // Cache reuse ratio — read vs write
102
+ const writeTokens =
103
+ report.total.cacheCreation1hTokens + report.total.cacheCreation5mTokens;
104
+ if (writeTokens > 0) {
105
+ const ratio = report.total.cacheReadTokens / writeTokens;
106
+ const verdict =
107
+ ratio >= 20
108
+ ? `${c.green}excellent reuse${c.reset}`
109
+ : ratio >= 5
110
+ ? `${c.yellow}OK reuse${c.reset}`
111
+ : `${c.red}low reuse${c.reset}`;
112
+ lines.push(
113
+ ` ${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}`,
114
+ );
115
+ }
116
+
117
+ // Plan leverage
118
+ const dayCount =
119
+ Math.max(
120
+ 1,
121
+ Math.round(
122
+ (report.period.to.getTime() - report.period.from.getTime()) /
123
+ (1000 * 60 * 60 * 24),
124
+ ),
125
+ );
126
+ const monthlyEquivalent = (total / dayCount) * 30;
127
+ const leverage = monthlyEquivalent / PLAN_MAX_PRICE;
128
+ if (leverage >= 1) {
129
+ lines.push(
130
+ ` ${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}`,
131
+ );
132
+ }
133
+
134
+ return lines;
135
+ }
136
+
137
+ export function formatTable(report: LedgerReport, label: string, groupName: string = 'subagent'): string {
138
+ const padWidth = Math.max(PAD_LABEL, Math.min(PAD_LABEL_WIDE, widestLabel(report.rows) + 2));
139
+ const summaryLine =
140
+ `${c.gray}${report.period.from.toISOString().slice(0, 10)} → ${report.period.to.toISOString().slice(0, 10)}${c.reset}` +
141
+ ` ${c.dim}·${c.reset} ` +
142
+ `${c.bold}${fmtUsdComma(report.total.cost.totalCost)}${c.reset} ${c.dim}shadow${c.reset}` +
143
+ ` ${c.dim}·${c.reset} ${report.total.sessionCount} sessions` +
144
+ ` ${c.dim}·${c.reset} ${report.rows.length} ${groupName}s`;
145
+
146
+ const header = `${c.bold}agent-ledger ${label}${c.reset} ${summaryLine}`;
61
147
 
62
148
  const colsRaw =
63
149
  ' ' +
64
- 'subagent'.padEnd(PAD_LABEL) +
150
+ groupName.padEnd(padWidth) +
65
151
  'sess'.padStart(4) +
66
152
  ' ' +
67
153
  'tokens(in/out)'.padStart(18) +
@@ -71,7 +157,7 @@ export function formatTable(report: LedgerReport, label: string): string {
71
157
  'cost'.padStart(9);
72
158
  const cols = `${c.gray}${colsRaw}${c.reset}`;
73
159
 
74
- const sepRaw = ' ' + '─'.repeat(PAD_LABEL + 4 + 2 + 18 + 2 + 32 + 2 + 9);
160
+ const sepRaw = ' ' + '─'.repeat(padWidth + 4 + 2 + 18 + 2 + 32 + 2 + 9);
75
161
  const sep = `${c.gray}${sepRaw}${c.reset}`;
76
162
 
77
163
  const lines = [
@@ -79,9 +165,9 @@ export function formatTable(report: LedgerReport, label: string): string {
79
165
  '',
80
166
  cols,
81
167
  sep,
82
- ...report.rows.map((r) => formatRow(r)),
168
+ ...report.rows.map((r) => formatRow(r, padWidth)),
83
169
  sep,
84
- formatRow({ ...report.total, subagent: 'total' }, true),
170
+ formatRow({ ...report.total, subagent: 'total' }, padWidth, true),
85
171
  ];
86
172
 
87
173
  // Server tool footer (only when present, since most rows have 0)
@@ -95,35 +181,156 @@ export function formatTable(report: LedgerReport, label: string): string {
95
181
  );
96
182
  }
97
183
 
184
+ // Insights
185
+ const insights = deriveInsights(report);
186
+ if (insights.length) {
187
+ lines.push('');
188
+ lines.push(...insights);
189
+ }
190
+
98
191
  return lines.join('\n');
99
192
  }
100
193
 
101
- // Daily trend chart: vertical-ish ASCII bars per day.
194
+ // Daily trend chart with fixed-width bars.
102
195
  export function formatTrend(report: LedgerReport, label: string): string {
103
196
  const max = report.rows.reduce((m, r) => Math.max(m, r.cost.totalCost), 0);
104
- const barWidth = 36;
105
197
 
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}`;
198
+ const summaryLine =
199
+ `${c.gray}${report.period.from.toISOString().slice(0, 10)} → ${report.period.to.toISOString().slice(0, 10)}${c.reset}` +
200
+ ` ${c.dim}·${c.reset} ` +
201
+ `${c.bold}${fmtUsdComma(report.total.cost.totalCost)}${c.reset} ${c.dim}shadow${c.reset}` +
202
+ ` ${c.dim}·${c.reset} ${report.total.sessionCount} sessions across ${report.rows.length} days`;
203
+
204
+ const header = `${c.bold}agent-ledger ${label} (daily)${c.reset} ${summaryLine}`;
109
205
 
110
206
  const lines = [header, ''];
111
207
  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 : '');
208
+ const bars = Math.round((row.cost.totalCost / Math.max(max, 0.01)) * BAR_WIDTH);
209
+ const barFilled = '█'.repeat(bars);
210
+ const barPadding = ' '.repeat(BAR_WIDTH - bars);
211
+ const bar = `${USE_COLOR ? c.cyanBright : ''}${barFilled}${USE_COLOR ? c.reset : ''}${barPadding}`;
115
212
  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}`);
213
+ const cost = colorCost(row.cost.totalCost, fmtUsd(row.cost.totalCost).padStart(10));
214
+ const sessionsRaw = `${row.sessionCount} sess`;
215
+ const sessions = `${c.dim}· ${sessionsRaw}${c.reset}`;
216
+ lines.push(` ${dateLabel} ${cost} ${bar} ${sessions}`);
119
217
  }
120
- lines.push('');
218
+ const sepWidth = 12 + 2 + 10 + 2 + BAR_WIDTH + 2 + 10;
219
+ lines.push(` ${c.gray}${'─'.repeat(sepWidth)}${c.reset}`);
220
+ const totalLabel = `${c.bold}total${c.reset}`.padEnd(12 + (USE_COLOR ? c.bold.length + c.reset.length : 0));
221
+ const totalCost = colorCost(
222
+ report.total.cost.totalCost,
223
+ fmtUsd(report.total.cost.totalCost).padStart(10),
224
+ );
225
+ lines.push(` ${totalLabel} ${totalCost}`);
226
+
227
+ // Daily-mode insights
228
+ const insights = deriveDailyInsights(report);
229
+ if (insights.length) {
230
+ lines.push('');
231
+ lines.push(...insights);
232
+ }
233
+
234
+ return lines.join('\n');
235
+ }
236
+
237
+ function deriveDailyInsights(report: LedgerReport): string[] {
238
+ if (report.rows.length === 0) return [];
239
+ const lines: string[] = [];
240
+ const peak = [...report.rows].sort((a, b) => b.cost.totalCost - a.cost.totalCost)[0]!;
241
+ const trough = [...report.rows]
242
+ .filter((r) => r.cost.totalCost > 0)
243
+ .sort((a, b) => a.cost.totalCost - b.cost.totalCost)[0];
244
+ const avg = report.total.cost.totalCost / report.rows.length;
245
+
121
246
  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
- )}`,
247
+ ` ${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}`,
126
248
  );
249
+ lines.push(
250
+ ` ${c.gray}📊${c.reset} avg/day: ${fmtUsd(avg)}`,
251
+ );
252
+ if (trough && trough.cost.totalCost > 0) {
253
+ const variance = peak.cost.totalCost / trough.cost.totalCost;
254
+ if (variance >= 3) {
255
+ lines.push(
256
+ ` ${c.yellow}🌪${c.reset} variance: ${c.bold}${variance.toFixed(0)}×${c.reset} ${c.dim}between peak (${peak.subagent}) and trough (${trough.subagent})${c.reset}`,
257
+ );
258
+ }
259
+ }
260
+ return lines;
261
+ }
262
+
263
+ // Single-screen dashboard. Aggregates the same period across 4 dimensions.
264
+ export function formatSummary(
265
+ bySubagent: LedgerReport,
266
+ byModel: LedgerReport,
267
+ byDay: LedgerReport,
268
+ byProject: LedgerReport,
269
+ label: string,
270
+ ): string {
271
+ const total = bySubagent.total.cost.totalCost;
272
+ const dayCount = Math.max(1, byDay.rows.length);
273
+ const monthlyEquivalent = (total / dayCount) * 30;
274
+ const leverage = monthlyEquivalent / PLAN_MAX_PRICE;
275
+
276
+ const top = (rows: AggregatedRow[]) => rows[0];
277
+ const tSub = top(bySubagent.rows);
278
+ const tMod = top(byModel.rows);
279
+ const tDay = [...byDay.rows].sort((a, b) => b.cost.totalCost - a.cost.totalCost)[0];
280
+ const tProj = top(byProject.rows);
281
+ // Top 3 projects for quick distribution view
282
+ const topProjects = byProject.rows.slice(0, 3);
283
+
284
+ const k = (label: string, value: string) =>
285
+ ` ${c.gray}${label.padEnd(20)}${c.reset} ${value}`;
286
+ const dollar = (n: number) => `${c.bold}${fmtUsdComma(n)}${c.reset}`;
287
+
288
+ const writeTokens =
289
+ bySubagent.total.cacheCreation1hTokens + bySubagent.total.cacheCreation5mTokens;
290
+ const reuseRatio = writeTokens > 0 ? bySubagent.total.cacheReadTokens / writeTokens : 0;
291
+
292
+ const lines = [
293
+ `${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}`,
294
+ '',
295
+ k('Shadow cost', dollar(total)),
296
+ k('Sessions', `${bySubagent.total.sessionCount}`),
297
+ k('Projects', `${byProject.rows.length}`),
298
+ k('Plan reference', `$${PLAN_MAX_PRICE}/mo (Max)`),
299
+ k('Multiplier', `${c.bold}${leverage.toFixed(0)}×${c.reset} ${c.dim}vs Max plan${c.reset}`),
300
+ '',
301
+ k('Top subagent', tSub ? `${c.cyan}${tSub.subagent}${c.reset} ${dollar(tSub.cost.totalCost)} ${c.dim}(${pct(tSub.cost.totalCost, total)})${c.reset}` : '—'),
302
+ k('Top model', tMod ? `${c.cyan}${tMod.subagent}${c.reset} ${dollar(tMod.cost.totalCost)} ${c.dim}(${pct(tMod.cost.totalCost, total)})${c.reset}` : '—'),
303
+ k('Top project', tProj ? `${c.cyan}${tProj.subagent}${c.reset} ${dollar(tProj.cost.totalCost)} ${c.dim}(${pct(tProj.cost.totalCost, total)})${c.reset}` : '—'),
304
+ k('Peak day', tDay ? `${c.cyan}${tDay.subagent}${c.reset} ${dollar(tDay.cost.totalCost)}` : '—'),
305
+ ];
306
+
307
+ // If multiple projects, list top 3 as a small breakdown
308
+ if (topProjects.length > 1) {
309
+ lines.push('');
310
+ lines.push(` ${c.gray}Project mix${c.reset}`);
311
+ for (const p of topProjects) {
312
+ lines.push(
313
+ ` ${c.cyan}${p.subagent.padEnd(36)}${c.reset} ${dollar(p.cost.totalCost)} ${c.dim}(${pct(p.cost.totalCost, total)})${c.reset}`,
314
+ );
315
+ }
316
+ }
317
+
318
+ lines.push('');
319
+ lines.push(k('Cache 1h writes', `${fmtTokens(bySubagent.total.cacheCreation1hTokens)} tokens · ${fmtUsd(bySubagent.total.cost.cacheCreation1hCost)}`));
320
+ lines.push(k('Cache reads', `${fmtTokens(bySubagent.total.cacheReadTokens)} tokens · ${fmtUsd(bySubagent.total.cost.cacheReadCost)}`));
321
+ lines.push(k(
322
+ 'Cache reuse',
323
+ writeTokens > 0
324
+ ? `${c.bold}${reuseRatio.toFixed(0)}×${c.reset} ${c.dim}reads/writes${c.reset}`
325
+ : 'n/a',
326
+ ));
327
+ lines.push(k(
328
+ 'Server tools',
329
+ bySubagent.total.webSearchRequests + bySubagent.total.webFetchRequests > 0
330
+ ? `web_search ×${bySubagent.total.webSearchRequests}, web_fetch ×${bySubagent.total.webFetchRequests} (${fmtUsd(bySubagent.total.cost.serverToolUseCost)})`
331
+ : `${c.dim}none${c.reset}`,
332
+ ));
333
+
127
334
  return lines.join('\n');
128
335
  }
129
336
 
package/src/parser.ts CHANGED
@@ -13,6 +13,7 @@ export interface ParseOptions {
13
13
 
14
14
  interface LogFile {
15
15
  path: string;
16
+ projectId: string;
16
17
  subagentType: string | undefined;
17
18
  }
18
19
 
@@ -34,11 +35,11 @@ export async function* findSessionLogs(
34
35
  return;
35
36
  }
36
37
  for (const project of projects) {
37
- yield* walkForJsonl(join(projectsDir, project));
38
+ yield* walkForJsonl(join(projectsDir, project), project);
38
39
  }
39
40
  }
40
41
 
41
- async function* walkForJsonl(dir: string): AsyncGenerator<LogFile> {
42
+ async function* walkForJsonl(dir: string, projectId: string): AsyncGenerator<LogFile> {
42
43
  let entries: string[] = [];
43
44
  try {
44
45
  entries = await readdir(dir);
@@ -55,11 +56,11 @@ async function* walkForJsonl(dir: string): AsyncGenerator<LogFile> {
55
56
  continue;
56
57
  }
57
58
  if (isDir) {
58
- yield* walkForJsonl(full);
59
+ yield* walkForJsonl(full, projectId);
59
60
  continue;
60
61
  }
61
62
  if (name.endsWith('.jsonl')) {
62
- yield { path: full, subagentType: await resolveSubagentType(full) };
63
+ yield { path: full, projectId, subagentType: await resolveSubagentType(full) };
63
64
  }
64
65
  }
65
66
  }
@@ -81,6 +82,7 @@ async function resolveSubagentType(jsonlPath: string): Promise<string | undefine
81
82
  export async function* parseFile(
82
83
  path: string,
83
84
  subagentType?: string,
85
+ projectId?: string,
84
86
  ): AsyncGenerator<SessionTurn> {
85
87
  const file = Bun.file(path);
86
88
  const text = await file.text();
@@ -94,7 +96,7 @@ export async function* parseFile(
94
96
  } catch {
95
97
  continue;
96
98
  }
97
- yield extractTurn(obj, sessionId, subagentType);
99
+ yield extractTurn(obj, sessionId, subagentType, projectId);
98
100
  }
99
101
  }
100
102
 
@@ -102,6 +104,7 @@ function extractTurn(
102
104
  obj: Record<string, unknown>,
103
105
  sessionId: string,
104
106
  subagentType: string | undefined,
107
+ projectId: string | undefined,
105
108
  ): SessionTurn {
106
109
  const msg = (obj.message ?? {}) as Record<string, unknown>;
107
110
  const usage = (msg.usage ?? undefined) as TokenUsage | undefined;
@@ -109,11 +112,13 @@ function extractTurn(
109
112
  // If the raw turn carries its own subagentType (our fixture format), prefer it;
110
113
  // otherwise fall back to the directory-derived one.
111
114
  const perTurnSubagent = ((obj.subagentType as string) ?? undefined) || undefined;
115
+ const perTurnProject = ((obj.projectId as string) ?? undefined) || undefined;
112
116
 
113
117
  return {
114
118
  type: ((obj.type as string) ?? 'assistant') as SessionTurn['type'],
115
119
  timestamp: (obj.timestamp as string) ?? new Date().toISOString(),
116
120
  sessionId,
121
+ projectId: perTurnProject ?? projectId,
117
122
  subagentType: perTurnSubagent ?? subagentType,
118
123
  model,
119
124
  usage,
@@ -125,7 +130,7 @@ function extractTurn(
125
130
  export async function* parseAll(opts: ParseOptions = {}): AsyncGenerator<SessionTurn> {
126
131
  const dir = opts.projectsDir ?? CLAUDE_PROJECTS_DIR;
127
132
  for await (const file of findSessionLogs(dir)) {
128
- for await (const turn of parseFile(file.path, file.subagentType)) {
133
+ for await (const turn of parseFile(file.path, file.subagentType, file.projectId)) {
129
134
  if (opts.from && new Date(turn.timestamp) < opts.from) continue;
130
135
  if (opts.to && new Date(turn.timestamp) > opts.to) continue;
131
136
  yield turn;
package/src/types.ts CHANGED
@@ -32,6 +32,7 @@ export interface SessionTurn {
32
32
  type: TurnType;
33
33
  timestamp: string;
34
34
  sessionId: string;
35
+ projectId?: string | undefined;
35
36
  subagentType?: string | undefined;
36
37
  model?: ModelId | undefined;
37
38
  usage?: TokenUsage | undefined;