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 +41 -0
- package/README.md +52 -5
- package/bin/agent-ledger.ts +53 -16
- package/package.json +1 -1
- package/src/aggregator.ts +22 -2
- package/src/format.ts +233 -26
- package/src/parser.ts +11 -6
- package/src/types.ts +1 -0
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
|
|
31
|
-
agent-ledger week --by model #
|
|
32
|
-
agent-ledger week --by
|
|
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
|
-
##
|
|
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
|
-
| `--
|
|
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 |
|
package/bin/agent-ledger.ts
CHANGED
|
@@ -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
|
-
--
|
|
38
|
-
|
|
39
|
-
--
|
|
40
|
-
|
|
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
|
|
53
|
+
FORCE_COLOR=1 Force colors when piping
|
|
47
54
|
|
|
48
55
|
Examples:
|
|
49
|
-
agent-ledger
|
|
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 --
|
|
53
|
-
agent-ledger month --
|
|
54
|
-
agent-ledger week --
|
|
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 (
|
|
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
|
-
|
|
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
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
|
-
|
|
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
|
|
43
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
107
|
-
`${c.
|
|
108
|
-
|
|
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)) *
|
|
113
|
-
const
|
|
114
|
-
|
|
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(
|
|
117
|
-
const
|
|
118
|
-
|
|
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
|
-
|
|
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.
|
|
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