@relayburn/sdk 1.8.0 → 1.10.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 CHANGED
@@ -4,12 +4,41 @@ All notable changes to `@relayburn/sdk`.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.10.0] - 2026-05-03
8
+
9
+ ### Breaking Changes
10
+
11
+ - `hotspots()` now returns a discriminated union (`{ kind: 'attribution' | 'bash' | 'bash-verb' | 'file' | 'subagent' | 'findings' }`) instead of either an attribution blob or a raw findings array. Callers must branch on `kind`. The default (no `groupBy`, no `patterns`) returns the `attribution` shape that mirrors `burn hotspots --json`. Pass `patterns` to get `findings`. Pass `groupBy` to narrow attribution to one axis (`bash` / `bash-verb` / `file` / `subagent`). Warrants the SDK major bump.
12
+
13
+ ### Added
14
+
15
+ - `hotspots({ groupBy })` narrows attribution to one aggregation axis: `bash`, `bash-verb`, `file`, or `subagent`. Useful for MCP tools and embedders that only want a single per-axis cut.
16
+ - `hotspots({ project, since })` accept the same forwarded options the other SDK queries do (project filter + ISO/relative `since` window normalization).
17
+ - `hotspots()` reads through the SQLite archive by default with transparent fallback to the JSONL ledger walk on archive failure. Pass `onLog` to capture the fallback reason.
18
+ - `hotspots()` surfaces a coverage-refusal shape (`refused: true` + `refusalReason`) when every matched turn lacks the tool-call/tool-result coverage `attributeHotspots` needs, so presenters can map it to the user-facing exit-2 + stderr message.
19
+
20
+ ## [1.9.0] - 2026-05-03
21
+
22
+ ### Added
23
+
24
+ - `compare({ models, … })` returns the per-(model, activity) `CompareResult` shape (`analyzedTurns`, `models`, `categories`, `totals`, flat `cells[]`, `fidelity { minimum, excluded, summary }`) — the JSON object `burn compare --json` now emits. Mirrors the CLI's archive-vs-ledger branching: archive when `minFidelity === 'partial'` and no provider filter, ledger walk otherwise. Falls back transparently to the ledger walk when the archive read fails.
25
+ - `sessionCost({ session })` returns the compact per-session cost shape (`totalUSD`, `totalTokens`, `turnCount`, `models`) the MCP `burn__sessionCost` tool now wraps directly.
26
+ - `summary()` result now includes `turnCount`.
27
+ - `summary()` and `sessionCost()` read through the SQLite archive by default with transparent fallback to the JSONL ledger walk on archive failure. Pass `onLog` to capture the fallback reason.
28
+ - `overhead({ project, since?, kind? })` returns per-file + per-section overhead cost attribution (the JSON shape `burn overhead --json` now consumes).
29
+ - `overheadTrim({ project, since?, kind?, top?, includeDiff? })` returns trim recommendations with projected savings and (by default) embedded unified diffs (the JSON shape `burn overhead trim --json` now consumes). Pass `includeDiff: false` to skip the per-file disk reads.
30
+ - `summary({ since })` and `overhead({ since })` / `overheadTrim({ since })` now accept either an ISO timestamp or a relative range (`24h`, `7d`, `4w`, `2m`); the SDK normalizes both forms before querying the ledger so direct SDK callers get the same forgiving input shape CLI users have. Previously a raw relative string would silently filter out every turn.
31
+
7
32
  ## [1.7.0] - 2026-05-02
8
33
 
9
34
  ### Added
10
35
 
11
36
  - `hotspots({ patterns })` now also surfaces `tool-output-bloat`, `ghost-surface`, and `tool-call-pattern` findings (previously only the core `detectPatterns` set). Each side-channel detector loads its own inputs (Claude settings, tool-result events, on-disk surface) lazily based on the requested patterns.
12
37
 
38
+ ### Changed
39
+
40
+ - SDK no longer depends on `@relayburn/cli`. `ingest()` now imports from the new `@relayburn/ingest` package, and `buildGhostSurfaceInputs` lives in `@relayburn/analyze`. The SDK's public surface is unchanged.
41
+
13
42
  ## [1.5.0] - 2026-05-01
14
43
 
15
44
  ### Added
package/README.md CHANGED
@@ -1,12 +1,50 @@
1
1
  # @relayburn/sdk
2
2
 
3
- Embeddable Relayburn SDK for in-process ingestion and analysis.
3
+ Embeddable Relayburn SDK for in-process ingestion and analysis. This package is the **source of truth** for the in-process query/compute surface — `@relayburn/mcp` and `@relayburn/cli` consume the SDK rather than duplicating its logic.
4
4
 
5
5
  ```ts
6
- import { Ledger, ingest, summary, hotspots } from '@relayburn/sdk';
6
+ import {
7
+ Ledger,
8
+ ingest,
9
+ summary,
10
+ sessionCost,
11
+ compare,
12
+ overhead,
13
+ overheadTrim,
14
+ hotspots,
15
+ } from '@relayburn/sdk';
7
16
 
8
17
  await Ledger.open({ home: '/tmp/relayburn-home' });
9
18
  await ingest({ ledgerHome: '/tmp/relayburn-home' });
19
+
20
+ // Slice-wide rollup: turnCount + per-model + per-tool aggregates.
10
21
  const stats = await summary({ session: 'session-id', ledgerHome: '/tmp/relayburn-home' });
22
+
23
+ // Compact session-scoped cost shape (totalUSD/totalTokens/turnCount/models).
24
+ // Powers the MCP `burn__sessionCost` tool.
25
+ const cost = await sessionCost({ session: 'session-id' });
26
+
27
+ // Per-(model, activity) comparison shape — the JSON object `burn compare --json` emits.
28
+ const cmp = await compare({
29
+ models: ['claude-sonnet-4-6', 'claude-haiku-4-5'],
30
+ since: '30d',
31
+ minFidelity: 'usage-only',
32
+ });
33
+
34
+ // Overhead-file (CLAUDE.md / AGENTS.md / .claude/CLAUDE.md) cost attribution.
35
+ const oh = await overhead({ project: '/path/to/repo', since: '30d' });
36
+ const trim = await overheadTrim({ project: '/path/to/repo', top: 3 });
37
+
38
+ // Per-axis hotspot attribution + pattern findings. Returns a discriminated
39
+ // union — branch on `kind`:
40
+ // { kind: 'attribution', files, bashVerbs, bash, subagents, sessions, … }
41
+ // { kind: 'bash' | 'bash-verb' | 'file' | 'subagent', rows: [...] }
42
+ // { kind: 'findings', findings: WasteFinding[], summary }
43
+ const attribution = await hotspots({ session: 'session-id' });
44
+ const fileRows = await hotspots({ session: 'session-id', groupBy: 'file' });
11
45
  const findings = await hotspots({ session: 'session-id', patterns: ['retry-loop'] });
12
46
  ```
47
+
48
+ `summary`, `sessionCost`, `compare`, `overhead`, `overheadTrim`, and `hotspots` read through the SQLite archive when available, transparently falling back to the JSONL ledger walk if the archive can't be opened. Pass `onLog` to surface fallback messages in your host's log channel.
49
+
50
+ `overheadTrim` includes a unified-diff string per recommendation by default (matches `burn overhead trim --json`); pass `includeDiff: false` to skip the per-file disk reads when you only need the recommendation rows.
package/index.d.ts CHANGED
@@ -4,11 +4,155 @@ export declare class Ledger { static open(opts?: LedgerOpenOptions): Promise<Led
4
4
  export interface IngestOptions { sessionId?: string; harness?: 'claude-code'|'codex'|'opencode'; ledgerHome?: string }
5
5
  export declare function ingest(opts?: IngestOptions): Promise<unknown>
6
6
 
7
- export interface SummaryOptions { session?: string; project?: string; since?: string; ledgerHome?: string }
8
- export declare function summary(opts?: SummaryOptions): Promise<{ totalTokens: number; totalCost: number; byTool: Array<{tool:string;tokens:number;cost:number;count:number}>; byModel: Array<{model:string;tokens:number;cost:number}> }>
7
+ export interface SummaryOptions {
8
+ session?: string;
9
+ project?: string;
10
+ /** ISO timestamp (e.g. `2026-04-01T00:00:00Z`) or relative range (`24h`, `7d`, `4w`, `2m`). */
11
+ since?: string;
12
+ ledgerHome?: string;
13
+ /** Optional logger invoked when the SQLite archive read fails and the SDK falls back to a full ledger walk. */
14
+ onLog?: (msg: string) => void;
15
+ }
16
+ export declare function summary(opts?: SummaryOptions): Promise<{
17
+ totalTokens: number;
18
+ totalCost: number;
19
+ turnCount: number;
20
+ byTool: Array<{ tool: string; tokens: number; cost: number; count: number }>;
21
+ byModel: Array<{ model: string; tokens: number; cost: number }>;
22
+ }>
23
+
24
+ export interface SessionCostOptions {
25
+ /** Session id to total. Omit for `{ note: 'no session id provided' }`. */
26
+ session?: string;
27
+ ledgerHome?: string;
28
+ onLog?: (msg: string) => void;
29
+ }
30
+ export interface SessionCostResult {
31
+ sessionId: string | null;
32
+ totalUSD: number;
33
+ totalTokens: number;
34
+ turnCount: number;
35
+ models: string[];
36
+ note?: string;
37
+ }
38
+ /** Compact session-scoped cost shape; powers the MCP `burn__sessionCost` tool. */
39
+ export declare function sessionCost(opts?: SessionCostOptions): Promise<SessionCostResult>
40
+
41
+ export type OverheadFileKind = 'claude-md' | 'agents-md';
42
+ export type OverheadHarness = 'claude-code' | 'codex' | 'opencode';
43
+
44
+ export interface OverheadOptions {
45
+ /** Project path to inspect; defaults to process.cwd(). */
46
+ project?: string;
47
+ /** ISO timestamp or relative range (`24h`, `7d`, `4w`, `2m`); the SDK normalizes both forms before querying. */
48
+ since?: string;
49
+ /** Narrow to a single overhead file kind. */
50
+ kind?: OverheadFileKind;
51
+ ledgerHome?: string;
52
+ onLog?: (msg: string) => void;
53
+ }
54
+
55
+ export interface OverheadSection {
56
+ heading: string;
57
+ startLine: number;
58
+ endLine: number;
59
+ tokens: number;
60
+ }
61
+
62
+ export interface OverheadSectionCost {
63
+ filePath: string;
64
+ section: OverheadSection;
65
+ tokenShare: number;
66
+ costPerSession: number;
67
+ totalCost: number;
68
+ }
69
+
70
+ export interface OverheadAttributionDetail {
71
+ sessionCount: number;
72
+ perSessionAvg: number;
73
+ perSessionP95: number;
74
+ totalCost: number;
75
+ sectionCosts: OverheadSectionCost[];
76
+ }
77
+
78
+ export interface OverheadFileSummary {
79
+ kind: OverheadFileKind;
80
+ path: string;
81
+ appliesTo: OverheadHarness[];
82
+ totalLines: number;
83
+ bytes: number;
84
+ tokens: number;
85
+ sections: OverheadSection[];
86
+ groupingLevel: number;
87
+ }
88
+
89
+ export interface OverheadPerFileEntry {
90
+ path: string;
91
+ kind: OverheadFileKind;
92
+ appliesTo: OverheadHarness[];
93
+ attribution: OverheadAttributionDetail;
94
+ }
95
+
96
+ export interface OverheadResult {
97
+ project: string;
98
+ files: OverheadFileSummary[];
99
+ perFile: OverheadPerFileEntry[];
100
+ grandTotal: number;
101
+ }
102
+
103
+ /** Per-file + per-section overhead cost attribution. Powers `burn overhead`. */
104
+ export declare function overhead(opts?: OverheadOptions): Promise<OverheadResult>
105
+
106
+ export interface OverheadTrimOptions extends OverheadOptions {
107
+ /** Recommendations per file. Default 3. */
108
+ top?: number;
109
+ /** Include the unified-diff text per recommendation (requires a file read per recommended file). Default true; pass false to skip. */
110
+ includeDiff?: boolean;
111
+ }
112
+
113
+ export interface OverheadTrimRecommendation {
114
+ file: string;
115
+ kind: OverheadFileKind;
116
+ appliesTo: OverheadHarness[];
117
+ section: { heading: string; startLine: number; endLine: number; tokens: number };
118
+ projectedSavings: {
119
+ perSessionUsd: number;
120
+ acrossWindowUsd: number;
121
+ tokens: number;
122
+ tokenShare: number;
123
+ };
124
+ diff?: string;
125
+ }
126
+
127
+ export interface OverheadTrimResult {
128
+ project: string;
129
+ since: string;
130
+ recommendations: OverheadTrimRecommendation[];
131
+ summary: {
132
+ filesAnalyzed: number;
133
+ filesWithRecommendations: number;
134
+ totalRecommendations: number;
135
+ totalProjectedSavingsPerSession: number;
136
+ totalProjectedSavingsAcrossWindow: number;
137
+ };
138
+ }
139
+
140
+ /** Trim recommendations for high-cost overhead-file sections. Powers `burn overhead trim`. */
141
+ export declare function overheadTrim(opts?: OverheadTrimOptions): Promise<OverheadTrimResult>
142
+
143
+ export type HotspotsGroupBy = 'attribution' | 'bash' | 'bash-verb' | 'file' | 'subagent';
9
144
 
10
145
  export interface HotspotsOptions {
11
146
  session?: string;
147
+ project?: string;
148
+ /** ISO timestamp (e.g. `2026-04-01T00:00:00Z`) or relative range (`24h`, `7d`, `4w`, `2m`). */
149
+ since?: string;
150
+ /**
151
+ * Narrow the attribution result to a single aggregation axis. When omitted
152
+ * (or `'attribution'`), the full attribution shape is returned. Ignored
153
+ * when `patterns` is set — patterns always returns the `findings` shape.
154
+ */
155
+ groupBy?: HotspotsGroupBy;
12
156
  /**
13
157
  * Pattern kinds to detect. Supported kinds:
14
158
  * - core (via `detectPatterns`): `retry-loop`, `failure-run`,
@@ -16,10 +160,199 @@ export interface HotspotsOptions {
16
160
  * `skill-recall-dup`, `skill-pruning-protection`, `system-prompt-tax`
17
161
  * - side-channel: `tool-output-bloat`, `ghost-surface`, `tool-call-pattern`
18
162
  *
19
- * When omitted or empty, returns the attribution result instead of a
20
- * findings array.
163
+ * When omitted or empty, returns the attribution result instead of the
164
+ * findings shape.
21
165
  */
22
166
  patterns?: string[];
23
167
  ledgerHome?: string;
168
+ /** Optional logger invoked when the SQLite archive read fails and the SDK falls back to a full ledger walk. */
169
+ onLog?: (msg: string) => void;
24
170
  }
25
- export declare function hotspots(opts?: HotspotsOptions): Promise<unknown>
171
+
172
+ /** Per-axis aggregation row (file). */
173
+ export interface HotspotsFileRow {
174
+ path: string;
175
+ firstEmitTurnIndex: number;
176
+ initialTokens: number;
177
+ persistenceTokens: number;
178
+ ridingTurns: number;
179
+ totalCost: number;
180
+ }
181
+
182
+ /** Per-axis aggregation row (bash, exact command). */
183
+ export interface HotspotsBashRow {
184
+ command: string | undefined;
185
+ argsHash: string;
186
+ callCount: number;
187
+ initialTokens: number;
188
+ persistenceTokens: number;
189
+ totalCost: number;
190
+ }
191
+
192
+ /** Per-axis aggregation row (bash, by leading verb). */
193
+ export interface HotspotsBashVerbRow {
194
+ verb: string;
195
+ callCount: number;
196
+ distinctCommands: number;
197
+ initialTokens: number;
198
+ persistenceTokens: number;
199
+ avgPersistenceTurns: number;
200
+ totalCost: number;
201
+ topExamples: string[];
202
+ }
203
+
204
+ /** Per-axis aggregation row (subagent / Agent / Task). */
205
+ export interface HotspotsSubagentRow {
206
+ subagentType: string;
207
+ callCount: number;
208
+ initialTokens: number;
209
+ persistenceTokens: number;
210
+ totalCost: number;
211
+ }
212
+
213
+ export interface HotspotsSessionTotal {
214
+ sessionId: string;
215
+ grandCost: number;
216
+ attributedCost: number;
217
+ unattributedCost: number;
218
+ attributionMethod: 'sized' | 'even-split';
219
+ }
220
+
221
+ export interface HotspotsFidelityBlock {
222
+ analyzed: number;
223
+ excluded: number;
224
+ /** Aggregate fidelity summary for the matched-window turns (analyzed + excluded). */
225
+ summary: unknown;
226
+ refused: boolean;
227
+ }
228
+
229
+ /** Full attribution shape — mirrors the CLI's `burn hotspots --json`. */
230
+ export interface HotspotsAttributionResult {
231
+ kind: 'attribution';
232
+ turnsAnalyzed: number;
233
+ grandTotal: number;
234
+ attributedTotal: number;
235
+ unattributedTotal: number;
236
+ attributionDegraded: boolean;
237
+ sessions: HotspotsSessionTotal[];
238
+ files: HotspotsFileRow[];
239
+ bashVerbs: HotspotsBashVerbRow[];
240
+ bash: HotspotsBashRow[];
241
+ subagents: HotspotsSubagentRow[];
242
+ fidelity: HotspotsFidelityBlock;
243
+ /** Set when every matched turn lacked the coverage attribution needs. */
244
+ refused?: boolean;
245
+ refusalReason?: string;
246
+ }
247
+
248
+ /** Narrowed shapes — one aggregation axis only. */
249
+ export interface HotspotsBashResult { kind: 'bash'; rows: HotspotsBashRow[]; refused?: boolean; refusalReason?: string }
250
+ export interface HotspotsBashVerbResult { kind: 'bash-verb'; rows: HotspotsBashVerbRow[]; refused?: boolean; refusalReason?: string }
251
+ export interface HotspotsFileResult { kind: 'file'; rows: HotspotsFileRow[]; refused?: boolean; refusalReason?: string }
252
+ export interface HotspotsSubagentResult { kind: 'subagent'; rows: HotspotsSubagentRow[]; refused?: boolean; refusalReason?: string }
253
+
254
+ export interface HotspotsFinding {
255
+ kind: string;
256
+ severity: string;
257
+ sessionId: string;
258
+ title: string;
259
+ estimatedSavings: { usdPerSession?: number; [k: string]: unknown };
260
+ [k: string]: unknown;
261
+ }
262
+
263
+ export interface HotspotsFindingsResult {
264
+ kind: 'findings';
265
+ findings: HotspotsFinding[];
266
+ /** Aggregate fidelity summary for the matched-window turns. */
267
+ summary: unknown;
268
+ }
269
+
270
+ export type HotspotsResult =
271
+ | HotspotsAttributionResult
272
+ | HotspotsBashResult
273
+ | HotspotsBashVerbResult
274
+ | HotspotsFileResult
275
+ | HotspotsSubagentResult
276
+ | HotspotsFindingsResult;
277
+
278
+ /**
279
+ * Per-axis hotspot attribution + pattern-finding queries. Returns a
280
+ * discriminated union — see `HotspotsResult`.
281
+ */
282
+ export declare function hotspots(opts?: HotspotsOptions): Promise<HotspotsResult>
283
+
284
+ export type FidelityClass = 'full' | 'usage-only' | 'aggregate-only' | 'cost-only' | 'partial';
285
+
286
+ export interface FidelitySummaryShape {
287
+ total: number;
288
+ byClass: Record<FidelityClass, number>;
289
+ unknown: number;
290
+ missingCoverage: Record<string, number>;
291
+ }
292
+
293
+ export interface CompareExcludedBreakdown {
294
+ total: number;
295
+ aggregateOnly: number;
296
+ costOnly: number;
297
+ partial: number;
298
+ usageOnly: number;
299
+ }
300
+
301
+ export interface CompareCellResult {
302
+ model: string;
303
+ category: string;
304
+ turns: number;
305
+ editTurns: number;
306
+ oneShotTurns: number;
307
+ pricedTurns: number;
308
+ totalCost: number;
309
+ costPerTurn: number | null;
310
+ oneShotRate: number | null;
311
+ cacheHitRate: number | null;
312
+ medianRetries: number | null;
313
+ noData: boolean;
314
+ insufficientSample: boolean;
315
+ }
316
+
317
+ export interface CompareOptions {
318
+ /** Required: ≥2 model names to compare. */
319
+ models: string[];
320
+ session?: string;
321
+ project?: string;
322
+ /** ISO timestamp (e.g. `2026-04-01T00:00:00Z`) or relative range (`24h`, `7d`, `4w`, `2m`). */
323
+ since?: string;
324
+ workflow?: string;
325
+ agent?: string;
326
+ /** Resolved provider filter (e.g. `['anthropic', 'synthetic']`). */
327
+ provider?: string[];
328
+ /** Insufficient-sample threshold; cells below this get flagged. Default 5. */
329
+ minSample?: number;
330
+ /** Minimum fidelity class to include in the aggregate. Default `'usage-only'`. */
331
+ minFidelity?: FidelityClass;
332
+ ledgerHome?: string;
333
+ onLog?: (msg: string) => void;
334
+ }
335
+
336
+ export interface CompareResult {
337
+ analyzedTurns: number;
338
+ minSample: number;
339
+ models: string[];
340
+ categories: string[];
341
+ totals: Record<string, { turns: number; totalCost: number }>;
342
+ cells: CompareCellResult[];
343
+ fidelity: {
344
+ minimum: FidelityClass;
345
+ excluded: CompareExcludedBreakdown;
346
+ summary: FidelitySummaryShape;
347
+ };
348
+ }
349
+
350
+ /**
351
+ * Per-(model, activity) comparison shape. Powers `burn compare` and the
352
+ * future `burn__compare` MCP tool. Reads through the SQLite archive when
353
+ * `minFidelity === 'partial'` and no provider filter is set; otherwise
354
+ * walks the ledger so the fidelity gate / provider filter can be applied
355
+ * per-turn. Falls back transparently to the ledger walk when the archive
356
+ * read fails.
357
+ */
358
+ export declare function compare(opts: CompareOptions): Promise<CompareResult>
package/index.js CHANGED
@@ -1,21 +1,48 @@
1
- import { queryAll, queryUserTurns, queryToolResultEvents } from '@relayburn/ledger';
2
1
  import {
3
- loadPricing,
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,
4
19
  costForTurn,
5
- attributeHotspots,
20
+ DEFAULT_MIN_SAMPLE,
21
+ detectGhostSurface,
6
22
  detectPatterns,
7
- findingsFromPatterns,
23
+ detectToolCallPatterns,
8
24
  detectToolOutputBloat,
9
- toolOutputBloatToFinding,
10
- detectGhostSurface,
25
+ filterTurnsByProvider,
26
+ findingsFromPatterns,
27
+ findOverheadFiles,
11
28
  ghostSurfaceToFinding,
12
- detectToolCallPatterns,
13
- toolCallPatternToFinding,
29
+ hasMinimumFidelity,
14
30
  loadClaudeSettings,
15
- userClaudeSettingsPath,
31
+ loadOverheadFile,
32
+ loadPricing,
16
33
  projectClaudeSettingsPath,
34
+ renderUnifiedDiffForRecommendation,
35
+ summarizeFidelity,
36
+ sumCosts,
37
+ attributeHotspots,
38
+ toolCallPatternToFinding,
39
+ toolOutputBloatToFinding,
40
+ userClaudeSettingsPath,
17
41
  } from '@relayburn/analyze';
18
- import { ingestAll, buildGhostSurfaceInputs } from '@relayburn/cli';
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';
19
46
 
20
47
  function withHome(home, fn) {
21
48
  const prev = process.env.RELAYBURN_HOME;
@@ -28,6 +55,65 @@ function withHome(home, fn) {
28
55
  });
29
56
  }
30
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
+
31
117
  export class Ledger {
32
118
  static async open(opts = {}) {
33
119
  return new Ledger(opts.home);
@@ -44,8 +130,8 @@ export async function ingest(opts = {}) {
44
130
 
45
131
  export async function summary(opts = {}) {
46
132
  return withHome(opts.ledgerHome, async () => {
47
- const q = { sessionId: opts.session, project: opts.project, since: opts.since };
48
- const turns = await queryAll(q);
133
+ const q = { sessionId: opts.session, project: opts.project, since: normalizeSince(opts.since) };
134
+ const turns = await loadTurnsViaArchive(q, opts.onLog);
49
135
  const pricing = await loadPricing();
50
136
  const byTool = new Map();
51
137
  const byModel = new Map();
@@ -78,65 +164,331 @@ export async function summary(opts = {}) {
78
164
  }
79
165
  }
80
166
 
81
- return { totalTokens, totalCost, byTool: [...byTool.values()], byModel: [...byModel.values()] };
167
+ return {
168
+ totalTokens,
169
+ totalCost,
170
+ turnCount: turns.length,
171
+ byTool: [...byTool.values()],
172
+ byModel: [...byModel.values()],
173
+ };
82
174
  });
83
175
  }
84
176
 
85
- export async function hotspots(opts = {}) {
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 = {}) {
86
184
  return withHome(opts.ledgerHome, async () => {
87
- const turns = await queryAll({ sessionId: opts.session });
88
- const userTurns = await queryUserTurns({ sessionId: opts.session });
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
+ }
89
207
  const pricing = await loadPricing();
90
- const userTurnsBySession = bucketBySession(userTurns);
91
- const attribution = attributeHotspots(turns, { pricing, userTurnsBySession });
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'];
92
240
 
93
- if (!opts.patterns || opts.patterns.length === 0) return attribution;
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
+ }
94
249
 
95
- const wanted = new Set(opts.patterns);
96
- const findings = [];
250
+ const VALID_HOTSPOTS_GROUP_BY = ['attribution', 'bash', 'bash-verb', 'file', 'subagent'];
97
251
 
98
- // Core patterns (retries, failures, edit-heavy, etc.) flow through
99
- // detectPatterns + findingsFromPatterns; non-matching kinds are filtered.
100
- const detected = detectPatterns(turns, { pricing, userTurnsBySession });
101
- for (const f of findingsFromPatterns(detected)) {
102
- if (wanted.has(f.kind)) findings.push(f);
103
- }
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;
104
271
 
105
- // Side-channel detectors live outside detectPatterns. Each one reads its
106
- // own slice of state, so we run them lazily based on `wanted`.
107
-
108
- if (wanted.has('tool-output-bloat')) {
109
- const settings = [];
110
- const userLoaded = await loadClaudeSettings(userClaudeSettingsPath());
111
- if (userLoaded) settings.push(userLoaded);
112
- const projectLoaded = await loadClaudeSettings(projectClaudeSettingsPath());
113
- if (projectLoaded) settings.push(projectLoaded);
114
- const toolResultEvents = await queryToolResultEvents({ sessionId: opts.session });
115
- const bloats = detectToolOutputBloat({
116
- settings,
117
- toolResultEvents,
118
- userTurns,
119
- turns,
120
- pricing,
121
- });
122
- for (const b of bloats) findings.push(toolOutputBloatToFinding(b));
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
+ );
123
286
  }
124
287
 
125
- if (wanted.has('ghost-surface')) {
126
- const ghostInputs = await buildGhostSurfaceInputs(turns, pricing);
127
- const ghosts = await detectGhostSurface(ghostInputs);
128
- for (const g of ghosts) findings.push(ghostSurfaceToFinding(g));
129
- }
288
+ const q = q_(opts);
289
+ const turns = await loadTurnsViaArchive(q, opts.onLog);
290
+ const pricing = await loadPricing();
130
291
 
131
- if (wanted.has('tool-call-pattern')) {
132
- const patterns = detectToolCallPatterns(turns, { pricing });
133
- for (const p of patterns) findings.push(toolCallPatternToFinding(p));
292
+ if (usingPatterns) {
293
+ return runHotspotsFindings(turns, pricing, opts, q);
134
294
  }
135
295
 
136
- return findings;
296
+ return runHotspotsAttribution(turns, pricing, opts, q);
137
297
  });
138
298
  }
139
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
+
140
492
  function bucketBySession(userTurns) {
141
493
  const out = new Map();
142
494
  for (const ut of userTurns) {
@@ -146,3 +498,339 @@ function bucketBySession(userTurns) {
146
498
  }
147
499
  return out;
148
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relayburn/sdk",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "description": "Embeddable Relayburn SDK for in-process ingest, summary, and hotspots queries",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -16,9 +16,10 @@
16
16
  "node": ">=22"
17
17
  },
18
18
  "dependencies": {
19
- "@relayburn/analyze": "1.8.0",
20
- "@relayburn/cli": "1.8.0",
21
- "@relayburn/ledger": "1.8.0"
19
+ "@relayburn/analyze": "1.10.0",
20
+ "@relayburn/ingest": "1.10.0",
21
+ "@relayburn/ledger": "1.10.0",
22
+ "@relayburn/reader": "1.10.0"
22
23
  },
23
24
  "repository": {
24
25
  "type": "git",