claude-agent-ledger 0.4.1 → 0.7.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/package.json CHANGED
@@ -1,15 +1,21 @@
1
1
  {
2
2
  "name": "claude-agent-ledger",
3
- "version": "0.4.1",
3
+ "version": "0.7.1",
4
4
  "description": "See exactly where your Claude Code budget goes — per subagent, per task.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "agent-ledger": "./bin/agent-ledger.ts"
7
+ "agent-ledger": "bin/agent-ledger.ts"
8
8
  },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "LICENSE",
13
+ "README.md"
14
+ ],
9
15
  "scripts": {
10
16
  "dev": "bun run bin/agent-ledger.ts",
11
17
  "test": "bun test",
12
- "build": "bun build bin/agent-ledger.ts --target=node --outdir dist",
18
+ "build": "bun build bin/agent-ledger.ts --target=bun --outdir dist",
13
19
  "typecheck": "tsc --noEmit"
14
20
  },
15
21
  "engines": {
@@ -27,10 +33,15 @@
27
33
  "license": "MIT",
28
34
  "repository": {
29
35
  "type": "git",
30
- "url": "https://github.com/XJM-free/claude-agent-ledger.git"
36
+ "url": "git+https://github.com/XJM-free/claude-agent-ledger.git"
37
+ },
38
+ "homepage": "https://github.com/XJM-free/claude-agent-ledger#readme",
39
+ "bugs": {
40
+ "url": "https://github.com/XJM-free/claude-agent-ledger/issues"
31
41
  },
32
42
  "devDependencies": {
33
43
  "@types/bun": "latest",
44
+ "fast-check": "^4.7.0",
34
45
  "typescript": "^5.6.0"
35
46
  }
36
47
  }
package/src/aggregator.ts CHANGED
@@ -1,26 +1,21 @@
1
1
  import { costFor } from './pricing.ts';
2
- import type { AggregatedRow, LedgerReport, SessionTurn } from './types.ts';
2
+ import type { AggregatedRow, LedgerReport, SessionTurn, SubagentTreeNode } from './types.ts';
3
3
 
4
4
  const MAIN_AGENT_LABEL = '(main)';
5
5
  const UNKNOWN_MODEL_LABEL = '(no-model)';
6
6
  const UNKNOWN_PROJECT_LABEL = '(no-project)';
7
+ const UNKNOWN_TOOL_LABEL = '(no-tool)';
7
8
 
8
- export type GroupKey = 'subagent' | 'model' | 'day' | 'project' | 'session';
9
+ export type GroupKey = 'subagent' | 'model' | 'day' | 'project' | 'session' | 'tool';
9
10
 
10
11
  // 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.
12
+ // "-Users-xiangjie-clawbot" → "~/clawbot"
16
13
  export function decodeProjectId(encoded: string): string {
17
14
  if (!encoded.startsWith('-')) return encoded;
18
- const parts = encoded.slice(1).split('-'); // drop leading '-'
19
- // Match common $HOME pattern: ['Users', '<user>', ...rest]
15
+ const parts = encoded.slice(1).split('-');
20
16
  if (parts.length >= 3 && parts[0] === 'Users') {
21
17
  return '~/' + parts.slice(2).join('-');
22
18
  }
23
- // Fallback: just convert all - to /
24
19
  return '/' + parts.join('/');
25
20
  }
26
21
 
@@ -59,83 +54,190 @@ function splitCache(turn: SessionTurn): { fiveMin: number; oneHour: number } {
59
54
  return { fiveMin: usage.cache_creation_input_tokens ?? 0, oneHour: 0 };
60
55
  }
61
56
 
62
- function keyOf(turn: SessionTurn, group: GroupKey): string {
63
- if (group === 'model') return turn.model ?? UNKNOWN_MODEL_LABEL;
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);
67
- return turn.subagentType ?? MAIN_AGENT_LABEL;
57
+ // Returns the keys this turn contributes to. Most groupings yield a single key,
58
+ // but 'tool' yields one key per tool_use block (so a turn that reads + greps
59
+ // counts toward both Read and Grep).
60
+ function keysOf(turn: SessionTurn, group: GroupKey): string[] {
61
+ if (group === 'model') return [turn.model ?? UNKNOWN_MODEL_LABEL];
62
+ if (group === 'day') return [turn.timestamp.slice(0, 10)];
63
+ if (group === 'project') return [decodeProjectId(turn.projectId ?? UNKNOWN_PROJECT_LABEL)];
64
+ if (group === 'session') return [turn.sessionId.slice(0, 8)];
65
+ if (group === 'tool') {
66
+ // Attribute the assistant turn's cost equally across every tool it invoked.
67
+ // If the turn invoked no tools, attribute to (no-tool).
68
+ return turn.toolUses && turn.toolUses.length > 0 ? turn.toolUses : [UNKNOWN_TOOL_LABEL];
69
+ }
70
+ return [turn.subagentType ?? MAIN_AGENT_LABEL];
68
71
  }
69
72
 
70
73
  function sortKey(group: GroupKey): (a: AggregatedRow, b: AggregatedRow) => number {
71
- // For day groupings users want chronological; otherwise descend by cost.
72
74
  if (group === 'day') return (a, b) => (a.subagent < b.subagent ? -1 : a.subagent > b.subagent ? 1 : 0);
73
75
  return (a, b) => b.cost.totalCost - a.cost.totalCost;
74
76
  }
75
77
 
78
+ // Streaming aggregator. add() per turn, finalize() at end.
79
+ // Suitable for fan-out: one parser stream → N aggregators in parallel.
80
+ export class Aggregator {
81
+ private rowsByKey = new Map<string, AggregatedRow>();
82
+ private sessionsByKey = new Map<string, Set<string>>();
83
+
84
+ constructor(
85
+ private readonly group: GroupKey,
86
+ private readonly from: Date,
87
+ private readonly to: Date,
88
+ ) {}
89
+
90
+ add(turn: SessionTurn): void {
91
+ if (turn.type !== 'assistant' || !turn.usage) return;
92
+
93
+ const cost = costFor(turn.usage, turn.model);
94
+ const { fiveMin, oneHour } = splitCache(turn);
95
+ const usage = turn.usage;
96
+ const keys = keysOf(turn, this.group);
97
+ // Distribute the turn's contributions evenly across all keys (relevant only for 'tool').
98
+ const share = 1 / keys.length;
99
+
100
+ for (const key of keys) {
101
+ let row = this.rowsByKey.get(key);
102
+ if (!row) {
103
+ row = emptyRow(key);
104
+ this.rowsByKey.set(key, row);
105
+ }
106
+ row.inputTokens += usage.input_tokens * share;
107
+ row.outputTokens += usage.output_tokens * share;
108
+ row.cacheCreation5mTokens += fiveMin * share;
109
+ row.cacheCreation1hTokens += oneHour * share;
110
+ row.cacheReadTokens += (usage.cache_read_input_tokens ?? 0) * share;
111
+ row.webSearchRequests += (usage.server_tool_use?.web_search_requests ?? 0) * share;
112
+ row.webFetchRequests += (usage.server_tool_use?.web_fetch_requests ?? 0) * share;
113
+ row.cost.inputCost += cost.inputCost * share;
114
+ row.cost.outputCost += cost.outputCost * share;
115
+ row.cost.cacheCreation5mCost += cost.cacheCreation5mCost * share;
116
+ row.cost.cacheCreation1hCost += cost.cacheCreation1hCost * share;
117
+ row.cost.cacheReadCost += cost.cacheReadCost * share;
118
+ row.cost.serverToolUseCost += cost.serverToolUseCost * share;
119
+ row.cost.totalCost += cost.totalCost * share;
120
+
121
+ let sessions = this.sessionsByKey.get(key);
122
+ if (!sessions) {
123
+ sessions = new Set<string>();
124
+ this.sessionsByKey.set(key, sessions);
125
+ }
126
+ sessions.add(turn.sessionId);
127
+ }
128
+ }
129
+
130
+ finalize(): LedgerReport {
131
+ for (const [key, row] of this.rowsByKey) {
132
+ row.sessionCount = this.sessionsByKey.get(key)?.size ?? 0;
133
+ }
134
+ const rows = [...this.rowsByKey.values()].sort(sortKey(this.group));
135
+ const total = rows.reduce<AggregatedRow>((acc, row) => {
136
+ acc.sessionCount += row.sessionCount;
137
+ acc.inputTokens += row.inputTokens;
138
+ acc.outputTokens += row.outputTokens;
139
+ acc.cacheCreation5mTokens += row.cacheCreation5mTokens;
140
+ acc.cacheCreation1hTokens += row.cacheCreation1hTokens;
141
+ acc.cacheReadTokens += row.cacheReadTokens;
142
+ acc.webSearchRequests += row.webSearchRequests;
143
+ acc.webFetchRequests += row.webFetchRequests;
144
+ acc.cost.inputCost += row.cost.inputCost;
145
+ acc.cost.outputCost += row.cost.outputCost;
146
+ acc.cost.cacheCreation5mCost += row.cost.cacheCreation5mCost;
147
+ acc.cost.cacheCreation1hCost += row.cost.cacheCreation1hCost;
148
+ acc.cost.cacheReadCost += row.cost.cacheReadCost;
149
+ acc.cost.serverToolUseCost += row.cost.serverToolUseCost;
150
+ acc.cost.totalCost += row.cost.totalCost;
151
+ return acc;
152
+ }, emptyRow('total'));
153
+ return { period: { from: this.from, to: this.to }, rows, total };
154
+ }
155
+ }
156
+
157
+ // Backwards-compat: the old async function-based API.
76
158
  export async function aggregate(
77
159
  turns: AsyncIterable<SessionTurn>,
78
160
  from: Date,
79
161
  to: Date,
80
162
  group: GroupKey = 'subagent',
81
163
  ): Promise<LedgerReport> {
82
- const rowsByKey = new Map<string, AggregatedRow>();
83
- const sessionsByKey = new Map<string, Set<string>>();
84
-
85
- for await (const turn of turns) {
86
- if (turn.type !== 'assistant' || !turn.usage) continue;
87
-
88
- const key = keyOf(turn, group);
89
- const row = rowsByKey.get(key) ?? emptyRow(key);
164
+ const agg = new Aggregator(group, from, to);
165
+ for await (const turn of turns) agg.add(turn);
166
+ return agg.finalize();
167
+ }
90
168
 
91
- const cost = costFor(turn.usage, turn.model);
92
- const { fiveMin, oneHour } = splitCache(turn);
169
+ // MOAT feature: subagent-graph cost attribution.
170
+ // Walks every parent session and recursively sums children to produce a tree
171
+ // rooted at the orchestrator. Helicone/Langfuse cannot do this — they observe
172
+ // API calls, not Claude Code's orchestration tree.
173
+ interface SessionAccum {
174
+ sessionId: string;
175
+ label: string;
176
+ selfCost: number;
177
+ turnCount: number;
178
+ parentSessionId?: string | undefined;
179
+ }
93
180
 
94
- row.inputTokens += turn.usage.input_tokens;
95
- row.outputTokens += turn.usage.output_tokens;
96
- row.cacheCreation5mTokens += fiveMin;
97
- row.cacheCreation1hTokens += oneHour;
98
- row.cacheReadTokens += turn.usage.cache_read_input_tokens ?? 0;
99
- row.webSearchRequests += turn.usage.server_tool_use?.web_search_requests ?? 0;
100
- row.webFetchRequests += turn.usage.server_tool_use?.web_fetch_requests ?? 0;
101
- row.cost.inputCost += cost.inputCost;
102
- row.cost.outputCost += cost.outputCost;
103
- row.cost.cacheCreation5mCost += cost.cacheCreation5mCost;
104
- row.cost.cacheCreation1hCost += cost.cacheCreation1hCost;
105
- row.cost.cacheReadCost += cost.cacheReadCost;
106
- row.cost.serverToolUseCost += cost.serverToolUseCost;
107
- row.cost.totalCost += cost.totalCost;
108
-
109
- const sessions = sessionsByKey.get(key) ?? new Set<string>();
110
- sessions.add(turn.sessionId);
111
- sessionsByKey.set(key, sessions);
112
-
113
- rowsByKey.set(key, row);
181
+ export class SubagentGraph {
182
+ private accums = new Map<string, SessionAccum>();
183
+
184
+ add(turn: SessionTurn): void {
185
+ if (turn.type !== 'assistant' || !turn.usage) return;
186
+ const cost = costFor(turn.usage, turn.model).totalCost;
187
+ let acc = this.accums.get(turn.sessionId);
188
+ if (!acc) {
189
+ acc = {
190
+ sessionId: turn.sessionId,
191
+ label: turn.subagentType ?? '(orchestrator)',
192
+ selfCost: 0,
193
+ turnCount: 0,
194
+ parentSessionId: turn.parentSessionId,
195
+ };
196
+ this.accums.set(turn.sessionId, acc);
197
+ }
198
+ acc.selfCost += cost;
199
+ acc.turnCount += 1;
200
+ // Children's parentSessionId may not appear until after the parent;
201
+ // keep the earliest non-undefined seen.
202
+ if (turn.parentSessionId && !acc.parentSessionId) {
203
+ acc.parentSessionId = turn.parentSessionId;
204
+ }
114
205
  }
115
206
 
116
- for (const [key, row] of rowsByKey) {
117
- row.sessionCount = sessionsByKey.get(key)?.size ?? 0;
207
+ // Build the trees. Each root has parentSessionId === undefined.
208
+ // Returns roots sorted by total cost (descending), top N only by default.
209
+ finalize(topN = 20): SubagentTreeNode[] {
210
+ const childrenOf = new Map<string, string[]>();
211
+ for (const acc of this.accums.values()) {
212
+ if (acc.parentSessionId) {
213
+ const list = childrenOf.get(acc.parentSessionId) ?? [];
214
+ list.push(acc.sessionId);
215
+ childrenOf.set(acc.parentSessionId, list);
216
+ }
217
+ }
218
+ const buildNode = (sessionId: string): SubagentTreeNode => {
219
+ const acc = this.accums.get(sessionId);
220
+ if (!acc) {
221
+ return { sessionId, label: '(missing)', selfCost: 0, totalCost: 0, children: [], turnCount: 0 };
222
+ }
223
+ const childIds = childrenOf.get(sessionId) ?? [];
224
+ const children = childIds.map((id) => buildNode(id));
225
+ children.sort((a, b) => b.totalCost - a.totalCost);
226
+ const childTotal = children.reduce((s, c) => s + c.totalCost, 0);
227
+ return {
228
+ sessionId,
229
+ label: acc.label,
230
+ selfCost: acc.selfCost,
231
+ totalCost: acc.selfCost + childTotal,
232
+ children,
233
+ turnCount: acc.turnCount,
234
+ };
235
+ };
236
+ const roots = [...this.accums.values()]
237
+ .filter((a) => !a.parentSessionId)
238
+ .map((a) => buildNode(a.sessionId))
239
+ .sort((a, b) => b.totalCost - a.totalCost)
240
+ .slice(0, topN);
241
+ return roots;
118
242
  }
119
-
120
- const rows = [...rowsByKey.values()].sort(sortKey(group));
121
- const total = rows.reduce<AggregatedRow>((acc, row) => {
122
- acc.sessionCount += row.sessionCount;
123
- acc.inputTokens += row.inputTokens;
124
- acc.outputTokens += row.outputTokens;
125
- acc.cacheCreation5mTokens += row.cacheCreation5mTokens;
126
- acc.cacheCreation1hTokens += row.cacheCreation1hTokens;
127
- acc.cacheReadTokens += row.cacheReadTokens;
128
- acc.webSearchRequests += row.webSearchRequests;
129
- acc.webFetchRequests += row.webFetchRequests;
130
- acc.cost.inputCost += row.cost.inputCost;
131
- acc.cost.outputCost += row.cost.outputCost;
132
- acc.cost.cacheCreation5mCost += row.cost.cacheCreation5mCost;
133
- acc.cost.cacheCreation1hCost += row.cost.cacheCreation1hCost;
134
- acc.cost.cacheReadCost += row.cost.cacheReadCost;
135
- acc.cost.serverToolUseCost += row.cost.serverToolUseCost;
136
- acc.cost.totalCost += row.cost.totalCost;
137
- return acc;
138
- }, emptyRow('total'));
139
-
140
- return { period: { from, to }, rows, total };
141
243
  }
package/src/explain.ts ADDED
@@ -0,0 +1,209 @@
1
+ // `agent-ledger explain <sessionId>` — root-cause why a session was expensive.
2
+ //
3
+ // Two-mode design:
4
+ // 1. Heuristic mode (always works, no API key): top-3 most expensive turns +
5
+ // tool-call breakdown + cache-reuse stats.
6
+ // 2. LLM mode (set ANTHROPIC_API_KEY): pipes the same evidence into
7
+ // Haiku 4.5 for a 4-bullet verdict ("prompt bloat", "Opus mis-selection",
8
+ // "no cache reuse", "tool output overflow"). Typically costs <$0.005/run.
9
+
10
+ import { homedir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { Glob } from 'bun';
13
+ import { parseFile } from './parser.ts';
14
+ import { costFor } from './pricing.ts';
15
+ import type { SessionTurn } from './types.ts';
16
+
17
+ interface TurnAnalysis {
18
+ turnIndex: number;
19
+ timestamp: string;
20
+ model: string | undefined;
21
+ cost: number;
22
+ inputTokens: number;
23
+ outputTokens: number;
24
+ cacheCreate5m: number;
25
+ cacheCreate1h: number;
26
+ cacheRead: number;
27
+ tools: string[];
28
+ }
29
+
30
+ interface SessionAnalysis {
31
+ sessionId: string;
32
+ subagentType: string | undefined;
33
+ totalCost: number;
34
+ turnCount: number;
35
+ models: Map<string, number>; // model → cost
36
+ tools: Map<string, number>; // tool → invocation count
37
+ cacheReuseRatio: number; // reads / writes
38
+ topTurns: TurnAnalysis[];
39
+ }
40
+
41
+ const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects');
42
+
43
+ async function findSessionFile(sessionPrefix: string): Promise<string | undefined> {
44
+ // Sessions are named `<uuid>.jsonl`. Match either full id or 8-char prefix.
45
+ const pattern = new Glob(`**/${sessionPrefix}*.jsonl`);
46
+ for await (const path of pattern.scan({ cwd: CLAUDE_PROJECTS_DIR, absolute: true })) {
47
+ return path;
48
+ }
49
+ return undefined;
50
+ }
51
+
52
+ export async function analyzeSession(sessionPrefix: string): Promise<SessionAnalysis | undefined> {
53
+ const path = await findSessionFile(sessionPrefix);
54
+ if (!path) return undefined;
55
+ const turns: SessionTurn[] = [];
56
+ for await (const t of parseFile(path)) turns.push(t);
57
+ return analyze(turns, sessionPrefix);
58
+ }
59
+
60
+ function analyze(turns: SessionTurn[], sessionPrefix: string): SessionAnalysis {
61
+ const analyses: TurnAnalysis[] = [];
62
+ const models = new Map<string, number>();
63
+ const tools = new Map<string, number>();
64
+ let totalCost = 0;
65
+ let totalCacheRead = 0;
66
+ let totalCacheWrite = 0;
67
+ let subagentType: string | undefined;
68
+
69
+ turns.forEach((turn, i) => {
70
+ if (turn.subagentType && !subagentType) subagentType = turn.subagentType;
71
+ if (turn.type !== 'assistant' || !turn.usage) return;
72
+ const c = costFor(turn.usage, turn.model).totalCost;
73
+ totalCost += c;
74
+ const m = turn.model ?? '(no-model)';
75
+ models.set(m, (models.get(m) ?? 0) + c);
76
+ for (const t of turn.toolUses ?? []) {
77
+ tools.set(t, (tools.get(t) ?? 0) + 1);
78
+ }
79
+ const fiveMin = turn.usage.cache_creation?.ephemeral_5m_input_tokens ?? turn.usage.cache_creation_input_tokens ?? 0;
80
+ const oneH = turn.usage.cache_creation?.ephemeral_1h_input_tokens ?? 0;
81
+ totalCacheRead += turn.usage.cache_read_input_tokens ?? 0;
82
+ totalCacheWrite += fiveMin + oneH;
83
+ analyses.push({
84
+ turnIndex: i,
85
+ timestamp: turn.timestamp,
86
+ model: turn.model,
87
+ cost: c,
88
+ inputTokens: turn.usage.input_tokens,
89
+ outputTokens: turn.usage.output_tokens,
90
+ cacheCreate5m: fiveMin,
91
+ cacheCreate1h: oneH,
92
+ cacheRead: turn.usage.cache_read_input_tokens ?? 0,
93
+ tools: turn.toolUses ?? [],
94
+ });
95
+ });
96
+
97
+ analyses.sort((a, b) => b.cost - a.cost);
98
+ const cacheReuseRatio = totalCacheWrite > 0 ? totalCacheRead / totalCacheWrite : 0;
99
+
100
+ return {
101
+ sessionId: sessionPrefix,
102
+ subagentType,
103
+ totalCost,
104
+ turnCount: analyses.length,
105
+ models,
106
+ tools,
107
+ cacheReuseRatio,
108
+ topTurns: analyses.slice(0, 3),
109
+ };
110
+ }
111
+
112
+ const fmtUsd = (n: number): string => '$' + n.toFixed(n < 0.01 ? 4 : 2);
113
+ const fmtToken = (n: number): string => {
114
+ if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
115
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
116
+ return String(Math.round(n));
117
+ };
118
+
119
+ export function formatHeuristic(s: SessionAnalysis): string {
120
+ const lines: string[] = [];
121
+ lines.push(`# Session ${s.sessionId} — heuristic root-cause`);
122
+ lines.push('');
123
+ lines.push(`- Subagent: \`${s.subagentType ?? '(orchestrator)'}\``);
124
+ lines.push(`- Total cost: **${fmtUsd(s.totalCost)}** across ${s.turnCount} assistant turns`);
125
+ lines.push(`- Models used:`);
126
+ [...s.models.entries()]
127
+ .sort((a, b) => b[1] - a[1])
128
+ .forEach(([m, c]) => lines.push(` - \`${m}\` — ${fmtUsd(c)}`));
129
+ lines.push('');
130
+ lines.push(`## Top tools by invocation count`);
131
+ const topTools = [...s.tools.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
132
+ if (topTools.length === 0) {
133
+ lines.push(' _(no tool_use blocks found in this session)_');
134
+ } else {
135
+ for (const [t, n] of topTools) lines.push(` - \`${t}\` × ${n}`);
136
+ }
137
+ lines.push('');
138
+ lines.push(`## Top 3 most expensive turns`);
139
+ for (const t of s.topTurns) {
140
+ lines.push(
141
+ `- **Turn ${t.turnIndex}** at ${t.timestamp} — ${fmtUsd(t.cost)}\n - model: \`${t.model ?? '(no-model)'}\`\n - input: ${fmtToken(t.inputTokens)}, output: ${fmtToken(t.outputTokens)}, cache 5m: ${fmtToken(t.cacheCreate5m)}, cache 1h: ${fmtToken(t.cacheCreate1h)}, cache read: ${fmtToken(t.cacheRead)}\n - tools: ${t.tools.join(', ') || '(none)'}`,
142
+ );
143
+ }
144
+ lines.push('');
145
+ lines.push(
146
+ `## Cache reuse — ${s.cacheReuseRatio.toFixed(1)}× (reads/writes)`,
147
+ );
148
+ if (s.cacheReuseRatio < 5) {
149
+ lines.push(
150
+ ` ⚠️ **Below 5×.** This session creates cache faster than it reuses. Consider: shorter system prompt, fewer dynamic context injections per turn.`,
151
+ );
152
+ } else if (s.cacheReuseRatio < 20) {
153
+ lines.push(` ✅ Acceptable but could be higher. Aim for >20× on long sessions.`);
154
+ } else {
155
+ lines.push(` ✅ Good — cache is doing its job.`);
156
+ }
157
+
158
+ return lines.join('\n');
159
+ }
160
+
161
+ interface AnthropicMsgRes {
162
+ content: Array<{ type: string; text?: string }>;
163
+ usage?: { input_tokens: number; output_tokens: number };
164
+ }
165
+
166
+ export async function llmExplain(s: SessionAnalysis): Promise<string | undefined> {
167
+ const apiKey = process.env.ANTHROPIC_API_KEY;
168
+ if (!apiKey) return undefined;
169
+ // The session identifier is useful locally but adds no analytical value to
170
+ // the API request, so keep it off the network.
171
+ const evidence = formatHeuristic({ ...s, sessionId: '[redacted]' });
172
+ const prompt = `You are a Claude Code cost-optimization expert. A user's session burned ${fmtUsd(s.totalCost)} across ${s.turnCount} turns. Below is the per-turn breakdown:
173
+
174
+ ${evidence}
175
+
176
+ In ≤4 bullets, identify the SPECIFIC root cause (not generic advice) and ONE concrete action the user can take to cut this session's cost by 50%+. Be terse. Cite turn numbers from the breakdown.`;
177
+ try {
178
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
179
+ method: 'POST',
180
+ headers: {
181
+ 'Content-Type': 'application/json',
182
+ 'x-api-key': apiKey,
183
+ 'anthropic-version': '2023-06-01',
184
+ },
185
+ body: JSON.stringify({
186
+ model: 'claude-haiku-4-5-20251001',
187
+ max_tokens: 600,
188
+ messages: [{ role: 'user', content: prompt }],
189
+ }),
190
+ });
191
+ if (!res.ok) {
192
+ console.error(`agent-ledger explain: API ${res.status} — falling back to heuristic`);
193
+ return undefined;
194
+ }
195
+ const data = (await res.json()) as AnthropicMsgRes;
196
+ const text = data.content.find((b) => b.type === 'text')?.text;
197
+ if (!text) return undefined;
198
+ const cost = data.usage
199
+ ? costFor(
200
+ { input_tokens: data.usage.input_tokens, output_tokens: data.usage.output_tokens },
201
+ 'claude-haiku-4-5-20251001',
202
+ ).totalCost
203
+ : 0;
204
+ return `## LLM root-cause analysis (Haiku 4.5, ${fmtUsd(cost)})\n\n${text.trim()}`;
205
+ } catch (err) {
206
+ console.error('agent-ledger explain: LLM call failed —', err);
207
+ return undefined;
208
+ }
209
+ }