@relayburn/sdk 1.10.0 → 2.1.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 +30 -39
- package/README.md +36 -50
- package/package.json +46 -17
- package/src/binding.cjs +91 -0
- package/src/binding.d.ts +21 -0
- package/src/index.cjs +68 -0
- package/{index.d.ts → src/index.d.ts} +138 -63
- package/src/index.js +144 -0
- package/index.js +0 -836
package/index.js
DELETED
|
@@ -1,836 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
buildArchive,
|
|
3
|
-
queryAll,
|
|
4
|
-
queryAllFromArchive,
|
|
5
|
-
queryTurnsFromArchive,
|
|
6
|
-
queryUserTurns,
|
|
7
|
-
queryToolResultEvents,
|
|
8
|
-
} from '@relayburn/ledger';
|
|
9
|
-
import {
|
|
10
|
-
aggregateByBash,
|
|
11
|
-
aggregateByBashVerb,
|
|
12
|
-
aggregateByFile,
|
|
13
|
-
aggregateBySubagent,
|
|
14
|
-
attributeOverhead,
|
|
15
|
-
buildCompareTable,
|
|
16
|
-
buildGhostSurfaceInputs,
|
|
17
|
-
buildTrimRecommendations,
|
|
18
|
-
compareFromArchive,
|
|
19
|
-
costForTurn,
|
|
20
|
-
DEFAULT_MIN_SAMPLE,
|
|
21
|
-
detectGhostSurface,
|
|
22
|
-
detectPatterns,
|
|
23
|
-
detectToolCallPatterns,
|
|
24
|
-
detectToolOutputBloat,
|
|
25
|
-
filterTurnsByProvider,
|
|
26
|
-
findingsFromPatterns,
|
|
27
|
-
findOverheadFiles,
|
|
28
|
-
ghostSurfaceToFinding,
|
|
29
|
-
hasMinimumFidelity,
|
|
30
|
-
loadClaudeSettings,
|
|
31
|
-
loadOverheadFile,
|
|
32
|
-
loadPricing,
|
|
33
|
-
projectClaudeSettingsPath,
|
|
34
|
-
renderUnifiedDiffForRecommendation,
|
|
35
|
-
summarizeFidelity,
|
|
36
|
-
sumCosts,
|
|
37
|
-
attributeHotspots,
|
|
38
|
-
toolCallPatternToFinding,
|
|
39
|
-
toolOutputBloatToFinding,
|
|
40
|
-
userClaudeSettingsPath,
|
|
41
|
-
} from '@relayburn/analyze';
|
|
42
|
-
import { ingestAll } from '@relayburn/ingest';
|
|
43
|
-
import { parseBashCommand, resolveProject } from '@relayburn/reader';
|
|
44
|
-
import { readFile } from 'node:fs/promises';
|
|
45
|
-
import * as path from 'node:path';
|
|
46
|
-
|
|
47
|
-
function withHome(home, fn) {
|
|
48
|
-
const prev = process.env.RELAYBURN_HOME;
|
|
49
|
-
if (home) process.env.RELAYBURN_HOME = home;
|
|
50
|
-
return Promise.resolve(fn()).finally(() => {
|
|
51
|
-
if (home) {
|
|
52
|
-
if (prev === undefined) delete process.env.RELAYBURN_HOME;
|
|
53
|
-
else process.env.RELAYBURN_HOME = prev;
|
|
54
|
-
}
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Bring the SQLite archive current and query against it, falling back to a
|
|
59
|
-
// full ledger walk if the archive can't be built or read. Mirrors the strategy
|
|
60
|
-
// the CLI's loadTurns() uses so SDK consumers (and the MCP server, which now
|
|
61
|
-
// calls through here) get the same hot-path performance without re-implementing
|
|
62
|
-
// the fallback logic in every caller. `onLog` lets callers surface the
|
|
63
|
-
// fallback reason; defaults to a no-op so library use stays quiet.
|
|
64
|
-
async function loadTurnsViaArchive(q, onLog) {
|
|
65
|
-
try {
|
|
66
|
-
await buildArchive();
|
|
67
|
-
return await queryAllFromArchive(q);
|
|
68
|
-
} catch (err) {
|
|
69
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
70
|
-
onLog?.(`archive query failed, falling back to ledger walk: ${msg}`);
|
|
71
|
-
return queryAll(q);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async function loadSessionTurnsViaArchive(sessionId, onLog) {
|
|
76
|
-
try {
|
|
77
|
-
await buildArchive();
|
|
78
|
-
return await queryTurnsFromArchive({ sessionId });
|
|
79
|
-
} catch (err) {
|
|
80
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
81
|
-
onLog?.(`archive query failed, falling back to ledger walk: ${msg}`);
|
|
82
|
-
return queryAll({ sessionId });
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Accept either a CLI-style relative range (`24h`, `7d`, `4w`, `2m`) or an
|
|
87
|
-
// ISO timestamp and return an ISO string the ledger query can compare. The
|
|
88
|
-
// ledger filter does lexical string comparison on `turn.ts`, so passing a raw
|
|
89
|
-
// `7d` would silently filter every turn out (since `'7'` > `'2'` lexically).
|
|
90
|
-
// Lifted from `packages/cli/src/format.ts` so direct SDK callers (and future
|
|
91
|
-
// MCP tools) get the same forgiving input shape the CLI users see, without
|
|
92
|
-
// the silent-drop trap.
|
|
93
|
-
function normalizeSince(since) {
|
|
94
|
-
if (since === undefined) return undefined;
|
|
95
|
-
if (typeof since !== 'string' || since.length === 0) return undefined;
|
|
96
|
-
const m = /^(\d+)([hdwm])$/.exec(since);
|
|
97
|
-
if (!m) {
|
|
98
|
-
const d = new Date(since);
|
|
99
|
-
if (Number.isNaN(d.getTime())) {
|
|
100
|
-
throw new Error(`invalid since: ${since} (expected ISO timestamp or relative range like 7d)`);
|
|
101
|
-
}
|
|
102
|
-
return d.toISOString();
|
|
103
|
-
}
|
|
104
|
-
const n = parseInt(m[1], 10);
|
|
105
|
-
const unit = m[2];
|
|
106
|
-
const ms =
|
|
107
|
-
unit === 'h'
|
|
108
|
-
? n * 3600_000
|
|
109
|
-
: unit === 'd'
|
|
110
|
-
? n * 86400_000
|
|
111
|
-
: unit === 'w'
|
|
112
|
-
? n * 7 * 86400_000
|
|
113
|
-
: /* m */ n * 30 * 86400_000;
|
|
114
|
-
return new Date(Date.now() - ms).toISOString();
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export class Ledger {
|
|
118
|
-
static async open(opts = {}) {
|
|
119
|
-
return new Ledger(opts.home);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
constructor(home) {
|
|
123
|
-
this.home = home;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export async function ingest(opts = {}) {
|
|
128
|
-
return withHome(opts.ledgerHome, async () => ingestAll());
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export async function summary(opts = {}) {
|
|
132
|
-
return withHome(opts.ledgerHome, async () => {
|
|
133
|
-
const q = { sessionId: opts.session, project: opts.project, since: normalizeSince(opts.since) };
|
|
134
|
-
const turns = await loadTurnsViaArchive(q, opts.onLog);
|
|
135
|
-
const pricing = await loadPricing();
|
|
136
|
-
const byTool = new Map();
|
|
137
|
-
const byModel = new Map();
|
|
138
|
-
let totalTokens = 0;
|
|
139
|
-
let totalCost = 0;
|
|
140
|
-
|
|
141
|
-
for (const t of turns) {
|
|
142
|
-
const c = costForTurn(t, pricing)?.total ?? 0;
|
|
143
|
-
const usage =
|
|
144
|
-
t.usage.input +
|
|
145
|
-
t.usage.output +
|
|
146
|
-
t.usage.reasoning +
|
|
147
|
-
t.usage.cacheRead +
|
|
148
|
-
t.usage.cacheCreate5m +
|
|
149
|
-
t.usage.cacheCreate1h;
|
|
150
|
-
totalTokens += usage;
|
|
151
|
-
totalCost += c;
|
|
152
|
-
|
|
153
|
-
const model = byModel.get(t.model) ?? { model: t.model, tokens: 0, cost: 0 };
|
|
154
|
-
model.tokens += usage;
|
|
155
|
-
model.cost += c;
|
|
156
|
-
byModel.set(t.model, model);
|
|
157
|
-
|
|
158
|
-
for (const call of t.toolCalls) {
|
|
159
|
-
const tool = byTool.get(call.name) ?? { tool: call.name, tokens: 0, cost: 0, count: 0 };
|
|
160
|
-
tool.tokens += usage;
|
|
161
|
-
tool.cost += c;
|
|
162
|
-
tool.count += 1;
|
|
163
|
-
byTool.set(call.name, tool);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
return {
|
|
168
|
-
totalTokens,
|
|
169
|
-
totalCost,
|
|
170
|
-
turnCount: turns.length,
|
|
171
|
-
byTool: [...byTool.values()],
|
|
172
|
-
byModel: [...byModel.values()],
|
|
173
|
-
};
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Compact session-scoped cost summary. Same numbers as `summary({ session })`
|
|
178
|
-
// but shaped for callers that just want the headline: totalUSD, totalTokens,
|
|
179
|
-
// turnCount, distinct models. The MCP `burn__sessionCost` tool wraps this
|
|
180
|
-
// directly so the cost shape lives in one place. `note` is set when the
|
|
181
|
-
// session is empty or when no session id was provided so MCP clients can
|
|
182
|
-
// surface a human-readable reason without re-deriving it.
|
|
183
|
-
export async function sessionCost(opts = {}) {
|
|
184
|
-
return withHome(opts.ledgerHome, async () => {
|
|
185
|
-
const sessionId = opts.session;
|
|
186
|
-
if (!sessionId) {
|
|
187
|
-
return {
|
|
188
|
-
sessionId: null,
|
|
189
|
-
totalUSD: 0,
|
|
190
|
-
totalTokens: 0,
|
|
191
|
-
turnCount: 0,
|
|
192
|
-
models: [],
|
|
193
|
-
note: 'no session id provided',
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
const turns = await loadSessionTurnsViaArchive(sessionId, opts.onLog);
|
|
197
|
-
if (turns.length === 0) {
|
|
198
|
-
return {
|
|
199
|
-
sessionId,
|
|
200
|
-
totalUSD: 0,
|
|
201
|
-
totalTokens: 0,
|
|
202
|
-
turnCount: 0,
|
|
203
|
-
models: [],
|
|
204
|
-
note: 'no turns recorded for this session yet',
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
const pricing = await loadPricing();
|
|
208
|
-
const models = new Set();
|
|
209
|
-
let totalTokens = 0;
|
|
210
|
-
const costs = [];
|
|
211
|
-
for (const t of turns) {
|
|
212
|
-
models.add(t.model);
|
|
213
|
-
const u = t.usage;
|
|
214
|
-
totalTokens +=
|
|
215
|
-
(u.input ?? 0) +
|
|
216
|
-
(u.output ?? 0) +
|
|
217
|
-
(u.reasoning ?? 0) +
|
|
218
|
-
(u.cacheRead ?? 0) +
|
|
219
|
-
(u.cacheCreate5m ?? 0) +
|
|
220
|
-
(u.cacheCreate1h ?? 0);
|
|
221
|
-
const c = costForTurn(t, pricing);
|
|
222
|
-
if (c) costs.push(c);
|
|
223
|
-
}
|
|
224
|
-
const total = sumCosts(costs);
|
|
225
|
-
return {
|
|
226
|
-
sessionId,
|
|
227
|
-
totalUSD: Math.round(total.total * 1_000_000) / 1_000_000,
|
|
228
|
-
totalTokens,
|
|
229
|
-
turnCount: turns.length,
|
|
230
|
-
models: [...models].sort(),
|
|
231
|
-
};
|
|
232
|
-
});
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// Coverage flags `attributeHotspots` and the matching aggregators need.
|
|
236
|
-
// Records without `fidelity` (older ledger writers, foreign sources) are
|
|
237
|
-
// treated as best-effort full and pass the gate. Mirrors
|
|
238
|
-
// `ATTRIBUTION_REQUIRED` + `turnPassesCoverage` in the CLI.
|
|
239
|
-
const HOTSPOTS_ATTRIBUTION_REQUIRED = ['hasToolCalls', 'hasToolResultEvents'];
|
|
240
|
-
|
|
241
|
-
function turnPassesCoverage(turn, required) {
|
|
242
|
-
const f = turn.fidelity;
|
|
243
|
-
if (!f) return true;
|
|
244
|
-
for (const key of required) {
|
|
245
|
-
if (!f.coverage[key]) return false;
|
|
246
|
-
}
|
|
247
|
-
return true;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const VALID_HOTSPOTS_GROUP_BY = ['attribution', 'bash', 'bash-verb', 'file', 'subagent'];
|
|
251
|
-
|
|
252
|
-
// Expanded hotspots(): returns a discriminated union covering every shape the
|
|
253
|
-
// CLI's `burn hotspots --json` (and a few narrower programmatic cuts) need.
|
|
254
|
-
//
|
|
255
|
-
// { kind: 'attribution' } — full per-axis aggregations
|
|
256
|
-
// { kind: 'bash' | 'bash-verb' | 'file' |
|
|
257
|
-
// 'subagent' } — narrow to one aggregation
|
|
258
|
-
// { kind: 'findings' } — pattern findings (when
|
|
259
|
-
// `patterns` is set)
|
|
260
|
-
//
|
|
261
|
-
// `groupBy` and `patterns` are mutually exclusive: passing `patterns` always
|
|
262
|
-
// returns the findings shape and `groupBy` is ignored.
|
|
263
|
-
//
|
|
264
|
-
// Pattern detectors that need extra data (Claude settings, tool-result events,
|
|
265
|
-
// on-disk ghost surface) are loaded lazily based on the requested patterns,
|
|
266
|
-
// the same way the CLI does — so passing only `['retry-loop']` won't pay for
|
|
267
|
-
// a settings.json read.
|
|
268
|
-
export async function hotspots(opts = {}) {
|
|
269
|
-
return withHome(opts.ledgerHome, async () => {
|
|
270
|
-
const usingPatterns = opts.patterns && opts.patterns.length > 0;
|
|
271
|
-
|
|
272
|
-
// Only validate `groupBy` when it actually steers the result. Per the
|
|
273
|
-
// documented mutual-exclusivity, passing `patterns` always returns
|
|
274
|
-
// findings and `groupBy` is ignored — including when its value is
|
|
275
|
-
// unknown — so callers that pass through a stale `groupBy` alongside
|
|
276
|
-
// `patterns` keep working.
|
|
277
|
-
if (
|
|
278
|
-
!usingPatterns &&
|
|
279
|
-
opts.groupBy !== undefined &&
|
|
280
|
-
!VALID_HOTSPOTS_GROUP_BY.includes(opts.groupBy)
|
|
281
|
-
) {
|
|
282
|
-
throw new Error(
|
|
283
|
-
`invalid hotspots groupBy: ${JSON.stringify(opts.groupBy)} ` +
|
|
284
|
-
`(expected one of: ${VALID_HOTSPOTS_GROUP_BY.join(', ')})`,
|
|
285
|
-
);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
const q = q_(opts);
|
|
289
|
-
const turns = await loadTurnsViaArchive(q, opts.onLog);
|
|
290
|
-
const pricing = await loadPricing();
|
|
291
|
-
|
|
292
|
-
if (usingPatterns) {
|
|
293
|
-
return runHotspotsFindings(turns, pricing, opts, q);
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
return runHotspotsAttribution(turns, pricing, opts, q);
|
|
297
|
-
});
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
async function runHotspotsAttribution(turns, pricing, opts, q = {}) {
|
|
301
|
-
const eligible = [];
|
|
302
|
-
const excluded = [];
|
|
303
|
-
for (const t of turns) {
|
|
304
|
-
if (turnPassesCoverage(t, HOTSPOTS_ATTRIBUTION_REQUIRED)) eligible.push(t);
|
|
305
|
-
else excluded.push(t);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
const fidelitySummary = summarizeFidelity(turns);
|
|
309
|
-
|
|
310
|
-
// Refusal: nothing to attribute. Mirror the CLI's refused-shape so callers
|
|
311
|
-
// can branch on `refused` without re-deriving the reason.
|
|
312
|
-
if (turns.length > 0 && eligible.length === 0) {
|
|
313
|
-
const refusalReason =
|
|
314
|
-
`${turns.length}/${turns.length} turns lack tool-call/tool-result coverage required for hotspots attribution`;
|
|
315
|
-
const groupBy = opts.groupBy ?? 'attribution';
|
|
316
|
-
if (groupBy !== 'attribution') {
|
|
317
|
-
return { kind: groupBy, rows: [], refused: true, refusalReason };
|
|
318
|
-
}
|
|
319
|
-
return {
|
|
320
|
-
kind: 'attribution',
|
|
321
|
-
turnsAnalyzed: 0,
|
|
322
|
-
grandTotal: 0,
|
|
323
|
-
attributedTotal: 0,
|
|
324
|
-
unattributedTotal: 0,
|
|
325
|
-
attributionDegraded: false,
|
|
326
|
-
sessions: [],
|
|
327
|
-
files: [],
|
|
328
|
-
bashVerbs: [],
|
|
329
|
-
bash: [],
|
|
330
|
-
subagents: [],
|
|
331
|
-
fidelity: {
|
|
332
|
-
analyzed: 0,
|
|
333
|
-
excluded: turns.length,
|
|
334
|
-
summary: fidelitySummary,
|
|
335
|
-
refused: true,
|
|
336
|
-
},
|
|
337
|
-
refused: true,
|
|
338
|
-
refusalReason,
|
|
339
|
-
};
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
const sessionIds = new Set(eligible.map((t) => t.sessionId));
|
|
343
|
-
// Reuse the precomputed `q` from the caller — `normalizeSince()` calls
|
|
344
|
-
// `Date.now()` for relative ranges, so re-deriving here would cut the
|
|
345
|
-
// user-turn window at a slightly later boundary than the turn slice and
|
|
346
|
-
// drop borderline records.
|
|
347
|
-
const userTurnsBySession = await bulkUserTurnsBySession(sessionIds, q);
|
|
348
|
-
const result = attributeHotspots(eligible, { pricing, userTurnsBySession });
|
|
349
|
-
|
|
350
|
-
const groupBy = opts.groupBy ?? 'attribution';
|
|
351
|
-
if (groupBy === 'bash') {
|
|
352
|
-
return { kind: 'bash', rows: aggregateByBash(result.attributions) };
|
|
353
|
-
}
|
|
354
|
-
if (groupBy === 'bash-verb') {
|
|
355
|
-
return {
|
|
356
|
-
kind: 'bash-verb',
|
|
357
|
-
rows: aggregateByBashVerb(result.attributions, parseBashCommand),
|
|
358
|
-
};
|
|
359
|
-
}
|
|
360
|
-
if (groupBy === 'file') {
|
|
361
|
-
return { kind: 'file', rows: aggregateByFile(result.attributions) };
|
|
362
|
-
}
|
|
363
|
-
if (groupBy === 'subagent') {
|
|
364
|
-
return { kind: 'subagent', rows: aggregateBySubagent(result.attributions) };
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
const files = aggregateByFile(result.attributions);
|
|
368
|
-
const bashVerbs = aggregateByBashVerb(result.attributions, parseBashCommand);
|
|
369
|
-
const bash = aggregateByBash(result.attributions);
|
|
370
|
-
const subagents = aggregateBySubagent(result.attributions);
|
|
371
|
-
const evenSplit = result.sessionTotals.filter(
|
|
372
|
-
(s) => s.attributionMethod === 'even-split',
|
|
373
|
-
).length;
|
|
374
|
-
const attributionDegraded =
|
|
375
|
-
result.sessionTotals.length > 0 &&
|
|
376
|
-
evenSplit / result.sessionTotals.length >= 0.5;
|
|
377
|
-
|
|
378
|
-
return {
|
|
379
|
-
kind: 'attribution',
|
|
380
|
-
turnsAnalyzed: eligible.length,
|
|
381
|
-
grandTotal: result.grandTotal,
|
|
382
|
-
attributedTotal: result.attributedTotal,
|
|
383
|
-
unattributedTotal: result.unattributedTotal,
|
|
384
|
-
attributionDegraded,
|
|
385
|
-
sessions: result.sessionTotals,
|
|
386
|
-
files,
|
|
387
|
-
bashVerbs,
|
|
388
|
-
bash,
|
|
389
|
-
subagents,
|
|
390
|
-
fidelity: {
|
|
391
|
-
analyzed: eligible.length,
|
|
392
|
-
excluded: excluded.length,
|
|
393
|
-
summary: fidelitySummary,
|
|
394
|
-
refused: false,
|
|
395
|
-
},
|
|
396
|
-
};
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
async function runHotspotsFindings(turns, pricing, opts, q = {}) {
|
|
400
|
-
const wanted = new Set(opts.patterns);
|
|
401
|
-
const findings = [];
|
|
402
|
-
// Forward `since` (and `sessionId`) so the user-turn + tool-result-event
|
|
403
|
-
// streams stay inside the same matched window the turn slice uses.
|
|
404
|
-
// Without this, detectors that read user-turn or tool-result-event state
|
|
405
|
-
// (system-prompt-tax, tool-output-bloat, retry/failure/cancellation graph
|
|
406
|
-
// walks) would mix older pre-window events into windowed analysis and
|
|
407
|
-
// surface false findings on long-lived sessions.
|
|
408
|
-
const sideQuery = {};
|
|
409
|
-
if (q.sessionId !== undefined) sideQuery.sessionId = q.sessionId;
|
|
410
|
-
if (q.since !== undefined) sideQuery.since = q.since;
|
|
411
|
-
const userTurns = await queryUserTurns(sideQuery);
|
|
412
|
-
const userTurnsBySession = bucketBySession(userTurns);
|
|
413
|
-
|
|
414
|
-
// Core patterns (retries, failures, edit-heavy, etc.) flow through
|
|
415
|
-
// detectPatterns + findingsFromPatterns; non-matching kinds are filtered.
|
|
416
|
-
const detected = detectPatterns(turns, { pricing, userTurnsBySession });
|
|
417
|
-
for (const f of findingsFromPatterns(detected)) {
|
|
418
|
-
if (wanted.has(f.kind)) findings.push(f);
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
// Side-channel detectors live outside detectPatterns. Each one reads its
|
|
422
|
-
// own slice of state, so we run them lazily based on `wanted`.
|
|
423
|
-
|
|
424
|
-
if (wanted.has('tool-output-bloat')) {
|
|
425
|
-
const settings = [];
|
|
426
|
-
const userLoaded = await loadClaudeSettings(userClaudeSettingsPath());
|
|
427
|
-
if (userLoaded) settings.push(userLoaded);
|
|
428
|
-
const projectLoaded = await loadClaudeSettings(projectClaudeSettingsPath());
|
|
429
|
-
if (projectLoaded) settings.push(projectLoaded);
|
|
430
|
-
const toolResultEvents = await queryToolResultEvents(sideQuery);
|
|
431
|
-
const bloats = detectToolOutputBloat({
|
|
432
|
-
settings,
|
|
433
|
-
toolResultEvents,
|
|
434
|
-
userTurns,
|
|
435
|
-
turns,
|
|
436
|
-
pricing,
|
|
437
|
-
});
|
|
438
|
-
for (const b of bloats) findings.push(toolOutputBloatToFinding(b));
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
if (wanted.has('ghost-surface')) {
|
|
442
|
-
const ghostInputs = await buildGhostSurfaceInputs(turns, pricing);
|
|
443
|
-
const ghosts = await detectGhostSurface(ghostInputs);
|
|
444
|
-
for (const g of ghosts) findings.push(ghostSurfaceToFinding(g));
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
if (wanted.has('tool-call-pattern')) {
|
|
448
|
-
const patterns = detectToolCallPatterns(turns, { pricing });
|
|
449
|
-
for (const p of patterns) findings.push(toolCallPatternToFinding(p));
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
return {
|
|
453
|
-
kind: 'findings',
|
|
454
|
-
findings,
|
|
455
|
-
summary: summarizeFidelity(turns),
|
|
456
|
-
};
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
// Build the ledger Query from SDK opts. Used by the bulk user-turn loader so
|
|
460
|
-
// it narrows by `since`/`source` during streaming rather than buffering the
|
|
461
|
-
// entire historical ledger. Mirrors the CLI's same trick.
|
|
462
|
-
function q_(opts) {
|
|
463
|
-
const q = {};
|
|
464
|
-
if (opts.session) q.sessionId = opts.session;
|
|
465
|
-
if (opts.project) q.project = opts.project;
|
|
466
|
-
const since = normalizeSince(opts.since);
|
|
467
|
-
if (since) q.since = since;
|
|
468
|
-
return q;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
// One ledger pass + in-memory bucket. Mirrors the CLI's `bulkUserTurnsBySession`:
|
|
472
|
-
// the per-session form `queryUserTurns({sessionId})` re-streams the entire
|
|
473
|
-
// ledger.jsonl on every call, so we issue a single bulk pass here and bucket
|
|
474
|
-
// by sessionId. `since`/`source` are forwarded so the streaming filter narrows
|
|
475
|
-
// the in-memory buffer to the same window the eligible turns live in.
|
|
476
|
-
async function bulkUserTurnsBySession(sessionIds, q = {}) {
|
|
477
|
-
const out = new Map();
|
|
478
|
-
if (sessionIds.size === 0) return out;
|
|
479
|
-
const filter = {};
|
|
480
|
-
if (q.since !== undefined) filter.since = q.since;
|
|
481
|
-
if (q.source !== undefined) filter.source = q.source;
|
|
482
|
-
const all = await queryUserTurns(filter);
|
|
483
|
-
for (const ut of all) {
|
|
484
|
-
if (!sessionIds.has(ut.sessionId)) continue;
|
|
485
|
-
const list = out.get(ut.sessionId);
|
|
486
|
-
if (list) list.push(ut);
|
|
487
|
-
else out.set(ut.sessionId, [ut]);
|
|
488
|
-
}
|
|
489
|
-
return out;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
function bucketBySession(userTurns) {
|
|
493
|
-
const out = new Map();
|
|
494
|
-
for (const ut of userTurns) {
|
|
495
|
-
const list = out.get(ut.sessionId);
|
|
496
|
-
if (list) list.push(ut);
|
|
497
|
-
else out.set(ut.sessionId, [ut]);
|
|
498
|
-
}
|
|
499
|
-
return out;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
const VALID_OVERHEAD_KINDS = ['claude-md', 'agents-md'];
|
|
503
|
-
|
|
504
|
-
// Discover and parse overhead files for a project, returning the parsed files
|
|
505
|
-
// alongside the cost attribution (per-file and per-section). Shared by
|
|
506
|
-
// `overhead()` (report mode) and `overheadTrim()` (recommendations mode) so the
|
|
507
|
-
// discovery + ingest + query + attribution pipeline lives in one place.
|
|
508
|
-
async function gatherOverhead(opts = {}) {
|
|
509
|
-
const projectPath = opts.project ? path.resolve(opts.project) : process.cwd();
|
|
510
|
-
const kind = opts.kind;
|
|
511
|
-
if (kind !== undefined && !VALID_OVERHEAD_KINDS.includes(kind)) {
|
|
512
|
-
throw new Error(
|
|
513
|
-
`invalid overhead kind: ${JSON.stringify(kind)} (expected one of: ${VALID_OVERHEAD_KINDS.join(', ')})`,
|
|
514
|
-
);
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
let found = await findOverheadFiles(projectPath);
|
|
518
|
-
if (kind) found = found.filter((f) => f.kind === kind);
|
|
519
|
-
if (found.length === 0) {
|
|
520
|
-
return { projectPath, files: [], attribution: null };
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
const files = [];
|
|
524
|
-
for (const f of found) files.push(await loadOverheadFile(f));
|
|
525
|
-
|
|
526
|
-
const resolved = resolveProject(projectPath);
|
|
527
|
-
const q = { project: resolved.projectKey ?? projectPath };
|
|
528
|
-
const normalizedSince = normalizeSince(opts.since);
|
|
529
|
-
if (normalizedSince) q.since = normalizedSince;
|
|
530
|
-
|
|
531
|
-
const turns = await loadTurnsViaArchive(q, opts.onLog);
|
|
532
|
-
const pricing = await loadPricing();
|
|
533
|
-
const attribution = attributeOverhead({ files, turns, pricing });
|
|
534
|
-
return { projectPath, files, attribution };
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
export async function overhead(opts = {}) {
|
|
538
|
-
return withHome(opts.ledgerHome, async () => {
|
|
539
|
-
const data = await gatherOverhead(opts);
|
|
540
|
-
if (!data.attribution) {
|
|
541
|
-
return { project: data.projectPath, files: [], perFile: [], grandTotal: 0 };
|
|
542
|
-
}
|
|
543
|
-
return {
|
|
544
|
-
project: data.projectPath,
|
|
545
|
-
files: data.files.map(({ file, parsed }) => ({
|
|
546
|
-
kind: file.kind,
|
|
547
|
-
path: file.path,
|
|
548
|
-
appliesTo: file.appliesTo,
|
|
549
|
-
totalLines: parsed.totalLines,
|
|
550
|
-
bytes: parsed.bytes,
|
|
551
|
-
tokens: parsed.tokens,
|
|
552
|
-
sections: parsed.sections,
|
|
553
|
-
groupingLevel: parsed.groupingLevel,
|
|
554
|
-
})),
|
|
555
|
-
perFile: data.attribution.perFile.map((p) => ({
|
|
556
|
-
path: p.file.path,
|
|
557
|
-
kind: p.file.kind,
|
|
558
|
-
appliesTo: p.file.appliesTo,
|
|
559
|
-
attribution: p.attribution,
|
|
560
|
-
})),
|
|
561
|
-
grandTotal: data.attribution.grandTotal,
|
|
562
|
-
};
|
|
563
|
-
});
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
export async function overheadTrim(opts = {}) {
|
|
567
|
-
return withHome(opts.ledgerHome, async () => {
|
|
568
|
-
const data = await gatherOverhead(opts);
|
|
569
|
-
const topPerFile = parseTopN(opts.top);
|
|
570
|
-
const sinceLabel = opts.since ?? 'all time';
|
|
571
|
-
if (!data.attribution) {
|
|
572
|
-
return {
|
|
573
|
-
project: data.projectPath,
|
|
574
|
-
since: sinceLabel,
|
|
575
|
-
recommendations: [],
|
|
576
|
-
summary: {
|
|
577
|
-
filesAnalyzed: 0,
|
|
578
|
-
filesWithRecommendations: 0,
|
|
579
|
-
totalRecommendations: 0,
|
|
580
|
-
totalProjectedSavingsPerSession: 0,
|
|
581
|
-
totalProjectedSavingsAcrossWindow: 0,
|
|
582
|
-
},
|
|
583
|
-
};
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
// The diff field is the unified-diff text the trim recommendation would
|
|
587
|
-
// produce — heavy enough to opt out of but useful enough that the CLI's
|
|
588
|
-
// --json mode always emits it. Keep that default; allow opts.includeDiff
|
|
589
|
-
// === false to skip the file reads when a caller (e.g. a future MCP tool)
|
|
590
|
-
// only wants the recommendation rows.
|
|
591
|
-
const includeDiff = opts.includeDiff !== false;
|
|
592
|
-
const textCache = new Map();
|
|
593
|
-
const recommendations = [];
|
|
594
|
-
let filesWithRecommendations = 0;
|
|
595
|
-
|
|
596
|
-
for (const fileAttr of data.attribution.perFile) {
|
|
597
|
-
const recs = buildTrimRecommendations(fileAttr.attribution, topPerFile);
|
|
598
|
-
if (recs.length === 0) continue;
|
|
599
|
-
filesWithRecommendations++;
|
|
600
|
-
let text;
|
|
601
|
-
if (includeDiff) {
|
|
602
|
-
text = textCache.get(fileAttr.file.path);
|
|
603
|
-
if (text === undefined) {
|
|
604
|
-
text = await readFile(fileAttr.file.path, 'utf8');
|
|
605
|
-
textCache.set(fileAttr.file.path, text);
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
for (const rec of recs) {
|
|
609
|
-
const entry = {
|
|
610
|
-
file: toProjectRelativePath(fileAttr.file.path, data.projectPath),
|
|
611
|
-
kind: fileAttr.file.kind,
|
|
612
|
-
appliesTo: fileAttr.file.appliesTo,
|
|
613
|
-
section: {
|
|
614
|
-
heading: rec.section.heading,
|
|
615
|
-
startLine: rec.section.startLine,
|
|
616
|
-
endLine: rec.section.endLine,
|
|
617
|
-
tokens: rec.section.tokens,
|
|
618
|
-
},
|
|
619
|
-
projectedSavings: {
|
|
620
|
-
perSessionUsd: rec.projectedSavingsPerSession,
|
|
621
|
-
acrossWindowUsd: rec.projectedSavingsAcrossWindow,
|
|
622
|
-
tokens: rec.section.tokens,
|
|
623
|
-
tokenShare: rec.tokenShare,
|
|
624
|
-
},
|
|
625
|
-
};
|
|
626
|
-
if (includeDiff) {
|
|
627
|
-
entry.diff = renderUnifiedDiffForRecommendation(
|
|
628
|
-
fileAttr.file.path,
|
|
629
|
-
text,
|
|
630
|
-
rec,
|
|
631
|
-
data.projectPath,
|
|
632
|
-
);
|
|
633
|
-
}
|
|
634
|
-
recommendations.push(entry);
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
return {
|
|
639
|
-
project: data.projectPath,
|
|
640
|
-
since: sinceLabel,
|
|
641
|
-
recommendations,
|
|
642
|
-
summary: {
|
|
643
|
-
filesAnalyzed: data.files.length,
|
|
644
|
-
filesWithRecommendations,
|
|
645
|
-
totalRecommendations: recommendations.length,
|
|
646
|
-
totalProjectedSavingsPerSession: recommendations.reduce(
|
|
647
|
-
(sum, r) => sum + r.projectedSavings.perSessionUsd,
|
|
648
|
-
0,
|
|
649
|
-
),
|
|
650
|
-
totalProjectedSavingsAcrossWindow: recommendations.reduce(
|
|
651
|
-
(sum, r) => sum + r.projectedSavings.acrossWindowUsd,
|
|
652
|
-
0,
|
|
653
|
-
),
|
|
654
|
-
},
|
|
655
|
-
};
|
|
656
|
-
});
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
function parseTopN(v) {
|
|
660
|
-
if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) return 3;
|
|
661
|
-
return Math.floor(v);
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
function toProjectRelativePath(filePath, projectPath) {
|
|
665
|
-
const rel = path.relative(projectPath, filePath);
|
|
666
|
-
const display = rel && !rel.startsWith('..') ? rel : filePath;
|
|
667
|
-
return display.split(path.sep).join('/');
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
const FIDELITY_CHOICES = ['full', 'usage-only', 'aggregate-only', 'cost-only', 'partial'];
|
|
671
|
-
|
|
672
|
-
// Per-(model, activity) comparison shape. Mirrors the archive-vs-ledger
|
|
673
|
-
// branching `runCompare` ships in the CLI: archive when nothing forces a
|
|
674
|
-
// per-turn walk (no fidelity gate, no provider filter), ledger walk
|
|
675
|
-
// otherwise. Returns the same JSON object the CLI's `--json` mode emits so
|
|
676
|
-
// the CLI becomes a thin presenter and a future `burn__compare` MCP tool
|
|
677
|
-
// can wrap this directly.
|
|
678
|
-
export async function compare(opts) {
|
|
679
|
-
if (!opts || !Array.isArray(opts.models) || opts.models.length < 2) {
|
|
680
|
-
throw new Error('compare: needs at least 2 models');
|
|
681
|
-
}
|
|
682
|
-
if (opts.minFidelity !== undefined && !FIDELITY_CHOICES.includes(opts.minFidelity)) {
|
|
683
|
-
throw new Error(
|
|
684
|
-
`compare: invalid minFidelity: ${opts.minFidelity} (expected one of ${FIDELITY_CHOICES.join(', ')})`,
|
|
685
|
-
);
|
|
686
|
-
}
|
|
687
|
-
return withHome(opts.ledgerHome, async () => {
|
|
688
|
-
const minFidelity = opts.minFidelity ?? 'usage-only';
|
|
689
|
-
const minSample = opts.minSample ?? DEFAULT_MIN_SAMPLE;
|
|
690
|
-
const providerFilter = normalizeProviderFilter(opts.provider);
|
|
691
|
-
|
|
692
|
-
const q = {};
|
|
693
|
-
const since = normalizeSince(opts.since);
|
|
694
|
-
if (since !== undefined) q.since = since;
|
|
695
|
-
if (opts.session !== undefined) q.sessionId = opts.session;
|
|
696
|
-
if (opts.project !== undefined) q.project = opts.project;
|
|
697
|
-
if (opts.workflow !== undefined || opts.agent !== undefined) {
|
|
698
|
-
q.enrichment = {};
|
|
699
|
-
if (opts.workflow !== undefined) q.enrichment.workflowId = opts.workflow;
|
|
700
|
-
if (opts.agent !== undefined) q.enrichment.agentId = opts.agent;
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
const pricing = await loadPricing();
|
|
704
|
-
const tableOpts = { pricing, minSample, models: opts.models };
|
|
705
|
-
|
|
706
|
-
// `RELAYBURN_ARCHIVE=0` (also `false`/`no`) is the documented escape
|
|
707
|
-
// hatch from the archive path — used by `burn compare --no-archive` for
|
|
708
|
-
// parity/debug workflows. Honor it before deciding whether to query the
|
|
709
|
-
// archive at all so the CLI flag actually forces the ledger walk even
|
|
710
|
-
// when the archive on disk is healthy.
|
|
711
|
-
const archiveEnabled = !envDisablesArchive();
|
|
712
|
-
|
|
713
|
-
// Archive path is additionally restricted to slices where nothing forces
|
|
714
|
-
// a per-turn walk: no fidelity gate (`partial` lets everything through)
|
|
715
|
-
// and no provider filter (provider is derived per turn from (model,
|
|
716
|
-
// source) at query time and the archive's grouped SQL doesn't expose
|
|
717
|
-
// that classifier).
|
|
718
|
-
const useArchive = archiveEnabled && minFidelity === 'partial' && !providerFilter;
|
|
719
|
-
|
|
720
|
-
let table;
|
|
721
|
-
let analyzedTurns;
|
|
722
|
-
let summary;
|
|
723
|
-
if (useArchive) {
|
|
724
|
-
try {
|
|
725
|
-
await buildArchive();
|
|
726
|
-
const archived = await compareFromArchive(q, tableOpts);
|
|
727
|
-
table = archived.table;
|
|
728
|
-
// For the fidelity-permissive mode we still emit a zero-excluded
|
|
729
|
-
// summary so the JSON schema stays stable. summarizeFidelity needs
|
|
730
|
-
// turn rows; pull them via the same archive-aware loader.
|
|
731
|
-
const turnsForSummary = await loadTurnsViaArchive(q, opts.onLog);
|
|
732
|
-
summary = summarizeFidelity(turnsForSummary);
|
|
733
|
-
analyzedTurns = turnsForSummary.length;
|
|
734
|
-
return shapeCompareResult(table, analyzedTurns, minFidelity, summary);
|
|
735
|
-
} catch (err) {
|
|
736
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
737
|
-
opts.onLog?.(`archive compare failed, falling back to ledger walk: ${msg}`);
|
|
738
|
-
// Fall through to ledger path.
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
// Ledger-walk path. When the archive is disabled we go straight to
|
|
743
|
-
// `queryAll` (no `buildArchive` side effect); otherwise the
|
|
744
|
-
// archive-aware loader still wins on the hot path even when the gate
|
|
745
|
-
// forces post-load filtering.
|
|
746
|
-
const queriedTurns = archiveEnabled
|
|
747
|
-
? await loadTurnsViaArchive(q, opts.onLog)
|
|
748
|
-
: await queryAll(q);
|
|
749
|
-
const turns = providerFilter ? filterTurnsByProvider(queriedTurns, providerFilter) : queriedTurns;
|
|
750
|
-
summary = summarizeFidelity(turns);
|
|
751
|
-
const filteredTurns = minFidelity === 'partial'
|
|
752
|
-
? turns
|
|
753
|
-
: turns.filter((t) => hasMinimumFidelity(t.fidelity, minFidelity));
|
|
754
|
-
table = buildCompareTable(filteredTurns, tableOpts);
|
|
755
|
-
analyzedTurns = filteredTurns.length;
|
|
756
|
-
return shapeCompareResult(table, analyzedTurns, minFidelity, summary);
|
|
757
|
-
});
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
function envDisablesArchive() {
|
|
761
|
-
const v = process.env.RELAYBURN_ARCHIVE;
|
|
762
|
-
return v === '0' || v === 'false' || v === 'no';
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
function normalizeProviderFilter(provider) {
|
|
766
|
-
if (!provider) return undefined;
|
|
767
|
-
if (!Array.isArray(provider)) {
|
|
768
|
-
throw new Error('compare: provider must be an array of strings');
|
|
769
|
-
}
|
|
770
|
-
const normalized = provider
|
|
771
|
-
.map((p) => (typeof p === 'string' ? p.trim().toLowerCase() : ''))
|
|
772
|
-
.filter(Boolean);
|
|
773
|
-
if (normalized.length === 0) return undefined;
|
|
774
|
-
return new Set(normalized);
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
// Sum the byClass buckets that fall below the minimum fidelity. We never
|
|
778
|
-
// exclude `unknown` (records without a fidelity field — `hasMinimumFidelity`
|
|
779
|
-
// passes them for backward compat), so they don't get counted here.
|
|
780
|
-
// `partial` is the "include everything" escape hatch; it always reports zero
|
|
781
|
-
// excluded.
|
|
782
|
-
export function computeCompareExcluded(summary, minimum) {
|
|
783
|
-
const out = { total: 0, aggregateOnly: 0, costOnly: 0, partial: 0, usageOnly: 0 };
|
|
784
|
-
if (minimum === 'partial') return out;
|
|
785
|
-
const order = ['cost-only', 'aggregate-only', 'partial', 'usage-only', 'full'];
|
|
786
|
-
const need = order.indexOf(minimum);
|
|
787
|
-
for (const cls of order) {
|
|
788
|
-
if (order.indexOf(cls) >= need) continue;
|
|
789
|
-
const n = summary.byClass[cls];
|
|
790
|
-
if (!n) continue;
|
|
791
|
-
out.total += n;
|
|
792
|
-
if (cls === 'aggregate-only') out.aggregateOnly += n;
|
|
793
|
-
else if (cls === 'cost-only') out.costOnly += n;
|
|
794
|
-
else if (cls === 'partial') out.partial += n;
|
|
795
|
-
else if (cls === 'usage-only') out.usageOnly += n;
|
|
796
|
-
}
|
|
797
|
-
return out;
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
function shapeCompareResult(table, analyzedTurns, minimum, summary) {
|
|
801
|
-
const excluded = computeCompareExcluded(summary, minimum);
|
|
802
|
-
const cells = [];
|
|
803
|
-
for (const m of table.models) {
|
|
804
|
-
for (const cat of table.categories) {
|
|
805
|
-
const c = table.cells[m][cat];
|
|
806
|
-
cells.push({
|
|
807
|
-
model: m,
|
|
808
|
-
category: cat,
|
|
809
|
-
turns: c.turns,
|
|
810
|
-
editTurns: c.editTurns,
|
|
811
|
-
oneShotTurns: c.oneShotTurns,
|
|
812
|
-
pricedTurns: c.pricedTurns,
|
|
813
|
-
totalCost: round(c.totalCost, 6),
|
|
814
|
-
costPerTurn: c.costPerTurn !== null ? round(c.costPerTurn, 6) : null,
|
|
815
|
-
oneShotRate: c.oneShotRate !== null ? round(c.oneShotRate, 4) : null,
|
|
816
|
-
cacheHitRate: c.cacheHitRate !== null ? round(c.cacheHitRate, 4) : null,
|
|
817
|
-
medianRetries: c.medianRetries,
|
|
818
|
-
noData: c.noData,
|
|
819
|
-
insufficientSample: c.insufficientSample,
|
|
820
|
-
});
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
return {
|
|
824
|
-
analyzedTurns,
|
|
825
|
-
minSample: table.minSample,
|
|
826
|
-
models: table.models,
|
|
827
|
-
categories: table.categories,
|
|
828
|
-
totals: table.totals,
|
|
829
|
-
cells,
|
|
830
|
-
fidelity: { minimum, excluded, summary },
|
|
831
|
-
};
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
function round(n, digits) {
|
|
835
|
-
return Number(n.toFixed(digits));
|
|
836
|
-
}
|