@relayburn/sdk 1.9.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 +13 -0
- package/README.md +8 -1
- package/index.d.ts +127 -3
- package/index.js +249 -43
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,19 @@ 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
|
+
|
|
7
20
|
## [1.9.0] - 2026-05-03
|
|
8
21
|
|
|
9
22
|
### Added
|
package/README.md
CHANGED
|
@@ -35,9 +35,16 @@ const cmp = await compare({
|
|
|
35
35
|
const oh = await overhead({ project: '/path/to/repo', since: '30d' });
|
|
36
36
|
const trim = await overheadTrim({ project: '/path/to/repo', top: 3 });
|
|
37
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' });
|
|
38
45
|
const findings = await hotspots({ session: 'session-id', patterns: ['retry-loop'] });
|
|
39
46
|
```
|
|
40
47
|
|
|
41
|
-
`summary`, `sessionCost`, `compare`, `overhead`, and `
|
|
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.
|
|
42
49
|
|
|
43
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
|
@@ -140,8 +140,19 @@ export interface OverheadTrimResult {
|
|
|
140
140
|
/** Trim recommendations for high-cost overhead-file sections. Powers `burn overhead trim`. */
|
|
141
141
|
export declare function overheadTrim(opts?: OverheadTrimOptions): Promise<OverheadTrimResult>
|
|
142
142
|
|
|
143
|
+
export type HotspotsGroupBy = 'attribution' | 'bash' | 'bash-verb' | 'file' | 'subagent';
|
|
144
|
+
|
|
143
145
|
export interface HotspotsOptions {
|
|
144
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;
|
|
145
156
|
/**
|
|
146
157
|
* Pattern kinds to detect. Supported kinds:
|
|
147
158
|
* - core (via `detectPatterns`): `retry-loop`, `failure-run`,
|
|
@@ -149,13 +160,126 @@ export interface HotspotsOptions {
|
|
|
149
160
|
* `skill-recall-dup`, `skill-pruning-protection`, `system-prompt-tax`
|
|
150
161
|
* - side-channel: `tool-output-bloat`, `ghost-surface`, `tool-call-pattern`
|
|
151
162
|
*
|
|
152
|
-
* When omitted or empty, returns the attribution result instead of
|
|
153
|
-
* findings
|
|
163
|
+
* When omitted or empty, returns the attribution result instead of the
|
|
164
|
+
* findings shape.
|
|
154
165
|
*/
|
|
155
166
|
patterns?: string[];
|
|
156
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;
|
|
157
170
|
}
|
|
158
|
-
|
|
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>
|
|
159
283
|
|
|
160
284
|
export type FidelityClass = 'full' | 'usage-only' | 'aggregate-only' | 'cost-only' | 'partial';
|
|
161
285
|
|
package/index.js
CHANGED
|
@@ -7,6 +7,10 @@ import {
|
|
|
7
7
|
queryToolResultEvents,
|
|
8
8
|
} from '@relayburn/ledger';
|
|
9
9
|
import {
|
|
10
|
+
aggregateByBash,
|
|
11
|
+
aggregateByBashVerb,
|
|
12
|
+
aggregateByFile,
|
|
13
|
+
aggregateBySubagent,
|
|
10
14
|
attributeOverhead,
|
|
11
15
|
buildCompareTable,
|
|
12
16
|
buildGhostSurfaceInputs,
|
|
@@ -36,7 +40,7 @@ import {
|
|
|
36
40
|
userClaudeSettingsPath,
|
|
37
41
|
} from '@relayburn/analyze';
|
|
38
42
|
import { ingestAll } from '@relayburn/ingest';
|
|
39
|
-
import { resolveProject } from '@relayburn/reader';
|
|
43
|
+
import { parseBashCommand, resolveProject } from '@relayburn/reader';
|
|
40
44
|
import { readFile } from 'node:fs/promises';
|
|
41
45
|
import * as path from 'node:path';
|
|
42
46
|
|
|
@@ -228,59 +232,261 @@ export async function sessionCost(opts = {}) {
|
|
|
228
232
|
});
|
|
229
233
|
}
|
|
230
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.
|
|
231
268
|
export async function hotspots(opts = {}) {
|
|
232
269
|
return withHome(opts.ledgerHome, async () => {
|
|
233
|
-
const
|
|
234
|
-
|
|
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);
|
|
235
290
|
const pricing = await loadPricing();
|
|
236
|
-
const userTurnsBySession = bucketBySession(userTurns);
|
|
237
|
-
const attribution = attributeHotspots(turns, { pricing, userTurnsBySession });
|
|
238
291
|
|
|
239
|
-
if (
|
|
292
|
+
if (usingPatterns) {
|
|
293
|
+
return runHotspotsFindings(turns, pricing, opts, q);
|
|
294
|
+
}
|
|
240
295
|
|
|
241
|
-
|
|
242
|
-
|
|
296
|
+
return runHotspotsAttribution(turns, pricing, opts, q);
|
|
297
|
+
});
|
|
298
|
+
}
|
|
243
299
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
+
}
|
|
250
307
|
|
|
251
|
-
|
|
252
|
-
// own slice of state, so we run them lazily based on `wanted`.
|
|
253
|
-
|
|
254
|
-
if (wanted.has('tool-output-bloat')) {
|
|
255
|
-
const settings = [];
|
|
256
|
-
const userLoaded = await loadClaudeSettings(userClaudeSettingsPath());
|
|
257
|
-
if (userLoaded) settings.push(userLoaded);
|
|
258
|
-
const projectLoaded = await loadClaudeSettings(projectClaudeSettingsPath());
|
|
259
|
-
if (projectLoaded) settings.push(projectLoaded);
|
|
260
|
-
const toolResultEvents = await queryToolResultEvents({ sessionId: opts.session });
|
|
261
|
-
const bloats = detectToolOutputBloat({
|
|
262
|
-
settings,
|
|
263
|
-
toolResultEvents,
|
|
264
|
-
userTurns,
|
|
265
|
-
turns,
|
|
266
|
-
pricing,
|
|
267
|
-
});
|
|
268
|
-
for (const b of bloats) findings.push(toolOutputBloatToFinding(b));
|
|
269
|
-
}
|
|
308
|
+
const fidelitySummary = summarizeFidelity(turns);
|
|
270
309
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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 };
|
|
275
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
|
+
}
|
|
276
341
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
+
}
|
|
281
366
|
|
|
282
|
-
|
|
283
|
-
|
|
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;
|
|
284
490
|
}
|
|
285
491
|
|
|
286
492
|
function bucketBySession(userTurns) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@relayburn/sdk",
|
|
3
|
-
"version": "1.
|
|
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,10 +16,10 @@
|
|
|
16
16
|
"node": ">=22"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@relayburn/analyze": "1.
|
|
20
|
-
"@relayburn/ingest": "1.
|
|
21
|
-
"@relayburn/ledger": "1.
|
|
22
|
-
"@relayburn/reader": "1.
|
|
19
|
+
"@relayburn/analyze": "1.10.0",
|
|
20
|
+
"@relayburn/ingest": "1.10.0",
|
|
21
|
+
"@relayburn/ledger": "1.10.0",
|
|
22
|
+
"@relayburn/reader": "1.10.0"
|
|
23
23
|
},
|
|
24
24
|
"repository": {
|
|
25
25
|
"type": "git",
|