claude-agent-ledger 0.4.1 → 0.7.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 +95 -0
- package/README.md +17 -1
- package/bin/agent-ledger.ts +194 -57
- package/bun.lock +5 -0
- package/package.json +2 -1
- package/src/aggregator.ts +173 -71
- package/src/explain.ts +207 -0
- package/src/parser.ts +110 -32
- package/src/tree.ts +107 -0
- package/src/types.ts +26 -8
- package/test/property.test.ts +188 -0
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
|
-
//
|
|
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('-');
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (group === '
|
|
67
|
-
return turn.
|
|
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
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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,207 @@
|
|
|
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"). Costs ~$0.001/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
|
+
const evidence = formatHeuristic(s);
|
|
170
|
+
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:
|
|
171
|
+
|
|
172
|
+
${evidence}
|
|
173
|
+
|
|
174
|
+
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.`;
|
|
175
|
+
try {
|
|
176
|
+
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
177
|
+
method: 'POST',
|
|
178
|
+
headers: {
|
|
179
|
+
'Content-Type': 'application/json',
|
|
180
|
+
'x-api-key': apiKey,
|
|
181
|
+
'anthropic-version': '2023-06-01',
|
|
182
|
+
},
|
|
183
|
+
body: JSON.stringify({
|
|
184
|
+
model: 'claude-haiku-4-5-20251001',
|
|
185
|
+
max_tokens: 600,
|
|
186
|
+
messages: [{ role: 'user', content: prompt }],
|
|
187
|
+
}),
|
|
188
|
+
});
|
|
189
|
+
if (!res.ok) {
|
|
190
|
+
console.error(`agent-ledger explain: API ${res.status} — falling back to heuristic`);
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
const data = (await res.json()) as AnthropicMsgRes;
|
|
194
|
+
const text = data.content.find((b) => b.type === 'text')?.text;
|
|
195
|
+
if (!text) return undefined;
|
|
196
|
+
const cost = data.usage
|
|
197
|
+
? costFor(
|
|
198
|
+
{ input_tokens: data.usage.input_tokens, output_tokens: data.usage.output_tokens },
|
|
199
|
+
'claude-haiku-4-5-20251001',
|
|
200
|
+
).totalCost
|
|
201
|
+
: 0;
|
|
202
|
+
return `## LLM root-cause analysis (Haiku 4.5, ${fmtUsd(cost)})\n\n${text.trim()}`;
|
|
203
|
+
} catch (err) {
|
|
204
|
+
console.error('agent-ledger explain: LLM call failed —', err);
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
}
|