claude-agent-ledger 0.3.0 → 0.4.1
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 +29 -0
- package/README.md +24 -10
- package/bin/agent-ledger.ts +46 -6
- package/package.json +1 -1
- package/src/aggregator.ts +22 -2
- package/src/format.ts +52 -24
- package/src/parser.ts +11 -6
- package/src/types.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,35 @@ 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.1] — 2026-04-22
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `--anonymize` flag — replaces project paths with `~/repo-A`, `~/repo-B`,
|
|
12
|
+
... and session ids with `sess-A`, `sess-B`, ... ordered by spend descending.
|
|
13
|
+
Use this when you want to share your output publicly (HN / Bluesky /
|
|
14
|
+
presentation) without leaking employer names or internal repo names.
|
|
15
|
+
Subagent / model / day labels stay untouched (those are public info).
|
|
16
|
+
|
|
17
|
+
## [0.4.0] — 2026-04-22
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- `--by project` — group spend by Claude Code project directory.
|
|
21
|
+
Decodes `~/.claude/projects/-Users-xiangjie-clawbot` → `~/clawbot` so you can
|
|
22
|
+
see which project is burning budget.
|
|
23
|
+
- `--by session` — group by session ID. Surfaces the single most expensive
|
|
24
|
+
conversations (useful for hunting the one that went sideways).
|
|
25
|
+
- Summary dashboard now shows:
|
|
26
|
+
- `Projects` count
|
|
27
|
+
- `Top project` line
|
|
28
|
+
- `Project mix` block — top 3 projects with $ and %
|
|
29
|
+
- `decodeProjectId()` exported for reuse. Special-cases the `~/` home prefix,
|
|
30
|
+
falls back to slash-joined path for other roots.
|
|
31
|
+
- Row label column now auto-widens up to 40 chars for project paths.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
- `formatTable` takes an optional `groupName` arg so the header column matches
|
|
35
|
+
the group (e.g. "project", "session").
|
|
36
|
+
|
|
8
37
|
## [0.3.0] — 2026-04-22
|
|
9
38
|
|
|
10
39
|
### Added
|
package/README.md
CHANGED
|
@@ -29,6 +29,8 @@ npm install -g claude-agent-ledger
|
|
|
29
29
|
|
|
30
30
|
agent-ledger week --summary # the dashboard (start here)
|
|
31
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
|
|
32
34
|
agent-ledger week --by day # daily bar chart with peak/avg/variance
|
|
33
35
|
agent-ledger month --md > report.md
|
|
34
36
|
```
|
|
@@ -41,7 +43,10 @@ Claude Code's built-in `/cost` shows the **current session** cost. That's it.
|
|
|
41
43
|
|
|
42
44
|
`claude-agent-ledger` answers questions `/cost` can't:
|
|
43
45
|
|
|
44
|
-
- Which of my **10+ subagents** burned the budget? (
|
|
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`)
|
|
45
50
|
- Which **day** of last week was the expensive one? (`--by day`)
|
|
46
51
|
- What would my Claude Max usage have cost on **pay-as-you-go**? (the "shadow cost" framing)
|
|
47
52
|
- Is my **cache reuse ratio** healthy? (auto-derived insight)
|
|
@@ -56,17 +61,24 @@ Run `agent-ledger week --summary` once and decide.
|
|
|
56
61
|
```
|
|
57
62
|
agent-ledger week summary 2026-04-15 → 2026-04-22
|
|
58
63
|
|
|
59
|
-
Shadow cost $13,
|
|
60
|
-
Sessions
|
|
64
|
+
Shadow cost $13,450.05
|
|
65
|
+
Sessions 219
|
|
66
|
+
Projects 9
|
|
61
67
|
Plan reference $200/mo (Max)
|
|
62
|
-
Multiplier
|
|
68
|
+
Multiplier 252× vs Max plan
|
|
63
69
|
|
|
64
|
-
Top subagent (main) $12,
|
|
65
|
-
Top model claude-opus-4-7 $7,
|
|
66
|
-
|
|
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
|
|
67
74
|
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
70
82
|
Cache reuse 38× reads/writes
|
|
71
83
|
Server tools none
|
|
72
84
|
```
|
|
@@ -166,7 +178,9 @@ If Anthropic's actual rate differs (or web_fetch has its own price), open an iss
|
|
|
166
178
|
|
|
167
179
|
| Flag | What it does |
|
|
168
180
|
|---|---|
|
|
169
|
-
| `--
|
|
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. |
|
|
183
|
+
| `--anonymize` | Replace project paths and session ids with `~/repo-A`, `sess-A`, ... so you can share screenshots without doxing your employer / private repos. |
|
|
170
184
|
| `--md` | Markdown table output (good for committing or sharing) |
|
|
171
185
|
| `--json` | Raw JSON output (pipe into jq) |
|
|
172
186
|
| `--plan pro\|max` | Hide dollar columns; show token utilization only |
|
package/bin/agent-ledger.ts
CHANGED
|
@@ -14,6 +14,7 @@ interface Args {
|
|
|
14
14
|
plan: Plan;
|
|
15
15
|
by: GroupKey;
|
|
16
16
|
summary: boolean;
|
|
17
|
+
anonymize: boolean;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
function periodRange(period: Period): { from: Date; to: Date } {
|
|
@@ -37,11 +38,19 @@ function usage(): never {
|
|
|
37
38
|
Flags:
|
|
38
39
|
--summary One-screen dashboard: total + top subagent + top
|
|
39
40
|
model + peak day + cache reuse + leverage vs $200/mo plan
|
|
40
|
-
--by <subagent|model|day>
|
|
41
|
-
|
|
41
|
+
--by <subagent|model|day|project|session>
|
|
42
|
+
Group rows by:
|
|
43
|
+
subagent (default) — agent attribution
|
|
44
|
+
model — opus vs sonnet vs haiku
|
|
45
|
+
day — daily ASCII bar chart
|
|
46
|
+
project — by Claude Code project (decoded path)
|
|
47
|
+
session — by session id (top spenders)
|
|
42
48
|
--md Markdown table output (good for sharing / commits)
|
|
43
49
|
--json Raw JSON output (for piping into jq)
|
|
44
50
|
--plan pro|max Suppress dollar columns (token utilization only)
|
|
51
|
+
--anonymize Replace project paths and session ids with safe
|
|
52
|
+
placeholders (~/repo-A, sess-A) so the output is
|
|
53
|
+
shareable without doxing yourself.
|
|
45
54
|
|
|
46
55
|
Environment:
|
|
47
56
|
NO_COLOR=1 Disable ANSI colors
|
|
@@ -75,7 +84,13 @@ function parseArgs(argv: string[]): Args {
|
|
|
75
84
|
}
|
|
76
85
|
} else if (flag === '--by') {
|
|
77
86
|
const next = rest[i + 1];
|
|
78
|
-
if (
|
|
87
|
+
if (
|
|
88
|
+
next === 'subagent' ||
|
|
89
|
+
next === 'model' ||
|
|
90
|
+
next === 'day' ||
|
|
91
|
+
next === 'project' ||
|
|
92
|
+
next === 'session'
|
|
93
|
+
) {
|
|
79
94
|
by = next;
|
|
80
95
|
i++;
|
|
81
96
|
} else {
|
|
@@ -90,9 +105,31 @@ function parseArgs(argv: string[]): Args {
|
|
|
90
105
|
plan,
|
|
91
106
|
by,
|
|
92
107
|
summary: rest.includes('--summary'),
|
|
108
|
+
anonymize: rest.includes('--anonymize'),
|
|
93
109
|
};
|
|
94
110
|
}
|
|
95
111
|
|
|
112
|
+
// Replace identifying labels with safe placeholders. Run after aggregation,
|
|
113
|
+
// before formatting. We anonymize project paths (which leak employer + repo
|
|
114
|
+
// names) and session ids; leave subagent / model / day untouched (those are
|
|
115
|
+
// either public Anthropic info or generic agent type names).
|
|
116
|
+
function anonymizeReport(report: LedgerReport, group: GroupKey): LedgerReport {
|
|
117
|
+
if (group !== 'project' && group !== 'session') return report;
|
|
118
|
+
const prefix = group === 'project' ? '~/repo-' : 'sess-';
|
|
119
|
+
const labelFor = (i: number): string => {
|
|
120
|
+
// Use A..Z then AA..ZZ for many entries
|
|
121
|
+
if (i < 26) return prefix + String.fromCharCode(65 + i);
|
|
122
|
+
return prefix + String.fromCharCode(65 + Math.floor(i / 26) - 1) + String.fromCharCode(65 + (i % 26));
|
|
123
|
+
};
|
|
124
|
+
const newRows = report.rows.map((r, i) => ({ ...r, subagent: labelFor(i) }));
|
|
125
|
+
return { ...report, rows: newRows };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// For --summary we may need to anonymize multiple sub-reports.
|
|
129
|
+
function anonymizeForSummary(byProject: LedgerReport): LedgerReport {
|
|
130
|
+
return anonymizeReport(byProject, 'project');
|
|
131
|
+
}
|
|
132
|
+
|
|
96
133
|
function planMask(report: LedgerReport, plan: Plan): LedgerReport {
|
|
97
134
|
if (plan === 'payg') return report;
|
|
98
135
|
const zero = () => ({
|
|
@@ -128,16 +165,19 @@ async function main(): Promise<void> {
|
|
|
128
165
|
const turns = await collectTurns({ from, to });
|
|
129
166
|
|
|
130
167
|
if (args.summary) {
|
|
131
|
-
const [bySub, byMod, byDay] = await Promise.all([
|
|
168
|
+
const [bySub, byMod, byDay, byProjRaw] = await Promise.all([
|
|
132
169
|
aggregate(iterTurns(turns), from, to, 'subagent'),
|
|
133
170
|
aggregate(iterTurns(turns), from, to, 'model'),
|
|
134
171
|
aggregate(iterTurns(turns), from, to, 'day'),
|
|
172
|
+
aggregate(iterTurns(turns), from, to, 'project'),
|
|
135
173
|
]);
|
|
136
|
-
|
|
174
|
+
const byProj = args.anonymize ? anonymizeForSummary(byProjRaw) : byProjRaw;
|
|
175
|
+
console.log(formatSummary(bySub, byMod, byDay, byProj, args.period));
|
|
137
176
|
return;
|
|
138
177
|
}
|
|
139
178
|
|
|
140
179
|
let report = await aggregate(iterTurns(turns), from, to, args.by);
|
|
180
|
+
if (args.anonymize) report = anonymizeReport(report, args.by);
|
|
141
181
|
report = planMask(report, args.plan);
|
|
142
182
|
|
|
143
183
|
let output: string;
|
|
@@ -148,7 +188,7 @@ async function main(): Promise<void> {
|
|
|
148
188
|
} else if (args.by === 'day') {
|
|
149
189
|
output = formatTrend(report, args.period);
|
|
150
190
|
} else {
|
|
151
|
-
output = formatTable(report, args.period);
|
|
191
|
+
output = formatTable(report, args.period, args.by);
|
|
152
192
|
}
|
|
153
193
|
console.log(output);
|
|
154
194
|
}
|
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,9 +1,17 @@
|
|
|
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
|
|
4
5
|
const BAR_WIDTH = 40;
|
|
5
6
|
const PLAN_MAX_PRICE = 200; // monthly USD reference for shadow-cost leverage
|
|
6
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
|
+
}
|
|
14
|
+
|
|
7
15
|
// Color helpers — TTY-aware, NO_COLOR-respecting (https://no-color.org/).
|
|
8
16
|
const USE_COLOR = (() => {
|
|
9
17
|
if (process.env.NO_COLOR) return false;
|
|
@@ -52,8 +60,8 @@ function pct(part: number, whole: number): string {
|
|
|
52
60
|
return `${((part / whole) * 100).toFixed(0)}%`;
|
|
53
61
|
}
|
|
54
62
|
|
|
55
|
-
function formatRow(row: AggregatedRow, isTotal = false): string {
|
|
56
|
-
const labelText = row.subagent.padEnd(
|
|
63
|
+
function formatRow(row: AggregatedRow, padWidth: number, isTotal = false): string {
|
|
64
|
+
const labelText = row.subagent.padEnd(padWidth);
|
|
57
65
|
const label = isTotal
|
|
58
66
|
? `${c.bold}${labelText}${c.reset}`
|
|
59
67
|
: `${c.cyan}${labelText}${c.reset}`;
|
|
@@ -126,19 +134,20 @@ function deriveInsights(report: LedgerReport): string[] {
|
|
|
126
134
|
return lines;
|
|
127
135
|
}
|
|
128
136
|
|
|
129
|
-
export function formatTable(report: LedgerReport, label: string): string {
|
|
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));
|
|
130
139
|
const summaryLine =
|
|
131
140
|
`${c.gray}${report.period.from.toISOString().slice(0, 10)} → ${report.period.to.toISOString().slice(0, 10)}${c.reset}` +
|
|
132
141
|
` ${c.dim}·${c.reset} ` +
|
|
133
142
|
`${c.bold}${fmtUsdComma(report.total.cost.totalCost)}${c.reset} ${c.dim}shadow${c.reset}` +
|
|
134
143
|
` ${c.dim}·${c.reset} ${report.total.sessionCount} sessions` +
|
|
135
|
-
` ${c.dim}·${c.reset} ${report.rows.length} ${
|
|
144
|
+
` ${c.dim}·${c.reset} ${report.rows.length} ${groupName}s`;
|
|
136
145
|
|
|
137
146
|
const header = `${c.bold}agent-ledger ${label}${c.reset} ${summaryLine}`;
|
|
138
147
|
|
|
139
148
|
const colsRaw =
|
|
140
149
|
' ' +
|
|
141
|
-
|
|
150
|
+
groupName.padEnd(padWidth) +
|
|
142
151
|
'sess'.padStart(4) +
|
|
143
152
|
' ' +
|
|
144
153
|
'tokens(in/out)'.padStart(18) +
|
|
@@ -148,7 +157,7 @@ export function formatTable(report: LedgerReport, label: string): string {
|
|
|
148
157
|
'cost'.padStart(9);
|
|
149
158
|
const cols = `${c.gray}${colsRaw}${c.reset}`;
|
|
150
159
|
|
|
151
|
-
const sepRaw = ' ' + '─'.repeat(
|
|
160
|
+
const sepRaw = ' ' + '─'.repeat(padWidth + 4 + 2 + 18 + 2 + 32 + 2 + 9);
|
|
152
161
|
const sep = `${c.gray}${sepRaw}${c.reset}`;
|
|
153
162
|
|
|
154
163
|
const lines = [
|
|
@@ -156,9 +165,9 @@ export function formatTable(report: LedgerReport, label: string): string {
|
|
|
156
165
|
'',
|
|
157
166
|
cols,
|
|
158
167
|
sep,
|
|
159
|
-
...report.rows.map((r) => formatRow(r)),
|
|
168
|
+
...report.rows.map((r) => formatRow(r, padWidth)),
|
|
160
169
|
sep,
|
|
161
|
-
formatRow({ ...report.total, subagent: 'total' }, true),
|
|
170
|
+
formatRow({ ...report.total, subagent: 'total' }, padWidth, true),
|
|
162
171
|
];
|
|
163
172
|
|
|
164
173
|
// Server tool footer (only when present, since most rows have 0)
|
|
@@ -251,11 +260,12 @@ function deriveDailyInsights(report: LedgerReport): string[] {
|
|
|
251
260
|
return lines;
|
|
252
261
|
}
|
|
253
262
|
|
|
254
|
-
// Single-screen dashboard. Aggregates the same period across
|
|
263
|
+
// Single-screen dashboard. Aggregates the same period across 4 dimensions.
|
|
255
264
|
export function formatSummary(
|
|
256
265
|
bySubagent: LedgerReport,
|
|
257
266
|
byModel: LedgerReport,
|
|
258
267
|
byDay: LedgerReport,
|
|
268
|
+
byProject: LedgerReport,
|
|
259
269
|
label: string,
|
|
260
270
|
): string {
|
|
261
271
|
const total = bySubagent.total.cost.totalCost;
|
|
@@ -267,6 +277,9 @@ export function formatSummary(
|
|
|
267
277
|
const tSub = top(bySubagent.rows);
|
|
268
278
|
const tMod = top(byModel.rows);
|
|
269
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);
|
|
270
283
|
|
|
271
284
|
const k = (label: string, value: string) =>
|
|
272
285
|
` ${c.gray}${label.padEnd(20)}${c.reset} ${value}`;
|
|
@@ -281,28 +294,43 @@ export function formatSummary(
|
|
|
281
294
|
'',
|
|
282
295
|
k('Shadow cost', dollar(total)),
|
|
283
296
|
k('Sessions', `${bySubagent.total.sessionCount}`),
|
|
297
|
+
k('Projects', `${byProject.rows.length}`),
|
|
284
298
|
k('Plan reference', `$${PLAN_MAX_PRICE}/mo (Max)`),
|
|
285
299
|
k('Multiplier', `${c.bold}${leverage.toFixed(0)}×${c.reset} ${c.dim}vs Max plan${c.reset}`),
|
|
286
300
|
'',
|
|
287
301
|
k('Top subagent', tSub ? `${c.cyan}${tSub.subagent}${c.reset} ${dollar(tSub.cost.totalCost)} ${c.dim}(${pct(tSub.cost.totalCost, total)})${c.reset}` : '—'),
|
|
288
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}` : '—'),
|
|
289
304
|
k('Peak day', tDay ? `${c.cyan}${tDay.subagent}${c.reset} ${dollar(tDay.cost.totalCost)}` : '—'),
|
|
290
|
-
'',
|
|
291
|
-
k('Cache 1h writes', `${fmtTokens(bySubagent.total.cacheCreation1hTokens)} tokens · ${fmtUsd(bySubagent.total.cost.cacheCreation1hCost)}`),
|
|
292
|
-
k('Cache reads', `${fmtTokens(bySubagent.total.cacheReadTokens)} tokens · ${fmtUsd(bySubagent.total.cost.cacheReadCost)}`),
|
|
293
|
-
k(
|
|
294
|
-
'Cache reuse',
|
|
295
|
-
writeTokens > 0
|
|
296
|
-
? `${c.bold}${reuseRatio.toFixed(0)}×${c.reset} ${c.dim}reads/writes${c.reset}`
|
|
297
|
-
: 'n/a',
|
|
298
|
-
),
|
|
299
|
-
k(
|
|
300
|
-
'Server tools',
|
|
301
|
-
bySubagent.total.webSearchRequests + bySubagent.total.webFetchRequests > 0
|
|
302
|
-
? `web_search ×${bySubagent.total.webSearchRequests}, web_fetch ×${bySubagent.total.webFetchRequests} (${fmtUsd(bySubagent.total.cost.serverToolUseCost)})`
|
|
303
|
-
: `${c.dim}none${c.reset}`,
|
|
304
|
-
),
|
|
305
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
|
+
|
|
306
334
|
return lines.join('\n');
|
|
307
335
|
}
|
|
308
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