@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 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 `overheadTrim` 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.
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 a
153
- * findings array.
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
- 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>
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 turns = await queryAll({ sessionId: opts.session });
234
- const userTurns = await queryUserTurns({ sessionId: opts.session });
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 (!opts.patterns || opts.patterns.length === 0) return attribution;
292
+ if (usingPatterns) {
293
+ return runHotspotsFindings(turns, pricing, opts, q);
294
+ }
240
295
 
241
- const wanted = new Set(opts.patterns);
242
- const findings = [];
296
+ return runHotspotsAttribution(turns, pricing, opts, q);
297
+ });
298
+ }
243
299
 
244
- // Core patterns (retries, failures, edit-heavy, etc.) flow through
245
- // detectPatterns + findingsFromPatterns; non-matching kinds are filtered.
246
- const detected = detectPatterns(turns, { pricing, userTurnsBySession });
247
- for (const f of findingsFromPatterns(detected)) {
248
- if (wanted.has(f.kind)) findings.push(f);
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
- // Side-channel detectors live outside detectPatterns. Each one reads its
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
- if (wanted.has('ghost-surface')) {
272
- const ghostInputs = await buildGhostSurfaceInputs(turns, pricing);
273
- const ghosts = await detectGhostSurface(ghostInputs);
274
- for (const g of ghosts) findings.push(ghostSurfaceToFinding(g));
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
- if (wanted.has('tool-call-pattern')) {
278
- const patterns = detectToolCallPatterns(turns, { pricing });
279
- for (const p of patterns) findings.push(toolCallPatternToFinding(p));
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
- return findings;
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.9.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,10 +16,10 @@
16
16
  "node": ">=22"
17
17
  },
18
18
  "dependencies": {
19
- "@relayburn/analyze": "1.9.0",
20
- "@relayburn/ingest": "1.9.0",
21
- "@relayburn/ledger": "1.9.0",
22
- "@relayburn/reader": "1.9.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"
23
23
  },
24
24
  "repository": {
25
25
  "type": "git",