@khorsheed/dsh-context-lens 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.en.md +53 -0
  3. package/README.i18n.yaml +6 -0
  4. package/README.md +52 -2
  5. package/cordis.patch.yml +13 -0
  6. package/lib/client.js +26953 -0
  7. package/lib/index.js +3943 -0
  8. package/lib/typert.host.d.ts +3 -0
  9. package/lib/typert.host.js +1569 -0
  10. package/lib/typert.remote-client.d.ts +40 -0
  11. package/lib/typert.remote-client.js +1202 -0
  12. package/lib/types/changes.d.ts +72 -0
  13. package/lib/types/changes.js +328 -0
  14. package/lib/types/client/AgentTabs.d.ts +34 -0
  15. package/lib/types/client/AgentTabs.js +89 -0
  16. package/lib/types/client/ChangeList.d.ts +28 -0
  17. package/lib/types/client/ChangeList.js +109 -0
  18. package/lib/types/client/ConfigFacts.d.ts +21 -0
  19. package/lib/types/client/ConfigFacts.js +30 -0
  20. package/lib/types/client/HeaderUtility.d.ts +12 -0
  21. package/lib/types/client/HeaderUtility.js +8 -0
  22. package/lib/types/client/InspectorPanel.d.ts +5 -0
  23. package/lib/types/client/InspectorPanel.js +486 -0
  24. package/lib/types/client/RawView.d.ts +18 -0
  25. package/lib/types/client/RawView.js +8 -0
  26. package/lib/types/client/RequestFacts.d.ts +19 -0
  27. package/lib/types/client/RequestFacts.js +39 -0
  28. package/lib/types/client/RoomDecisionsView.d.ts +24 -0
  29. package/lib/types/client/RoomDecisionsView.js +52 -0
  30. package/lib/types/client/SchemaFields.d.ts +13 -0
  31. package/lib/types/client/SchemaFields.js +96 -0
  32. package/lib/types/client/SegCtl.d.ts +32 -0
  33. package/lib/types/client/SegCtl.js +12 -0
  34. package/lib/types/client/SegmentBlocks.d.ts +28 -0
  35. package/lib/types/client/SegmentBlocks.js +225 -0
  36. package/lib/types/client/TimingView.d.ts +17 -0
  37. package/lib/types/client/TimingView.js +98 -0
  38. package/lib/types/client/TokenView.d.ts +21 -0
  39. package/lib/types/client/TokenView.js +22 -0
  40. package/lib/types/client/ToolCatalog.d.ts +9 -0
  41. package/lib/types/client/ToolCatalog.js +51 -0
  42. package/lib/types/client/Trend.d.ts +25 -0
  43. package/lib/types/client/Trend.js +100 -0
  44. package/lib/types/client/TurnAction.d.ts +14 -0
  45. package/lib/types/client/TurnAction.js +31 -0
  46. package/lib/types/client/TurnMenu.d.ts +36 -0
  47. package/lib/types/client/TurnMenu.js +50 -0
  48. package/lib/types/client/aggregate.d.ts +141 -0
  49. package/lib/types/client/aggregate.js +213 -0
  50. package/lib/types/client/contract.d.ts +58 -0
  51. package/lib/types/client/contract.js +1 -0
  52. package/lib/types/client/definition.d.ts +47 -0
  53. package/lib/types/client/definition.js +25 -0
  54. package/lib/types/client/icons-local.d.ts +19 -0
  55. package/lib/types/client/icons-local.js +3 -0
  56. package/lib/types/client/icons.d.ts +18 -0
  57. package/lib/types/client/icons.js +2 -0
  58. package/lib/types/client/index.d.ts +42 -0
  59. package/lib/types/client/index.js +139 -0
  60. package/lib/types/client/locales.d.ts +365 -0
  61. package/lib/types/client/locales.js +705 -0
  62. package/lib/types/client/preset-visibility.d.ts +99 -0
  63. package/lib/types/client/preset-visibility.js +108 -0
  64. package/lib/types/client/scroll-sync.d.ts +54 -0
  65. package/lib/types/client/scroll-sync.js +118 -0
  66. package/lib/types/client/timing.d.ts +29 -0
  67. package/lib/types/client/timing.js +117 -0
  68. package/lib/types/client/wire.d.ts +13 -0
  69. package/lib/types/client/wire.js +11 -0
  70. package/lib/types/index.d.ts +32 -0
  71. package/lib/types/index.js +25 -0
  72. package/lib/types/log-lane.d.ts +198 -0
  73. package/lib/types/log-lane.js +841 -0
  74. package/lib/types/messages.d.ts +96 -0
  75. package/lib/types/messages.js +254 -0
  76. package/lib/types/prefix-diff.d.ts +4 -0
  77. package/lib/types/prefix-diff.js +20 -0
  78. package/lib/types/remote.d.ts +45 -0
  79. package/lib/types/remote.js +99 -0
  80. package/lib/types/room.d.ts +66 -0
  81. package/lib/types/room.js +101 -0
  82. package/lib/types/segmenter.d.ts +100 -0
  83. package/lib/types/segmenter.js +184 -0
  84. package/lib/types/service.d.ts +145 -0
  85. package/lib/types/service.js +493 -0
  86. package/lib/types/store.d.ts +424 -0
  87. package/lib/types/store.js +1920 -0
  88. package/lib/types/tool-summary.d.ts +6 -0
  89. package/lib/types/tool-summary.js +7 -0
  90. package/lib/types/types.d.ts +703 -0
  91. package/lib/types/types.js +18 -0
  92. package/locale/en.json +6 -0
  93. package/locale/zh.json +6 -0
  94. package/package.json +133 -3
@@ -0,0 +1,1920 @@
1
+ /**
2
+ * The context-lens capture store: an in-memory ring of sessions → turns →
3
+ * agent scopes → requests, plus the correlation tables the lanes feed.
4
+ *
5
+ * Correlation model:
6
+ * - **Turns** come from `turn/start` / `turn/end` on a root session; a request
7
+ * whose `sessionId` is the root lands in the turn open at dispatch time.
8
+ * - **Aux requests** (`purpose: compaction | session-title | …`) are not
9
+ * conversation steps even when one fires mid-turn: they accumulate in a
10
+ * per-session pseudo-turn keyed by purpose, sorted into the turn list at
11
+ * first sight.
12
+ * - **Scopes**: `main` is the conversation itself; each `subagent/catalog`
13
+ * event opens a child scope pinned to the turn that delegated it. A request
14
+ * whose `sessionId` is a known child joins that scope (in-process drivers);
15
+ * fresh-process children fill the same scope from the log lane instead.
16
+ * - **Tool timing** pairs `tool/call` → `tool/result` by callId and attributes
17
+ * the interval to the request whose response issued the call (callId owner
18
+ * table, fed from tool-call stream blocks).
19
+ *
20
+ * Eviction is turn-granular (never a half turn): per-session turn cap and a
21
+ * global request cap both evict the oldest CLOSED turn; a turn holding a live
22
+ * stream is skipped, so the caps are soft while streams are open.
23
+ *
24
+ * @module @khorsheed/dsh-context-lens/store
25
+ */
26
+ import { countToolCalls } from "./tool-summary.js";
27
+ import { splitRequest, rebaseSegments } from "./segmenter.js";
28
+ import { AMBIENT_SOURCES, lastParagraph, previewLine } from "./messages.js";
29
+ import { prefixTextChange } from "./prefix-diff.js";
30
+ import { diffRequests } from "./changes.js";
31
+ import { applyRoomEvent, emptyRoom, roomTurnSeq, runsOf } from "./room.js";
32
+ /** Replacement facts kept per session; older facts only matter for requests long evicted. */
33
+ const REPLACEMENT_CAP = 2048;
34
+ const DEFAULT_MAX_TURNS_PER_SESSION = 30;
35
+ const DEFAULT_MAX_BACKFILL_REQUESTS_PER_SESSION = 500;
36
+ const DEFAULT_MAX_STORED_CHARS_TOTAL = 150_000_000;
37
+ /**
38
+ * Whether one scope passes a read's allow-list: unfiltered when the filter
39
+ * carries no `scopes`, otherwise membership. Shared by the store's query
40
+ * methods and the service's child-log gate.
41
+ */
42
+ export function scopeAuthorized(scopeId, filter) {
43
+ return filter?.scopes === undefined || filter.scopes.includes(scopeId);
44
+ }
45
+ /** The scope's fidelity marker for the wire; absent while it holds no captured content. */
46
+ function scopeOrigin(scope) {
47
+ if (scope.logFilled)
48
+ return 'log-reconstructed';
49
+ if (scope.live || scope.requests.length > 0)
50
+ return 'live';
51
+ return undefined;
52
+ }
53
+ /** The in-memory capture store. All mutation enters through the `note*`/`observe*` methods. */
54
+ export class InspectorStore {
55
+ sessions = new Map();
56
+ childToParent = new Map();
57
+ /** A session opened on its own that its log header names as a child (`parentSession`). */
58
+ parents = new Map();
59
+ /** callId → the request whose response issued the call (exec attribution). */
60
+ callOwners = new Map();
61
+ callNames = new Map();
62
+ /** Recently observed rendered assemblies (system-section attribution). */
63
+ assemblies = [];
64
+ now;
65
+ onTurnEvicted;
66
+ maxTurnsPerSession;
67
+ maxBackfillRequestsPerSession;
68
+ maxStoredCharsTotal;
69
+ /** The instance epoch stamped on every query result (restart invalidation). */
70
+ generation;
71
+ seqCounter = 0;
72
+ /** Stored segment text characters across all sessions (the memory driver). */
73
+ totalChars = 0;
74
+ constructor(config = {}, deps = {}) {
75
+ this.maxTurnsPerSession = config.maxTurnsPerSession ?? DEFAULT_MAX_TURNS_PER_SESSION;
76
+ this.maxBackfillRequestsPerSession = config.maxBackfillRequestsPerSession ?? DEFAULT_MAX_BACKFILL_REQUESTS_PER_SESSION;
77
+ this.maxStoredCharsTotal = config.maxStoredCharsTotal ?? DEFAULT_MAX_STORED_CHARS_TOTAL;
78
+ this.now = deps.now ?? Date.now;
79
+ this.onTurnEvicted = deps.onTurnEvicted;
80
+ this.generation = deps.generation ?? `${this.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
81
+ }
82
+ /** The shared assembly ring (service feeds it from the assemble waterfall). */
83
+ get assemblyRing() {
84
+ return this.assemblies;
85
+ }
86
+ /** Whether `sessionId` is a known subagent child of some captured session. */
87
+ isChild(sessionId) {
88
+ return this.childToParent.has(sessionId);
89
+ }
90
+ /** The root session one request/event belongs to (children resolve up). */
91
+ rootOf(sessionId) {
92
+ let cursor = sessionId;
93
+ const seen = new Set();
94
+ while (this.childToParent.has(cursor) && !seen.has(cursor)) {
95
+ seen.add(cursor);
96
+ cursor = this.childToParent.get(cursor);
97
+ }
98
+ return cursor;
99
+ }
100
+ session(id) {
101
+ let record = this.sessions.get(id);
102
+ if (record === undefined) {
103
+ record = {
104
+ id,
105
+ turns: [],
106
+ activeCompactions: new Map(),
107
+ prunings: new Map(),
108
+ replacements: new Map(),
109
+ lastAccess: this.now(),
110
+ turnSpans: new Map(),
111
+ replacedBy: new Map(),
112
+ openTurn: undefined,
113
+ buckets: new Map(),
114
+ nextBucketSeq: -1,
115
+ nextScopeSeq: 1,
116
+ backfillState: 'none',
117
+ backfillReason: undefined,
118
+ backfilledRequests: 0,
119
+ pendingCalls: new Map(),
120
+ messageTurns: new Map(),
121
+ messageDurations: new Map(),
122
+ evictedTurns: 0,
123
+ nextRequestNumbers: new Map(),
124
+ };
125
+ this.sessions.set(id, record);
126
+ }
127
+ return record;
128
+ }
129
+ /** Allocate one turn row; the caller sets the address (`turnSeq`) in `init`. */
130
+ mintTurn(session, at, init) {
131
+ const turn = {
132
+ liveContent: false,
133
+ query: '',
134
+ startedAt: at,
135
+ scopes: new Map(),
136
+ ...init,
137
+ };
138
+ session.turns.push(turn);
139
+ return turn;
140
+ }
141
+ mainScope(session, turn, at) {
142
+ let scope = turn.scopes.get('main');
143
+ if (scope === undefined) {
144
+ scope = {
145
+ scopeId: 'main',
146
+ scopeSeq: session.nextScopeSeq,
147
+ kind: 'main',
148
+ status: 'running',
149
+ config: {},
150
+ requests: [],
151
+ startedAt: at,
152
+ live: false,
153
+ logFilled: false,
154
+ };
155
+ session.nextScopeSeq += 1;
156
+ turn.scopes.set('main', scope);
157
+ }
158
+ return scope;
159
+ }
160
+ /** Observe durable compression boundaries without manufacturing model calls. */
161
+ noteCompaction(sessionId, type, data) {
162
+ const session = this.session(this.rootOf(sessionId));
163
+ const id = data['compactionId'];
164
+ if (typeof id !== 'string')
165
+ return;
166
+ if (type === 'compaction/start')
167
+ session.activeCompactions.set(sessionId, { id, ...typeof data['turn'] === 'number' ? { hostTurn: data['turn'] } : {} });
168
+ if (type === 'compaction/end')
169
+ session.activeCompactions.delete(sessionId);
170
+ if (type === 'compaction/summary' || type === 'compaction/applied') {
171
+ for (const turn of session.turns)
172
+ for (const scope of turn.scopes.values())
173
+ for (const request of scope.requests) {
174
+ if (request.compaction?.id !== id)
175
+ continue;
176
+ if (Array.isArray(data['shadowedSeqs']))
177
+ request.compaction.replacedCount = data['shadowedSeqs'].length;
178
+ if (type === 'compaction/applied')
179
+ request.compaction.applied = true;
180
+ }
181
+ }
182
+ }
183
+ /** Preserve pruning metadata across later requests, with bounded observation state. */
184
+ notePruning(sessionId, messageId, beforeChars, afterChars) {
185
+ const state = this.session(this.rootOf(sessionId)).prunings;
186
+ const key = `${sessionId}:${messageId}`;
187
+ if (afterChars >= beforeChars && !state.has(key))
188
+ return;
189
+ state.set(key, { beforeChars: state.get(key)?.beforeChars ?? beforeChars, afterChars });
190
+ if (state.size > 512)
191
+ state.delete(state.keys().next().value);
192
+ }
193
+ /** Record one host surface replacement (live event or log backfill) for the change list. */
194
+ noteReplacement(sessionId, fact) {
195
+ const session = this.session(this.rootOf(sessionId));
196
+ session.replacements.set(fact.newId, fact);
197
+ for (const id of fact.replacedIds)
198
+ if (id !== fact.newId)
199
+ session.replacedBy.set(id, fact);
200
+ while (session.replacements.size > REPLACEMENT_CAP)
201
+ session.replacements.delete(session.replacements.keys().next().value);
202
+ while (session.replacedBy.size > REPLACEMENT_CAP * 4)
203
+ session.replacedBy.delete(session.replacedBy.keys().next().value);
204
+ }
205
+ /**
206
+ * Begin one captured request: correlate session/turn/scope, split the
207
+ * segments against the scope's previous fingerprint, assign the sequence.
208
+ *
209
+ * @returns the stored row, or `undefined` when the request carries no
210
+ * session identity the capture can place (a host call with no sessionId
211
+ * and no purpose is out of the panel's scope).
212
+ */
213
+ beginRequest(snapshot, startedAt) {
214
+ if (snapshot.sessionId === undefined)
215
+ return undefined;
216
+ const rootId = this.rootOf(snapshot.sessionId);
217
+ const session = this.session(rootId);
218
+ const isChild = snapshot.sessionId !== rootId;
219
+ const compaction = snapshot.purpose === 'compaction' ? session.activeCompactions.get(snapshot.sessionId) : undefined;
220
+ let turn;
221
+ let scopeId;
222
+ if (isChild) {
223
+ // In-process child: the scope was opened by the catalog event; find it.
224
+ // A scope the log lane already filled stays log-sourced (no double count).
225
+ const found = this.findScope(session, snapshot.sessionId);
226
+ if (found === undefined || found.scope.logFilled)
227
+ return undefined;
228
+ turn = found.turn;
229
+ scopeId = found.scope.scopeId;
230
+ found.scope.live = true;
231
+ }
232
+ else if (snapshot.purpose !== undefined || session.openTurn === undefined) {
233
+ // Aux requests (`compaction`, `session-title`, …) are not conversation
234
+ // steps even when one fires mid-turn: they accumulate in the per-session
235
+ // pseudo-turn keyed by purpose. A purpose-less request with no open turn
236
+ // lands in the same kind of bucket under 'ad-hoc' (and a turn/end that
237
+ // later closes a pre-mount turn drains it — see turnEnd).
238
+ const purpose = snapshot.purpose ?? 'ad-hoc';
239
+ const bucketKey = compaction ? `${purpose}:${compaction.id}` : purpose;
240
+ let bucket = session.buckets.get(bucketKey);
241
+ if (bucket === undefined) {
242
+ bucket = this.mintTurn(session, startedAt, {
243
+ purpose,
244
+ query: purpose,
245
+ turnSeq: session.nextBucketSeq,
246
+ });
247
+ session.nextBucketSeq -= 1;
248
+ session.buckets.set(bucketKey, bucket);
249
+ }
250
+ turn = bucket;
251
+ scopeId = 'main';
252
+ }
253
+ else {
254
+ turn = session.openTurn;
255
+ scopeId = 'main';
256
+ }
257
+ const scope = scopeId === 'main'
258
+ ? this.mainScope(session, turn, startedAt)
259
+ : turn.scopes.get(scopeId);
260
+ if (scope === undefined)
261
+ return undefined;
262
+ // UI scopes belong to turns; the main agent's input history does not reset there.
263
+ const previous = scope.previous ?? (snapshot.purpose === undefined
264
+ ? this.previousRequest(session, scopeId, startedAt, this.seqCounter)?.fingerprint : undefined);
265
+ const segments = splitRequest({
266
+ messages: snapshot.messages.map(message => {
267
+ const pruning = message.id === undefined ? undefined : session.prunings.get(`${snapshot.sessionId}:${message.id}`);
268
+ return pruning === undefined ? message : { ...message, pruning };
269
+ }),
270
+ ...snapshot.systemSlot === undefined ? {} : { systemSlot: snapshot.systemSlot },
271
+ ...snapshot.tools === undefined ? {} : { tools: snapshot.tools },
272
+ context: {
273
+ ...previous === undefined || snapshot.purpose === 'compaction' ? {} : { previous },
274
+ assemblies: this.assemblies,
275
+ resolveToolName: callId => this.callNames.get(callId),
276
+ durationFor: messageId => session.messageDurations.get(messageId),
277
+ turnFor: messageId => session.messageTurns.get(messageId),
278
+ },
279
+ });
280
+ if (snapshot.purpose !== 'compaction')
281
+ scope.previous = segments.fingerprint;
282
+ const request = {
283
+ seq: this.seqCounter,
284
+ sessionId: rootId,
285
+ scopeId: scope.scopeId,
286
+ turnSeq: turn.turnSeq,
287
+ ...snapshot.purpose === undefined ? {} : { purpose: snapshot.purpose },
288
+ ...compaction === undefined ? {} : { compaction: { ...compaction } },
289
+ startedAt,
290
+ status: 'streaming',
291
+ config: snapshot.config,
292
+ responseSummary: { kind: snapshot.purpose === 'compaction' ? 'compaction' : 'none' },
293
+ segments: {
294
+ system: segments.system,
295
+ tools: segments.tools,
296
+ history: segments.history,
297
+ added: segments.added,
298
+ },
299
+ totalChars: segments.totalChars,
300
+ blocks: [],
301
+ execs: [],
302
+ fingerprint: segments.fingerprint,
303
+ ...snapshot.purpose === undefined && previous === undefined ? { needsHistoryBaseline: true } : {},
304
+ };
305
+ this.seqCounter += 1;
306
+ // Auxiliary/purpose calls have no complete conversation-history baseline.
307
+ const nextNumber = snapshot.purpose === undefined ? session.nextRequestNumbers.get(scopeId) : undefined;
308
+ if (nextNumber !== undefined) {
309
+ request.requestNumber = nextNumber;
310
+ session.nextRequestNumbers.set(scopeId, nextNumber + 1);
311
+ }
312
+ scope.requests.push(request);
313
+ turn.liveContent = true;
314
+ this.totalChars += request.totalChars;
315
+ if (scope.config.provider === undefined && snapshot.config.provider !== undefined) {
316
+ scope.config = snapshot.config;
317
+ }
318
+ this.enforceCaps();
319
+ return request;
320
+ }
321
+ /** Attach a purpose-built (log-lane) request row to an existing child scope. */
322
+ appendLogRequest(rootSessionId, scopeId, built) {
323
+ const session = this.sessions.get(rootSessionId);
324
+ if (session === undefined)
325
+ return undefined;
326
+ const found = this.findScope(session, scopeId);
327
+ if (found === undefined || found.scope.live)
328
+ return undefined;
329
+ found.scope.logFilled = true;
330
+ const request = {
331
+ ...built,
332
+ seq: this.seqCounter,
333
+ sessionId: rootSessionId,
334
+ scopeId,
335
+ turnSeq: found.turn.turnSeq,
336
+ };
337
+ this.seqCounter += 1;
338
+ found.scope.requests.push(request);
339
+ if (built.purpose === undefined)
340
+ found.scope.previous = built.fingerprint;
341
+ this.totalChars += request.totalChars;
342
+ this.attachResponse(request, built.response);
343
+ this.accountExecutionResults(request);
344
+ this.enforceCaps();
345
+ return request;
346
+ }
347
+ findScope(session, scopeId) {
348
+ for (const turn of session.turns) {
349
+ const scope = turn.scopes.get(scopeId);
350
+ if (scope !== undefined)
351
+ return { turn, scope };
352
+ }
353
+ return undefined;
354
+ }
355
+ /** One stream chunk observed on a live request's tee. */
356
+ observeChunk(request, chunk, at) {
357
+ if (request.firstChunkAt === undefined)
358
+ request.firstChunkAt = at;
359
+ switch (chunk.type) {
360
+ case 'block-start': {
361
+ if (chunk.index === undefined)
362
+ return;
363
+ request.blocks.push({
364
+ kind: chunk.blockType ?? 'unknown',
365
+ index: chunk.index,
366
+ start: at,
367
+ });
368
+ return;
369
+ }
370
+ case 'text-delta':
371
+ case 'reasoning-delta': {
372
+ // Delta-accumulate so a cancelled mid-block response stays partially visible.
373
+ const span = request.blocks.find(block => block.index === chunk.index && block.end === undefined);
374
+ if (span === undefined || chunk.text === undefined)
375
+ return;
376
+ span.text = (span.text ?? '') + chunk.text;
377
+ return;
378
+ }
379
+ case 'tool-call-delta': {
380
+ const span = request.blocks.find(block => block.index === chunk.index && block.end === undefined);
381
+ if (span === undefined || chunk.argumentsDelta === undefined)
382
+ return;
383
+ span.args = (span.args ?? '') + chunk.argumentsDelta;
384
+ return;
385
+ }
386
+ case 'block-end': {
387
+ const span = request.blocks.find(block => block.index === chunk.index && block.end === undefined);
388
+ if (span === undefined)
389
+ return;
390
+ span.end = at;
391
+ // The assembled block is authoritative over the accumulated deltas.
392
+ if (chunk.block?.text !== undefined)
393
+ span.text = chunk.block.text;
394
+ if (chunk.block?.type === 'tool-call') {
395
+ if (chunk.block.name !== undefined)
396
+ span.toolName = chunk.block.name;
397
+ if (chunk.block.id !== undefined) {
398
+ span.callId = chunk.block.id;
399
+ this.callOwners.set(chunk.block.id, request);
400
+ if (chunk.block.name !== undefined)
401
+ this.callNames.set(chunk.block.id, chunk.block.name);
402
+ }
403
+ if (chunk.block.arguments !== undefined)
404
+ span.args = chunk.block.arguments;
405
+ }
406
+ return;
407
+ }
408
+ case 'usage':
409
+ if (chunk.usage !== undefined)
410
+ request.usage = chunk.usage;
411
+ return;
412
+ case 'finish':
413
+ if (chunk.reason !== undefined)
414
+ request.finishKind = chunk.reason.kind;
415
+ if (chunk.reason?.kind === 'error' || chunk.reason?.kind === 'aborted') {
416
+ request.error = chunk.reason.failure?.message ?? chunk.reason.kind;
417
+ }
418
+ return;
419
+ default:
420
+ }
421
+ }
422
+ /** Settle one request: ok on a clean finish, failed on an error finish or a raised error. */
423
+ settleRequest(request, at, thrown) {
424
+ if (request.status !== 'streaming')
425
+ return;
426
+ request.settledAt = at;
427
+ if (thrown !== undefined) {
428
+ request.status = 'failed';
429
+ request.error = thrown instanceof Error ? thrown.message : String(thrown);
430
+ }
431
+ else if (request.error !== undefined) {
432
+ request.status = 'failed';
433
+ }
434
+ else if (request.finishKind === undefined) {
435
+ // The stream closed without a finish chunk (consumer abandoned it).
436
+ request.status = 'failed';
437
+ request.error = 'stream closed before its finish chunk';
438
+ }
439
+ else {
440
+ request.status = 'ok';
441
+ }
442
+ request.responseSummary = request.purpose === 'compaction' ? { kind: 'compaction' } : summarizeResponse(request);
443
+ this.attachResponse(request, responseFromSpans(request.blocks));
444
+ const session = this.sessions.get(request.sessionId);
445
+ if (session !== undefined && request.purpose === undefined) {
446
+ session.lastSettledMain = request;
447
+ }
448
+ }
449
+ /** Set the request's response and count its chars into the store budget. */
450
+ attachResponse(request, response) {
451
+ if (response === undefined)
452
+ return;
453
+ request.response = response;
454
+ const chars = responseSize(response);
455
+ request.responseChars = chars;
456
+ this.totalChars += chars;
457
+ }
458
+ accountExecutionResults(request) {
459
+ const chars = executionSize(request);
460
+ this.totalChars += chars - (request.executionChars ?? 0);
461
+ request.executionChars = chars;
462
+ }
463
+ /** Incremental child folds mutate shared execution spans after insertion. */
464
+ refreshExecutionResults(sessionId, scopeId) {
465
+ const session = this.sessions.get(sessionId);
466
+ const scope = session && this.findScope(session, scopeId)?.scope;
467
+ for (const request of scope?.requests ?? [])
468
+ this.accountExecutionResults(request);
469
+ this.enforceCaps();
470
+ }
471
+ /** turn/start on a root session (child sessions route their events to the parent instead). */
472
+ /** @param seq - the `turn/start` event's log seq (the chat's history-jump target). */
473
+ turnStart(sessionId, hostTurn, at, seq) {
474
+ const rootId = this.rootOf(sessionId);
475
+ const session = this.session(rootId);
476
+ const scopeId = sessionId === rootId ? 'main' : sessionId;
477
+ if (hostTurn === 1 && !session.nextRequestNumbers.has(scopeId))
478
+ session.nextRequestNumbers.set(scopeId, 1);
479
+ if (this.isChild(sessionId))
480
+ return;
481
+ if (!session.turnSpans.has(hostTurn))
482
+ session.turnSpans.set(hostTurn, { start: at });
483
+ if (session.logDriven) {
484
+ session.logDirty = true;
485
+ return;
486
+ }
487
+ if (session.openTurn !== undefined)
488
+ return;
489
+ // The host turn number is the canonical address: a turn the log lane
490
+ // already reconstructed reopens here rather than duplicating (defensive —
491
+ // host numbers never repeat within one process).
492
+ const existing = session.turns.find(turn => turn.hostTurn === hostTurn && turn.purpose === undefined);
493
+ if (existing !== undefined) {
494
+ session.openTurn = existing;
495
+ if (seq !== undefined)
496
+ existing.startSeq = seq;
497
+ return;
498
+ }
499
+ session.openTurn = this.mintTurn(session, at, { hostTurn, turnSeq: hostTurn, liveContent: true, liveStartObserved: true });
500
+ if (seq !== undefined)
501
+ session.openTurn.startSeq = seq;
502
+ }
503
+ /** turn/end: close the open turn, resolve its main scope, sweep abandoned streams. */
504
+ turnEnd(sessionId, hostTurn, reason, at) {
505
+ if (this.isChild(sessionId))
506
+ return;
507
+ const span = this.sessions.get(sessionId)?.turnSpans.get(hostTurn);
508
+ if (span !== undefined)
509
+ span.end = at;
510
+ const session = this.sessions.get(sessionId);
511
+ if (session === undefined)
512
+ return;
513
+ if (session.logDriven) {
514
+ session.logDirty = true;
515
+ return;
516
+ }
517
+ let turn = session.openTurn;
518
+ if (turn === undefined) {
519
+ // The plugin mounted mid-turn: the open bracket predates the capture, so
520
+ // the turn's live requests drifted into the ad-hoc bucket. Drain them
521
+ // into a record for this host turn — the overlap rule (any live request
522
+ // ⇒ fully live, the log lane never merges in) then holds for it.
523
+ const bucket = session.buckets.get('ad-hoc');
524
+ const drifted = bucket?.scopes.get('main')?.requests.filter(request => request.purpose === undefined) ?? [];
525
+ if (drifted.length === 0)
526
+ return;
527
+ turn = this.mintTurn(session, at, { hostTurn, turnSeq: hostTurn, liveContent: true });
528
+ const main = this.mainScope(session, turn, drifted[0]?.startedAt ?? at);
529
+ for (const request of drifted) {
530
+ request.turnSeq = turn.turnSeq;
531
+ main.requests.push(request);
532
+ }
533
+ const bucketScope = bucket?.scopes.get('main');
534
+ if (bucket !== undefined && bucketScope !== undefined) {
535
+ bucketScope.requests = bucketScope.requests.filter(request => !drifted.includes(request));
536
+ if (bucketScope.requests.length === 0) {
537
+ bucket.scopes.delete('main');
538
+ session.buckets.delete('ad-hoc');
539
+ session.turns = session.turns.filter(candidate => candidate !== bucket);
540
+ }
541
+ }
542
+ }
543
+ turn.endedAt = at;
544
+ session.openTurn = undefined;
545
+ const main = turn.scopes.get('main');
546
+ if (main !== undefined) {
547
+ main.status = reason.kind === 'completed' || reason.kind === 'max-tokens' ? 'ok' : 'failed';
548
+ if (main.status === 'failed') {
549
+ main.error = reason.kind === 'error' ? (reason.error?.message ?? 'turn failed') : reason.kind;
550
+ }
551
+ main.endedAt = at;
552
+ }
553
+ for (const scope of turn.scopes.values()) {
554
+ for (const request of scope.requests) {
555
+ if (request.status === 'streaming') {
556
+ this.settleRequest(request, at, new Error('stream still open at turn end'));
557
+ }
558
+ }
559
+ }
560
+ this.enforceCaps();
561
+ }
562
+ /** A human message entering the turn — the turn list's query preview. */
563
+ noteUserMessage(sessionId, sourceKind, text) {
564
+ if (this.isChild(sessionId))
565
+ return;
566
+ const turn = this.sessions.get(sessionId)?.openTurn;
567
+ if (turn === undefined || turn.query !== '')
568
+ return;
569
+ if (sourceKind !== 'user') {
570
+ if (turn.trigger === undefined && !AMBIENT_SOURCES.has(sourceKind))
571
+ turn.trigger = { source: sourceKind, text: lastParagraph(text) };
572
+ return;
573
+ }
574
+ turn.query = text.replace(/\s+/g, ' ').trim().slice(0, 120);
575
+ }
576
+ /** The turn's closing assistant message id + generation-time backfill for history rows. */
577
+ noteAssistantMessage(sessionId, messageId) {
578
+ const session = this.sessions.get(this.rootOf(sessionId));
579
+ if (session === undefined)
580
+ return;
581
+ if (!this.isChild(sessionId) && session.openTurn !== undefined) {
582
+ session.openTurn.closingMessageId = messageId;
583
+ }
584
+ const producer = session.lastSettledMain;
585
+ if (producer !== undefined) {
586
+ const total = producer.settledAt === undefined ? undefined : producer.settledAt - producer.startedAt;
587
+ if (total !== undefined)
588
+ session.messageDurations.set(messageId, total);
589
+ }
590
+ }
591
+ /** tool/call: open the exec interval (when the issuing request is known) and queue delegation binding. */
592
+ toolCall(sessionId, callId, name, args, at) {
593
+ const rootId = this.rootOf(sessionId);
594
+ const session = this.session(rootId);
595
+ this.callNames.set(callId, name);
596
+ const owner = this.callOwners.get(callId);
597
+ if (owner !== undefined) {
598
+ owner.execs.push({ callId, name, start: at });
599
+ }
600
+ if (isDelegationTool(name)) {
601
+ const pending = { callId, name, at, ...delegationLabel(args) };
602
+ session.pendingCalls.set(callId, pending);
603
+ }
604
+ }
605
+ /** Source turns belong to session events, not the immutable message payload. */
606
+ noteMessageTurn(sessionId, messageId, turn) {
607
+ const root = this.childToParent.get(sessionId) ?? sessionId;
608
+ const session = this.sessions.get(root);
609
+ const value = turn ?? session?.openTurn?.hostTurn;
610
+ if (session && value !== undefined)
611
+ session.messageTurns.set(messageId, value);
612
+ }
613
+ /** tool/result: close the exec interval; resolve a delegation scope's outcome. */
614
+ toolResult(sessionId, callId, isError, result, at) {
615
+ const rootId = this.rootOf(sessionId);
616
+ const session = this.sessions.get(rootId);
617
+ const owner = this.callOwners.get(callId);
618
+ if (owner !== undefined) {
619
+ const span = owner.execs.find(exec => exec.callId === callId && exec.end === undefined);
620
+ if (span !== undefined) {
621
+ span.end = at;
622
+ span.isError = isError;
623
+ span.result = result;
624
+ this.accountExecutionResults(owner);
625
+ this.enforceCaps();
626
+ }
627
+ }
628
+ if (session === undefined)
629
+ return;
630
+ const pending = session.pendingCalls.get(callId);
631
+ if (pending === undefined)
632
+ return;
633
+ session.pendingCalls.delete(callId);
634
+ // A delegation call that produced no catalog event (the child never
635
+ // materialized) still opens a scope so the failed run shows with zero
636
+ // requests instead of vanishing.
637
+ const scopeId = pending.childId ?? `call:${callId}`;
638
+ const found = this.findScope(session, scopeId);
639
+ const scope = found?.scope ?? this.openChildScope(session, scopeId, pending.label, at, callId);
640
+ if (scope === undefined)
641
+ return;
642
+ scope.status = isError ? 'failed' : 'ok';
643
+ if (isError)
644
+ scope.error = previewLine(result);
645
+ scope.endedAt = at;
646
+ }
647
+ /** subagent/catalog: open the child scope on the current turn and bind the pending call. */
648
+ subagentCatalog(sessionId, childId, label, at) {
649
+ if (this.isChild(sessionId))
650
+ return;
651
+ const session = this.session(sessionId);
652
+ this.childToParent.set(childId, sessionId);
653
+ const scope = this.openChildScope(session, childId, label, at);
654
+ if (scope === undefined)
655
+ return;
656
+ for (const pending of session.pendingCalls.values()) {
657
+ if (pending.childId === undefined) {
658
+ pending.childId = childId;
659
+ scope.callId = pending.callId;
660
+ if (pending.label !== undefined && scope.label === undefined)
661
+ scope.label = pending.label;
662
+ break;
663
+ }
664
+ }
665
+ }
666
+ /** Open one child scope on the session's current turn (or latest turn as a fallback). */
667
+ openChildScope(session, scopeId, label, at, callId) {
668
+ const existing = this.findScope(session, scopeId);
669
+ if (existing !== undefined)
670
+ return existing.scope;
671
+ // A child started outside any turn (a room member a human addressed) has
672
+ // no turn of its own: it waits in the hidden holder, read by room rounds.
673
+ const turn = session.openTurn ?? this.holderTurn(session, at);
674
+ const scope = {
675
+ scopeId,
676
+ scopeSeq: session.nextScopeSeq,
677
+ kind: 'subagent',
678
+ ...label === undefined ? {} : { label },
679
+ status: 'running',
680
+ config: {},
681
+ requests: [],
682
+ startedAt: at,
683
+ live: false,
684
+ logFilled: false,
685
+ ...callId === undefined ? {} : { callId },
686
+ };
687
+ session.nextScopeSeq += 1;
688
+ turn.scopes.set(scopeId, scope);
689
+ return scope;
690
+ }
691
+ /** Every child scope of one session (the log lane iterates these). */
692
+ childScopes(sessionId) {
693
+ const session = this.sessions.get(sessionId);
694
+ if (session === undefined)
695
+ return [];
696
+ const out = [];
697
+ for (const turn of session.turns) {
698
+ for (const scope of turn.scopes.values()) {
699
+ if (scope.kind === 'subagent' && !scope.scopeId.startsWith('call:')) {
700
+ out.push({ scopeId: scope.scopeId, live: scope.live, status: scope.status });
701
+ }
702
+ }
703
+ }
704
+ return out;
705
+ }
706
+ // ---------- history backfill ----------
707
+ /** The session's backfill watermark (retryable until 'complete'). */
708
+ backfillState(sessionId) {
709
+ return this.sessions.get(sessionId)?.backfillState ?? 'none';
710
+ }
711
+ /** A durably unreadable log stops the backfill from retrying it on every query. */
712
+ markBackfillBroken(sessionId, reason) {
713
+ const session = this.session(sessionId);
714
+ session.backfillState = 'broken';
715
+ session.backfillReason = reason;
716
+ }
717
+ /**
718
+ * Insert log-reconstructed turns (the pre-mount history), newest-first within
719
+ * budget: live-content turns win the budget outright, a host turn with any
720
+ * record is never merged into (the overlap rule), and one oversized turn is
721
+ * SKIPPED without aborting the older ones. Scope ordinals are renumbered
722
+ * afterwards so the chips' `#N` stay chronological across the live/backfill
723
+ * boundary (scopeSeq is a display anchor, not an address).
724
+ *
725
+ * The watermark is honest: 'complete' only when every candidate fit; a
726
+ * budget-dropped candidate keeps the state 'partial', and the service
727
+ * re-reads the log on later queries (eviction may have freed room). The
728
+ * insert itself never evicts a live turn: char pressure evicts the oldest
729
+ * BACKFILLED turns first.
730
+ *
731
+ * @returns the number of turns actually inserted.
732
+ */
733
+ insertLogTurns(sessionId, logTurns) {
734
+ const session = this.session(sessionId);
735
+ session.lastAccess = this.now();
736
+ this.reconcileRequestNumbers(session, logTurns);
737
+ const hosts = logTurns.filter(turn => turn.purpose === undefined).map(turn => turn.hostTurn);
738
+ if (hosts.length > 0)
739
+ session.logRange = { first: Math.min(...hosts), last: Math.max(...hosts) };
740
+ for (const turn of logTurns) {
741
+ if (turn.purpose === undefined)
742
+ session.turnSpans.set(turn.hostTurn, { start: turn.startedAt, ...turn.endedAt === undefined ? {} : { end: turn.endedAt } });
743
+ }
744
+ // Children started outside any turn always load: room rounds read them whatever the window.
745
+ for (const holder of logTurns.filter(turn => turn.purpose === 'room-members')) {
746
+ const target = this.holderTurn(session, holder.startedAt);
747
+ for (const delegation of holder.delegations) {
748
+ if (delegation.childId === undefined || this.findScope(session, delegation.childId) === undefined)
749
+ this.addDelegationScope(session, target, delegation);
750
+ }
751
+ }
752
+ // A log-driven session re-reads what changed since the last read: a loaded
753
+ // turn the log has grown or closed since is rebuilt from the log.
754
+ let refreshed = 0;
755
+ if (session.logDriven && session.backfillState !== 'broken') {
756
+ const materialized = this.windowOf(session, hosts);
757
+ for (const loaded of session.turns.filter(turn => turn.purpose === undefined && turn.hostTurn !== undefined)) {
758
+ const logged = logTurns.find(turn => turn.purpose === undefined && turn.hostTurn === loaded.hostTurn);
759
+ if (logged === undefined || !materialized.has(logged.hostTurn))
760
+ continue;
761
+ const held = loaded.scopes.get('main')?.requests.length ?? 0;
762
+ if (held === logged.requests.length && loaded.endedAt === this.logTurnEnd(session, logged, logTurns))
763
+ continue;
764
+ this.evictTurn(session, loaded, false);
765
+ this.insertLogTurn(session, logged, logTurns);
766
+ refreshed += 1;
767
+ }
768
+ if (refreshed > 0)
769
+ this.renumberScopes(session);
770
+ }
771
+ const dirty = session.logDirty === true;
772
+ session.logDirty = false;
773
+ if (session.backfillState === 'broken' || (session.backfillState === 'complete' && !dirty))
774
+ return refreshed;
775
+ const keep = this.windowOf(session, hosts);
776
+ const window = logTurns.filter(turn => turn.purpose === undefined && keep.has(turn.hostTurn));
777
+ if (window.length === 0) {
778
+ session.backfillState = 'complete';
779
+ return 0;
780
+ }
781
+ const lo = Math.min(...window.map(turn => turn.hostTurn)), hi = Math.max(...window.map(turn => turn.hostTurn));
782
+ const startAt = window.find(turn => turn.hostTurn === lo).startedAt;
783
+ const nextAfter = logTurns.filter(turn => turn.purpose === undefined && turn.hostTurn > hi).sort((x, y) => x.hostTurn - y.hostTurn)[0]?.startedAt ?? Number.POSITIVE_INFINITY;
784
+ // Purpose calls (compactions) ride with the window's turns: by their host turn, else by time.
785
+ const inWindow = (turn) => {
786
+ if (turn.purpose === 'room-members')
787
+ return false;
788
+ if (turn.purpose === undefined)
789
+ return turn.hostTurn >= lo && turn.hostTurn <= hi;
790
+ const host = turn.requests[0]?.compaction?.hostTurn;
791
+ return host !== undefined ? host >= lo && host <= hi : turn.startedAt >= startAt && turn.startedAt < nextAfter;
792
+ };
793
+ const candidates = logTurns.filter(logTurn => inWindow(logTurn) &&
794
+ !session.turns.some(turn => logTurn.purpose === undefined
795
+ ? turn.purpose === undefined && turn.hostTurn === logTurn.hostTurn
796
+ : [...turn.scopes.values()].some(scope => scope.requests.some(request => request.compaction?.id === logTurn.requests[0]?.compaction?.id))));
797
+ let dropped = 0;
798
+ let inserted = 0;
799
+ // Newest-first. The loaded turns stay one contiguous run, so the unloaded
800
+ // ones are always older and paging back makes progress: the first turn
801
+ // that does not fit ends the fill — unless nothing fit yet, when it is
802
+ // skipped so one oversized turn cannot pin the window.
803
+ const stop = (i) => {
804
+ if (inserted === 0) {
805
+ dropped += 1;
806
+ return false;
807
+ }
808
+ dropped += i + 1;
809
+ return true;
810
+ };
811
+ for (let i = candidates.length - 1; i >= 0; i -= 1) {
812
+ const candidate = candidates[i];
813
+ const candidateChars = candidate.requests.reduce((n, request) => n + request.totalChars + executionSize(request) + responseSize(request.response), 0);
814
+ if (candidate.requests.length > this.maxBackfillRequestsPerSession - session.backfilledRequests) {
815
+ if (stop(i))
816
+ break;
817
+ continue;
818
+ }
819
+ // Within a session a candidate never displaces a present turn (it is
820
+ // older than anything already inserted). Across sessions, the backfill
821
+ // of the least recently read session yields its room first.
822
+ if (this.totalChars + candidateChars > this.maxStoredCharsTotal)
823
+ this.freeChars(session, candidateChars);
824
+ if (this.totalChars + candidateChars > this.maxStoredCharsTotal) {
825
+ if (stop(i))
826
+ break;
827
+ continue;
828
+ }
829
+ this.insertLogTurn(session, candidate, logTurns);
830
+ inserted += 1;
831
+ }
832
+ session.backfillState = dropped > 0 ? 'partial' : 'complete';
833
+ if (inserted > 0)
834
+ this.renumberScopes(session);
835
+ return inserted;
836
+ }
837
+ /**
838
+ * The backfill window over a log's host turns: the newest
839
+ * `maxTurnsPerSession` at or before the window's end. At the latest, live
840
+ * turns the log does not hold yet take window slots too. The log lane
841
+ * materializes only these turns' requests.
842
+ */
843
+ windowHosts(sessionId, hosts) {
844
+ return this.windowOf(this.session(sessionId), hosts);
845
+ }
846
+ windowOf(session, hosts) {
847
+ const end = session.windowEnd ?? Number.POSITIVE_INFINITY;
848
+ const logged = new Set(hosts);
849
+ const liveAhead = session.windowEnd === undefined
850
+ ? session.turns.filter(turn => turn.liveContent && turn.hostTurn !== undefined && !logged.has(turn.hostTurn)).length : 0;
851
+ return new Set([...logged].filter(host => host <= end).sort((x, y) => y - x).slice(0, Math.max(0, this.maxTurnsPerSession - liveAhead)));
852
+ }
853
+ /**
854
+ * Make room for `needed` chars by evicting other sessions' backfilled
855
+ * turns, least recently read session first, oldest turn first. Live
856
+ * content never yields. A session that lost backfill re-reads it when next
857
+ * read (its state drops back to partial).
858
+ */
859
+ freeChars(current, needed) {
860
+ const others = [...this.sessions.values()].filter(other => other !== current).sort((x, y) => x.lastAccess - y.lastAccess);
861
+ for (const other of others) {
862
+ const victims = other.turns.filter(turn => !turn.liveContent && !this.turnIsLive(turn)).sort((x, y) => x.startedAt - y.startedAt);
863
+ for (const victim of victims) {
864
+ if (this.totalChars + needed <= this.maxStoredCharsTotal)
865
+ return;
866
+ this.evictTurn(other, victim, false);
867
+ if (other.backfillState === 'complete')
868
+ other.backfillState = 'partial';
869
+ }
870
+ }
871
+ }
872
+ /**
873
+ * Move a session's backfill window: drop its backfilled turns (live turns
874
+ * stay) and let the next read refill the window ending at `end` (the
875
+ * latest turns when absent).
876
+ */
877
+ setWindow(sessionId, end) {
878
+ const session = this.session(sessionId);
879
+ if (end === undefined)
880
+ delete session.windowEnd;
881
+ else
882
+ session.windowEnd = end;
883
+ for (const turn of [...session.turns]) {
884
+ if (!turn.liveContent && !this.turnIsLive(turn))
885
+ this.evictTurn(session, turn, false);
886
+ }
887
+ if (session.backfillState !== 'broken')
888
+ session.backfillState = 'none';
889
+ }
890
+ /** A complete log can anchor live turns without retaining old request bodies. */
891
+ reconcileRequestNumbers(session, logTurns) {
892
+ for (const logged of logTurns) {
893
+ if (logged.purpose !== undefined)
894
+ continue;
895
+ const live = session.turns.find(t => t.hostTurn === logged.hostTurn && t.purpose === undefined && t.liveStartObserved);
896
+ const first = logged.requests[0]?.requestNumber;
897
+ if (!live || first === undefined)
898
+ continue;
899
+ const requests = live.scopes.get('main')?.requests ?? [];
900
+ for (let i = 0; i < requests.length; i++)
901
+ requests[i].requestNumber ??= first + i;
902
+ }
903
+ // An older partial live turn need not prevent a later, fully anchored turn
904
+ // from continuing. Only the latest observed request must have a number.
905
+ const liveTurns = session.turns.filter(t => t.liveContent && t.purpose === undefined)
906
+ .sort((a, b) => a.startedAt - b.startedAt || a.turnSeq - b.turnSeq);
907
+ const liveRequests = liveTurns.flatMap(t => t.scopes.get('main')?.requests ?? []);
908
+ if (liveRequests.length && liveRequests.at(-1)?.requestNumber === undefined)
909
+ return;
910
+ let next = session.nextRequestNumbers.get('main');
911
+ for (const r of [...logTurns.flatMap(t => t.requests), ...liveRequests]) {
912
+ if (r.requestNumber !== undefined)
913
+ next = Math.max(next ?? 1, r.requestNumber + 1);
914
+ }
915
+ if (next !== undefined)
916
+ session.nextRequestNumbers.set('main', next);
917
+ }
918
+ /** Retry numbering after a streaming turn’s first durable response becomes available. */
919
+ /**
920
+ * Make a session log-driven: an external agent's session opened on its own
921
+ * (a CLI in its own process, so no request passes through this host). Its
922
+ * turns come from its log only; the live turns opened so far held nothing
923
+ * and give way to the log's.
924
+ */
925
+ markLogDriven(sessionId) {
926
+ // Also a known child: opened on its own, its own record is what the panel reads.
927
+ const session = this.session(sessionId);
928
+ if (session.logDriven)
929
+ return;
930
+ session.logDriven = true;
931
+ session.logDirty = true;
932
+ for (const turn of [...session.turns]) {
933
+ if (turn.purpose !== undefined || !turn.liveContent)
934
+ continue;
935
+ if ([...turn.scopes.values()].some(scope => scope.requests.length > 0))
936
+ continue;
937
+ if (session.openTurn === turn)
938
+ session.openTurn = undefined;
939
+ this.evictTurn(session, turn, false);
940
+ }
941
+ }
942
+ /** Any live event of a log-driven session: the next read re-reads its log. */
943
+ noteLogActivity(sessionId) {
944
+ const session = this.sessions.get(sessionId);
945
+ if (session?.logDriven)
946
+ session.logDirty = true;
947
+ }
948
+ /** Whether a read should re-read a log-driven session's log. */
949
+ needsLogRead(sessionId) {
950
+ return this.sessions.get(sessionId)?.logDirty === true;
951
+ }
952
+ /** A log turn's end as the store records it: still open when it is a log-driven session's last turn. */
953
+ logTurnEnd(session, logTurn, logTurns) {
954
+ if (this.logTurnOpen(session, logTurn, logTurns))
955
+ return undefined;
956
+ const lastSettled = logTurn.requests.reduce((latest, request) => request.settledAt !== undefined && (latest === undefined || request.settledAt > latest)
957
+ ? request.settledAt
958
+ : latest, undefined);
959
+ return logTurn.endedAt ?? lastSettled ?? logTurn.startedAt;
960
+ }
961
+ logTurnOpen(session, logTurn, logTurns) {
962
+ if (!session.logDriven || logTurn.endReasonKind !== undefined || logTurn.purpose !== undefined)
963
+ return false;
964
+ return !logTurns.some(turn => turn.purpose === undefined && turn.hostTurn > logTurn.hostTurn);
965
+ }
966
+ needsRequestNumbers(sessionId) {
967
+ return this.sessions.get(sessionId)?.turns.some(t => t.liveStartObserved && (t.scopes.get('main')?.requests.some(r => r.purpose === undefined && r.requestNumber === undefined) ?? false)) ?? false;
968
+ }
969
+ /** One reconstructed turn → one TurnRecord (main scope + delegation stubs). */
970
+ insertLogTurn(session, logTurn, logTurns = []) {
971
+ const turn = this.mintTurn(session, logTurn.startedAt, {
972
+ ...logTurn.purpose === undefined ? { hostTurn: logTurn.hostTurn } : { purpose: logTurn.purpose },
973
+ turnSeq: logTurn.purpose === undefined ? logTurn.hostTurn : session.nextBucketSeq--,
974
+ query: logTurn.query,
975
+ });
976
+ if (logTurn.trigger !== undefined)
977
+ turn.trigger = logTurn.trigger;
978
+ if (logTurn.startSeq !== undefined)
979
+ turn.startSeq = logTurn.startSeq;
980
+ if (logTurn.closingMessageId !== undefined)
981
+ turn.closingMessageId = logTurn.closingMessageId;
982
+ // A turn the log never closed (a crash) reads as failed/interrupted, and
983
+ // still gets an end time so eviction treats it as closed — except a
984
+ // log-driven session's last turn, which is still running.
985
+ const open = this.logTurnOpen(session, logTurn, logTurns);
986
+ const end = this.logTurnEnd(session, logTurn, logTurns);
987
+ if (end !== undefined)
988
+ turn.endedAt = end;
989
+ const mainStatus = open ? 'running'
990
+ : logTurn.endReasonKind === 'completed' || logTurn.endReasonKind === 'max-tokens' ? 'ok' : 'failed';
991
+ const main = {
992
+ scopeId: 'main',
993
+ scopeSeq: 0, // renumbered after the insert
994
+ kind: 'main',
995
+ status: mainStatus,
996
+ // The scope's config line comes from its first request's header.
997
+ config: logTurn.requests[0]?.config ?? {},
998
+ requests: [],
999
+ startedAt: logTurn.startedAt,
1000
+ ...turn.endedAt === undefined ? {} : { endedAt: turn.endedAt },
1001
+ live: false,
1002
+ logFilled: true,
1003
+ };
1004
+ if (mainStatus === 'failed') {
1005
+ main.error = logTurn.endReasonKind === undefined
1006
+ ? 'interrupted (no turn/end in the log)'
1007
+ : (logTurn.endError ?? logTurn.endReasonKind);
1008
+ }
1009
+ turn.scopes.set('main', main);
1010
+ for (const built of logTurn.requests) {
1011
+ const request = {
1012
+ ...built,
1013
+ seq: this.seqCounter,
1014
+ sessionId: session.id,
1015
+ scopeId: 'main',
1016
+ turnSeq: turn.turnSeq,
1017
+ };
1018
+ this.seqCounter += 1;
1019
+ main.requests.push(request);
1020
+ this.totalChars += request.totalChars;
1021
+ session.backfilledRequests += 1;
1022
+ this.attachResponse(request, built.response);
1023
+ this.accountExecutionResults(request);
1024
+ }
1025
+ for (const delegation of logTurn.delegations)
1026
+ this.addDelegationScope(session, turn, delegation);
1027
+ }
1028
+ /** One reconstructed delegation → its child scope on `turn`. */
1029
+ addDelegationScope(session, turn, delegation) {
1030
+ const scopeId = delegation.childId ?? `call:${delegation.callId}`;
1031
+ if (delegation.childId !== undefined)
1032
+ this.childToParent.set(delegation.childId, session.id);
1033
+ const label = delegation.catalogLabel ?? delegation.label;
1034
+ const scope = {
1035
+ scopeId,
1036
+ scopeSeq: 0,
1037
+ kind: 'subagent',
1038
+ ...label === undefined ? {} : { label },
1039
+ // A catalog-only child has no tool/result to judge it by; no evidence of failure is not failure.
1040
+ status: delegation.callId === '' ? 'ok' : delegation.isError === true || delegation.resultAt === undefined ? 'failed' : 'ok',
1041
+ ...delegation.isError === true && delegation.errorPreview !== undefined
1042
+ ? { error: delegation.errorPreview }
1043
+ : delegation.resultAt === undefined && delegation.callId !== '' ? { error: 'no tool/result in the log' } : {},
1044
+ config: {},
1045
+ requests: [],
1046
+ startedAt: delegation.at,
1047
+ ...delegation.resultAt === undefined ? {} : { endedAt: delegation.resultAt },
1048
+ live: false,
1049
+ logFilled: false,
1050
+ ...delegation.callId === '' ? {} : { callId: delegation.callId },
1051
+ };
1052
+ turn.scopes.set(scopeId, scope);
1053
+ }
1054
+ /** The hidden turn holding children started outside any turn (never listed; room rounds read it). */
1055
+ holderTurn(session, at) {
1056
+ const found = session.turns.find(turn => turn.purpose === 'room-members');
1057
+ if (found !== undefined)
1058
+ return found;
1059
+ const turn = this.mintTurn(session, at, { purpose: 'room-members', query: '', turnSeq: session.nextBucketSeq });
1060
+ session.nextBucketSeq -= 1;
1061
+ return turn;
1062
+ }
1063
+ /** Renumber one session's scope ordinals chronologically (turn order, then creation order). */
1064
+ renumberScopes(session) {
1065
+ let next = 1;
1066
+ const ordered = [...session.turns].sort((a, b) => a.startedAt - b.startedAt || a.turnSeq - b.turnSeq);
1067
+ for (const turn of ordered) {
1068
+ for (const scope of turn.scopes.values()) {
1069
+ scope.scopeSeq = next;
1070
+ next += 1;
1071
+ }
1072
+ }
1073
+ }
1074
+ // ---------- queries ----------
1075
+ /**
1076
+ * Light turn list of one session plus its backfill watermark (sorted newest
1077
+ * last). A {@link ScopeFilter} restricts every aggregate to the authorized
1078
+ * scopes; the turn rows themselves (query preview, timestamps) are
1079
+ * session-level metadata and stay listed.
1080
+ */
1081
+ listTurns(sessionId, filter) {
1082
+ const session = this.sessions.get(sessionId);
1083
+ if (session !== undefined)
1084
+ session.lastAccess = this.now();
1085
+ const backfill = session === undefined
1086
+ ? { state: 'none' }
1087
+ : {
1088
+ state: session.backfillState,
1089
+ ...session.backfillReason === undefined ? {} : { reason: session.backfillReason },
1090
+ };
1091
+ if (session === undefined)
1092
+ return { turns: [], backfill, generation: this.generation, ...this.parents.has(sessionId) ? { parent: this.parents.get(sessionId) } : {} };
1093
+ const ordinals = this.ordinals(session);
1094
+ const listed = [...session.turns, ...this.roomTurns(session, filter)]
1095
+ .filter(turn => turn.purpose !== 'room-members' && this.attachedTurn(session, turn) === undefined)
1096
+ .sort((a, b) => a.startedAt - b.startedAt || a.turnSeq - b.turnSeq);
1097
+ const handoffs = (session.room?.handoffs ?? []).map(h => ({ at: h.at, name: session.room.members.get(h.memberId) ?? h.memberId }));
1098
+ const decisions = scopeAuthorized('main', filter) ? this.decisionsByTurn(session, filter) : new Map();
1099
+ return {
1100
+ turns: listed.map(turn => {
1101
+ const held = decisions.get(turn.turnSeq);
1102
+ const item = { ...this.turnItem(sessionId, turn, filter), ...held === undefined ? {} : { roomDecisionIds: held.map(row => row.id) } };
1103
+ const ordinal = ordinals.get(turn.hostTurn !== undefined ? `h${turn.hostTurn}` : `t${turn.turnSeq}`);
1104
+ return ordinal === undefined ? item : { ...item, ordinal };
1105
+ }),
1106
+ backfill,
1107
+ total: ordinals.size,
1108
+ unloaded: this.unloadedRuns(session, ordinals),
1109
+ ...handoffs.length === 0 ? {} : { handoffs },
1110
+ generation: this.generation,
1111
+ ...this.parents.has(sessionId) ? { parent: this.parents.get(sessionId) } : {},
1112
+ ...session.logRange === undefined ? {} : { range: session.logRange },
1113
+ ...session.evictedTurns === 0 ? {} : { evictedTurnCount: session.evictedTurns },
1114
+ ...filter?.scopes === undefined ? {} : { authorizedScopes: this.authorizedScopes(session, filter) },
1115
+ };
1116
+ }
1117
+ /** The session's existing scopes that pass the filter (turn order, then creation order). */
1118
+ authorizedScopes(session, filter) {
1119
+ const allowed = new Set(filter.scopes);
1120
+ const out = [];
1121
+ for (const turn of [...session.turns].sort((a, b) => a.startedAt - b.startedAt || a.turnSeq - b.turnSeq)) {
1122
+ for (const scope of turn.scopes.values()) {
1123
+ if (allowed.has(scope.scopeId) && !out.includes(scope.scopeId))
1124
+ out.push(scope.scopeId);
1125
+ }
1126
+ }
1127
+ return out;
1128
+ }
1129
+ turnItem(sessionId, turn, filter) {
1130
+ let requestCount = 0;
1131
+ let scopeCount = 0;
1132
+ let failedScopeCount = 0;
1133
+ let totalInputTokens = 0;
1134
+ let totalOutputTokens = 0;
1135
+ for (const scope of turn.scopes.values()) {
1136
+ if (!scopeAuthorized(scope.scopeId, filter))
1137
+ continue;
1138
+ scopeCount += 1;
1139
+ requestCount += scope.requests.length;
1140
+ if (scope.status === 'failed')
1141
+ failedScopeCount += 1;
1142
+ for (const request of scope.requests) {
1143
+ // The usage buckets are disjoint: the turn total counts all three.
1144
+ totalInputTokens += (request.usage?.inputTokens ?? 0)
1145
+ + (request.usage?.cacheReadTokens ?? 0)
1146
+ + (request.usage?.cacheWriteTokens ?? 0);
1147
+ totalOutputTokens += request.usage?.outputTokens ?? 0;
1148
+ }
1149
+ }
1150
+ return {
1151
+ sessionId,
1152
+ turnSeq: turn.turnSeq,
1153
+ ...turn.hostTurn === undefined ? {} : { hostTurn: turn.hostTurn },
1154
+ query: turn.query,
1155
+ ...turn.query === '' && turn.trigger !== undefined ? { trigger: turn.trigger } : {},
1156
+ ...turn.room === undefined ? {} : { room: turn.room },
1157
+ ...turn.startSeq === undefined ? {} : { startSeq: turn.startSeq },
1158
+ startedAt: turn.startedAt,
1159
+ ...turn.endedAt === undefined ? {} : { endedAt: turn.endedAt },
1160
+ requestCount,
1161
+ scopeCount,
1162
+ failedScopeCount,
1163
+ totalInputTokens,
1164
+ totalOutputTokens,
1165
+ origin: turn.liveContent ? 'live' : 'log-reconstructed',
1166
+ ...turn.closingMessageId === undefined ? {} : { closingMessageId: turn.closingMessageId },
1167
+ };
1168
+ }
1169
+ /** One turn's scope/request overview, without segments. */
1170
+ getTurn(sessionId, turnSeq, filter) {
1171
+ const session = this.sessions.get(sessionId);
1172
+ if (session !== undefined)
1173
+ session.lastAccess = this.now();
1174
+ const turn = session === undefined ? undefined : this.findTurn(session, turnSeq, filter);
1175
+ if (session === undefined || turn === undefined)
1176
+ return undefined;
1177
+ const item = this.turnItem(sessionId, turn, filter);
1178
+ const scopes = [...turn.scopes.values()]
1179
+ .filter(scope => scopeAuthorized(scope.scopeId, filter))
1180
+ .map(scope => this.scopeOverview(session, turn, scope));
1181
+ // Compactions attach to the turn they happened in, as one more view beside the agents.
1182
+ if (turn.hostTurn !== undefined && scopeAuthorized('main', filter)) {
1183
+ for (const pseudo of session.turns) {
1184
+ if (this.attachedTurn(session, pseudo) !== turn)
1185
+ continue;
1186
+ for (const scope of pseudo.scopes.values())
1187
+ scopes.push(this.scopeOverview(session, pseudo, scope));
1188
+ }
1189
+ }
1190
+ const roomDecisions = scopeAuthorized('main', filter) ? this.decisionsByTurn(session, filter).get(turn.turnSeq) : undefined;
1191
+ return {
1192
+ turn: item,
1193
+ scopes,
1194
+ ...roomDecisions === undefined ? {} : { roomDecisions },
1195
+ generation: this.generation,
1196
+ ...filter?.scopes === undefined ? {} : { authorizedScopes: scopes.map(scope => scope.scopeId) },
1197
+ };
1198
+ }
1199
+ /**
1200
+ * The room's decision-model calls by the turn they happened in: each goes to
1201
+ * the latest listed turn (main turn or room round) that started at or
1202
+ * before it.
1203
+ */
1204
+ decisionsByTurn(session, filter) {
1205
+ const byTurn = new Map();
1206
+ const decisions = session.room?.decisions ?? [];
1207
+ if (decisions.length === 0)
1208
+ return byTurn;
1209
+ const listed = [...session.turns, ...this.roomTurns(session, filter)]
1210
+ .filter(turn => turn.purpose !== 'room-members' && this.attachedTurn(session, turn) === undefined)
1211
+ .sort((a, b) => a.startedAt - b.startedAt || a.turnSeq - b.turnSeq);
1212
+ for (const decision of decisions) {
1213
+ const owner = listed.findLast(turn => turn.startedAt <= decision.at);
1214
+ if (owner === undefined)
1215
+ continue;
1216
+ const rows = byTurn.get(owner.turnSeq) ?? [];
1217
+ rows.push(decision);
1218
+ byTurn.set(owner.turnSeq, rows);
1219
+ }
1220
+ return byTurn;
1221
+ }
1222
+ /** Record one `room/*` event of a main session (log backfill or live). */
1223
+ noteRoomEvent(sessionId, type, data, seq, at) {
1224
+ if (this.isChild(sessionId) || !type.startsWith('room/'))
1225
+ return;
1226
+ const session = this.session(sessionId);
1227
+ session.room ??= emptyRoom();
1228
+ applyRoomEvent(session.room, type, data, seq, at);
1229
+ }
1230
+ /** A listed turn or a room round, by its panel address. */
1231
+ findTurn(session, turnSeq, filter) {
1232
+ return session.turns.find(candidate => candidate.turnSeq === turnSeq)
1233
+ ?? (turnSeq < -999_999 ? this.roomTurns(session, filter).find(candidate => candidate.turnSeq === turnSeq) : undefined);
1234
+ }
1235
+ /** Whether a moment falls inside some main turn (loaded or not). */
1236
+ insideMainTurn(session, at) {
1237
+ for (const span of session.turnSpans.values()) {
1238
+ if (at >= span.start && at <= (span.end ?? Number.POSITIVE_INFINITY))
1239
+ return true;
1240
+ }
1241
+ return false;
1242
+ }
1243
+ /**
1244
+ * Every human room dispatch made outside a main turn, as a round: the
1245
+ * dispatch's message, and per member run the child session's requests of
1246
+ * that run (its recorded child turn, else the run's time span). Built on
1247
+ * read from the room state and the children's scopes; nothing is stored.
1248
+ */
1249
+ roomTurns(session, filter) {
1250
+ const room = session.room;
1251
+ if (room === undefined)
1252
+ return [];
1253
+ return room.dispatches.filter(dispatch => !this.insideMainTurn(session, dispatch.at)).map((dispatch) => {
1254
+ const scopes = new Map();
1255
+ const runs = runsOf(room, dispatch.seq);
1256
+ for (const run of runs) {
1257
+ const child = this.childOfRun(session, run);
1258
+ if (child !== undefined && !scopeAuthorized(child.scopeId, filter))
1259
+ continue;
1260
+ // The child's own scope id: its requests open by that id from any view.
1261
+ const scopeId = child?.scopeId ?? `room:${dispatch.seq}:${run.memberId}`;
1262
+ const from = (run.startedAt ?? dispatch.at) - 2000, to = (run.endedAt ?? this.now()) + 2000;
1263
+ const requests = (child?.requests ?? []).filter(request => run.childTurn !== undefined && request.hostTurn !== undefined
1264
+ ? request.hostTurn === run.childTurn
1265
+ : request.startedAt >= from && request.startedAt <= to);
1266
+ scopes.set(scopeId, {
1267
+ scopeId,
1268
+ scopeSeq: child?.scopeSeq ?? 0,
1269
+ kind: 'subagent',
1270
+ label: run.member ?? room.members.get(run.memberId) ?? child?.label ?? run.memberId,
1271
+ ...child?.provider === undefined ? {} : { provider: child.provider },
1272
+ status: run.state === 'failed' ? 'failed' : run.state === 'done' ? 'ok' : 'running',
1273
+ config: child?.config ?? {},
1274
+ requests,
1275
+ startedAt: run.startedAt ?? dispatch.at,
1276
+ ...run.endedAt === undefined ? {} : { endedAt: run.endedAt },
1277
+ live: false,
1278
+ logFilled: true,
1279
+ });
1280
+ }
1281
+ const ends = runs.map(run => run.endedAt).filter((end) => end !== undefined);
1282
+ return {
1283
+ turnSeq: roomTurnSeq(dispatch.seq),
1284
+ purpose: 'room',
1285
+ query: dispatch.text,
1286
+ room: { targets: dispatch.targets },
1287
+ startedAt: dispatch.at,
1288
+ ...ends.length === 0 ? {} : { endedAt: Math.max(...ends) },
1289
+ liveContent: false,
1290
+ scopes,
1291
+ };
1292
+ });
1293
+ }
1294
+ /** The child session a run went to: named by the run, else the child catalogued closest to the run's start. */
1295
+ childOfRun(session, run) {
1296
+ if (run.childSessionId !== undefined) {
1297
+ const named = this.findScope(session, run.childSessionId)?.scope;
1298
+ if (named !== undefined)
1299
+ return named;
1300
+ }
1301
+ if (run.startedAt === undefined)
1302
+ return undefined;
1303
+ let best;
1304
+ for (const turn of session.turns)
1305
+ for (const scope of turn.scopes.values()) {
1306
+ if (scope.kind !== 'subagent' || Math.abs(scope.startedAt - run.startedAt) > 10_000)
1307
+ continue;
1308
+ if (best === undefined || Math.abs(scope.startedAt - run.startedAt) < Math.abs(best.startedAt - run.startedAt))
1309
+ best = scope;
1310
+ }
1311
+ return best;
1312
+ }
1313
+ /**
1314
+ * One numbering for the whole session, by time: every main turn (loaded or
1315
+ * not) and every room round. Keys: `h<hostTurn>` for main turns,
1316
+ * `t<turnSeq>` for room rounds.
1317
+ */
1318
+ ordinals(session) {
1319
+ const units = [];
1320
+ const hosts = new Set();
1321
+ for (const [host, span] of session.turnSpans) {
1322
+ units.push({ key: `h${host}`, at: span.start, host });
1323
+ hosts.add(host);
1324
+ }
1325
+ for (const turn of session.turns) {
1326
+ if (turn.hostTurn !== undefined && !hosts.has(turn.hostTurn))
1327
+ units.push({ key: `h${turn.hostTurn}`, at: turn.startedAt, host: turn.hostTurn });
1328
+ }
1329
+ for (const round of this.roomTurns(session))
1330
+ units.push({ key: `t${round.turnSeq}`, at: round.startedAt, host: Number.POSITIVE_INFINITY });
1331
+ units.sort((a, b) => a.at - b.at || a.host - b.host);
1332
+ return new Map(units.map((unit, i) => [unit.key, i + 1]));
1333
+ }
1334
+ /** Runs of main turns the log holds but the store has not loaded, in ordinals, with their host-turn ends. */
1335
+ unloadedRuns(session, ordinals) {
1336
+ const loaded = new Set(session.turns.filter(turn => turn.hostTurn !== undefined).map(turn => turn.hostTurn));
1337
+ const missing = [...session.turnSpans.keys()].filter(host => !loaded.has(host))
1338
+ .map(host => ({ host, ordinal: ordinals.get(`h${host}`) })).sort((a, b) => a.ordinal - b.ordinal);
1339
+ const out = [];
1340
+ for (const { host, ordinal } of missing) {
1341
+ const last = out.at(-1);
1342
+ if (last !== undefined && last.to === ordinal - 1) {
1343
+ last.to = ordinal;
1344
+ last.lastHost = host;
1345
+ }
1346
+ else
1347
+ out.push({ from: ordinal, to: ordinal, firstHost: host, lastHost: host });
1348
+ }
1349
+ return out;
1350
+ }
1351
+ /** A compaction pseudo-turn's host turn: the one it started in, else the next turn to begin; undefined for any other turn. */
1352
+ attachedTurn(session, turn) {
1353
+ if (turn.purpose !== 'compaction')
1354
+ return undefined;
1355
+ const real = session.turns.filter(t => t.hostTurn !== undefined).sort((a, b) => a.startedAt - b.startedAt);
1356
+ const hostTurn = [...turn.scopes.values()].flatMap(scope => scope.requests).find(r => r.compaction?.hostTurn !== undefined)?.compaction?.hostTurn;
1357
+ return (hostTurn === undefined ? undefined : real.find(t => t.hostTurn === hostTurn))
1358
+ ?? real.find(t => t.startedAt >= turn.startedAt) ?? real.at(-1);
1359
+ }
1360
+ /** Where the panel addresses a request: compaction requests live in their host turn's `compaction:` view. */
1361
+ addressOf(session, request) {
1362
+ const pseudo = session.turns.find(t => t.turnSeq === request.turnSeq);
1363
+ const host = pseudo === undefined ? undefined : this.attachedTurn(session, pseudo);
1364
+ return {
1365
+ turnSeq: host?.turnSeq ?? request.turnSeq,
1366
+ scopeId: host === undefined ? request.scopeId : `compaction:${request.turnSeq}`,
1367
+ seq: request.seq,
1368
+ ...request.requestNumber === undefined ? {} : { requestNumber: request.requestNumber },
1369
+ };
1370
+ }
1371
+ scopeOverview(session, turn, scope) {
1372
+ const origin = scopeOrigin(scope);
1373
+ const host = this.attachedTurn(session, turn);
1374
+ const scopeId = host === undefined ? scope.scopeId : `compaction:${turn.turnSeq}`;
1375
+ const main = turn.scopes.get('main');
1376
+ const delegator = scope.callId === undefined ? undefined
1377
+ : main?.requests.find(r => r.response?.toolCalls.some(call => call.callId === scope.callId));
1378
+ const compaction = host === undefined ? undefined : scope.requests.find(r => r.compaction !== undefined)?.compaction;
1379
+ const after = host === undefined ? undefined : host.scopes.get('main')?.requests
1380
+ .filter(r => r.startedAt <= turn.startedAt).sort((a, b) => b.startedAt - a.startedAt)[0];
1381
+ return {
1382
+ scopeId,
1383
+ scopeSeq: scope.scopeSeq,
1384
+ kind: host !== undefined ? 'compaction' : scope.kind,
1385
+ ...scope.label === undefined ? {} : { label: scope.label },
1386
+ ...scope.provider === undefined ? {} : { provider: scope.provider },
1387
+ ...delegator === undefined ? {} : { delegatedBy: this.addressOf(session, delegator) },
1388
+ ...compaction === undefined ? {} : { compaction: {
1389
+ ...compaction.replacedCount === undefined ? {} : { replacedCount: compaction.replacedCount },
1390
+ ...after === undefined ? {} : { after: this.addressOf(session, after) },
1391
+ ...compaction.hostTurn === undefined ? { betweenTurns: true } : {},
1392
+ } },
1393
+ status: scope.status,
1394
+ ...scope.error === undefined ? {} : { error: scope.error },
1395
+ config: scope.config,
1396
+ startedAt: scope.startedAt,
1397
+ ...scope.endedAt === undefined ? {} : { endedAt: scope.endedAt },
1398
+ ...origin === undefined ? {} : { origin },
1399
+ requests: scope.requests.map((request) => ({
1400
+ seq: request.seq,
1401
+ ...request.requestNumber === undefined ? {} : { requestNumber: request.requestNumber },
1402
+ scopeId,
1403
+ status: request.status,
1404
+ config: request.config,
1405
+ responseSummary: request.responseSummary,
1406
+ startedAt: request.startedAt,
1407
+ ...request.usage === undefined ? {} : { usage: request.usage },
1408
+ ...request.settledAt === undefined ? {} : { totalMs: request.settledAt - request.startedAt },
1409
+ ...request.compaction?.inputPartial ? { timingApproximate: true } : {},
1410
+ totalChars: request.totalChars,
1411
+ toolErrors: request.execs.filter(exec => exec.isError).length,
1412
+ })),
1413
+ };
1414
+ }
1415
+ /** Record the child's provider from its `subagent/descriptor` (live child events or its log). */
1416
+ noteChildProvider(childId, provider) {
1417
+ const root = this.childToParent.get(childId);
1418
+ const session = root === undefined ? undefined : this.sessions.get(root);
1419
+ const found = session === undefined ? undefined : this.findScope(session, childId);
1420
+ if (found !== undefined)
1421
+ found.scope.provider = provider;
1422
+ }
1423
+ /** A session opened on its own that is some other session's child. */
1424
+ noteParent(sessionId, parent) {
1425
+ this.parents.set(sessionId, parent);
1426
+ }
1427
+ /** Whether a child's parent link is already known (the service resolves it once). */
1428
+ hasParent(sessionId) {
1429
+ return this.parents.has(sessionId);
1430
+ }
1431
+ /**
1432
+ * One request's full four-segment breakdown. A request whose scope the
1433
+ * filter excludes answers with a {@link ScopedRefusal} — distinguishable
1434
+ * from `undefined` (no such seq on this instance).
1435
+ */
1436
+ getRequest(sessionId, seq, filter) {
1437
+ const session = this.sessions.get(sessionId);
1438
+ if (session === undefined)
1439
+ return undefined;
1440
+ for (const turn of session.turns) {
1441
+ for (const scope of turn.scopes.values()) {
1442
+ const request = scope.requests.find(candidate => candidate.seq === seq);
1443
+ if (request === undefined)
1444
+ continue;
1445
+ if (!scopeAuthorized(scope.scopeId, filter)) {
1446
+ return { refused: 'no-authorized-content', generation: this.generation };
1447
+ }
1448
+ const previous = session.turns.flatMap(t => [...t.scopes.values()])
1449
+ .filter(s => s.scopeId === scope.scopeId)
1450
+ .flatMap(s => s.requests)
1451
+ .filter(r => r.purpose === request.purpose && (r.startedAt < request.startedAt || (r.startedAt === request.startedAt && r.seq < request.seq)))
1452
+ .sort((a, b) => b.startedAt - a.startedAt || b.seq - a.seq)[0];
1453
+ return { ...this.requestView(request), ...previous === undefined || request.compaction?.inputPartial || previous.compaction?.inputPartial ? {} : { prefix: this.withLinks(session, request, previous, comparePrefix(previous, request, factLookup(session))) } };
1454
+ }
1455
+ }
1456
+ return undefined;
1457
+ }
1458
+ /** Resolve change entries to retained requests: the compaction call, and the request that still held un-pruned text. */
1459
+ withLinks(session, request, previous, prefix) {
1460
+ if (prefix.changes === undefined)
1461
+ return prefix;
1462
+ const link = (r) => this.addressOf(session, r);
1463
+ const retained = session.turns.flatMap(turn => [...turn.scopes.values()]).flatMap(scope => scope.requests);
1464
+ return { ...prefix, changes: prefix.changes.map(change => {
1465
+ if (change.kind === 'compaction' && change.compactionId !== undefined) {
1466
+ const target = retained.find(r => r.compaction?.id === change.compactionId && r.seq !== request.seq);
1467
+ return target === undefined ? change : { ...change, compactionRequest: link(target) };
1468
+ }
1469
+ return change.kind === 'prune' ? { ...change, pruneBefore: link(previous) } : change;
1470
+ }) };
1471
+ }
1472
+ previousRequest(session, scopeId, at, seq) {
1473
+ return session.turns.flatMap(turn => turn.scopes.get(scopeId)?.requests ?? [])
1474
+ .filter(r => r.purpose === undefined && (r.startedAt < at || (r.startedAt === at && r.seq < seq)))
1475
+ .sort((a, b) => b.startedAt - a.startedAt || b.seq - a.seq)[0];
1476
+ }
1477
+ requestView(request) {
1478
+ const session = this.sessions.get(request.sessionId);
1479
+ const baseline = request.needsHistoryBaseline && session !== undefined
1480
+ ? this.previousRequest(session, request.scopeId, request.startedAt, request.seq) : undefined;
1481
+ const segments = baseline === undefined ? request.segments
1482
+ : rebaseSegments(request.segments, request.fingerprint, baseline.fingerprint);
1483
+ const owners = new Map();
1484
+ for (const turn of this.sessions.get(request.sessionId)?.turns ?? []) {
1485
+ for (const r of turn.scopes.get(request.scopeId)?.requests ?? []) {
1486
+ if (r.requestNumber !== undefined)
1487
+ for (const c of r.response?.toolCalls ?? []) {
1488
+ if (c.callId !== undefined)
1489
+ owners.set(c.callId, r.requestNumber);
1490
+ }
1491
+ }
1492
+ }
1493
+ const retained = (this.sessions.get(request.sessionId)?.turns ?? []).flatMap(turn => turn.scopes.get(request.scopeId)?.requests ?? []);
1494
+ const link = (r) => session === undefined ? { turnSeq: r.turnSeq, scopeId: r.scopeId, seq: r.seq } : this.addressOf(session, r);
1495
+ const before = retained.filter(r => r.purpose === undefined && r.startedAt < request.startedAt).sort((a, b) => b.startedAt - a.startedAt)[0];
1496
+ const provenance = (items) => items.map(item => {
1497
+ const number = item.toolCallId === undefined ? undefined : owners.get(item.toolCallId);
1498
+ const compression = item.compactionId === undefined ? undefined : retained.find(r => r.compaction?.id === item.compactionId);
1499
+ const original = item.pruning === undefined ? undefined : retained.filter(r => r.startedAt < request.startedAt && [...r.segments.history, ...r.segments.added].some(old => old.messageId === item.messageId && !old.pruning)).sort((a, b) => b.startedAt - a.startedAt)[0];
1500
+ return { ...item,
1501
+ ...number === undefined ? {} : { sourceRequestNumber: number },
1502
+ ...compression === undefined ? {} : { compactionRequest: link(compression) },
1503
+ ...item.pruning === undefined ? {} : { pruning: { ...item.pruning, ...original === undefined ? {} : { before: link(original) } } },
1504
+ };
1505
+ });
1506
+ const response = request.response && { ...request.response, toolCalls: request.response.toolCalls.map(call => {
1507
+ const exec = call.callId === undefined ? undefined : request.execs.find(e => e.callId === call.callId);
1508
+ return exec === undefined ? call : { ...call, execution: {
1509
+ status: exec.isError === true ? 'failed' : exec.end !== undefined || exec.isError === false ? 'ok' : 'running',
1510
+ ...exec.result === undefined ? {} : { result: exec.result },
1511
+ } };
1512
+ }) };
1513
+ return {
1514
+ seq: request.seq,
1515
+ ...request.requestNumber === undefined ? {} : { requestNumber: request.requestNumber },
1516
+ scopeId: session === undefined ? request.scopeId : this.addressOf(session, request).scopeId,
1517
+ sessionId: request.sessionId,
1518
+ ...request.purpose === undefined ? {} : { purpose: request.purpose },
1519
+ ...request.compaction === undefined ? {} : { compaction: { ...request.compaction, ...before === undefined ? {} : { before: link(before) } } },
1520
+ startedAt: request.startedAt,
1521
+ status: request.status,
1522
+ ...request.error === undefined ? {} : { error: request.error },
1523
+ config: request.config,
1524
+ ...request.usage === undefined ? {} : { usage: request.usage },
1525
+ timing: requestTiming(request),
1526
+ generation: this.generation,
1527
+ responseSummary: request.responseSummary,
1528
+ ...response === undefined ? {} : { response },
1529
+ segments: { ...segments, history: provenance(segments.history), added: provenance(segments.added) },
1530
+ };
1531
+ }
1532
+ /** One turn's whole-turn timing aggregation, filtered to the authorized scopes. */
1533
+ getTurnTiming(sessionId, turnSeq, filter) {
1534
+ const session = this.sessions.get(sessionId);
1535
+ const turn = session === undefined ? undefined : this.findTurn(session, turnSeq, filter);
1536
+ if (session === undefined || turn === undefined)
1537
+ return undefined;
1538
+ const lanes = [];
1539
+ const tools = new Map();
1540
+ for (const scope of turn.scopes.values()) {
1541
+ if (!scopeAuthorized(scope.scopeId, filter))
1542
+ continue;
1543
+ for (const request of scope.requests) {
1544
+ lanes.push(...requestLanes(request));
1545
+ for (const exec of request.execs) {
1546
+ if (exec.end === undefined)
1547
+ continue;
1548
+ lanes.push({
1549
+ kind: 'tool-exec',
1550
+ requestSeq: request.seq,
1551
+ scopeId: request.scopeId,
1552
+ startedAt: exec.start,
1553
+ durationMs: exec.end - exec.start,
1554
+ tool: exec.name,
1555
+ callId: exec.callId,
1556
+ ...exec.isError === undefined ? {} : { isError: exec.isError },
1557
+ });
1558
+ const argsMs = request.blocks
1559
+ .filter(block => block.callId === exec.callId && block.end !== undefined)
1560
+ .reduce((n, block) => n + (block.end - block.start), 0);
1561
+ const aggregate = tools.get(exec.name) ?? { tool: exec.name, calls: 0, totalMs: 0, argsMs: 0 };
1562
+ aggregate.calls += 1;
1563
+ aggregate.totalMs += exec.end - exec.start;
1564
+ aggregate.argsMs += argsMs;
1565
+ tools.set(exec.name, aggregate);
1566
+ }
1567
+ }
1568
+ }
1569
+ lanes.sort((a, b) => a.startedAt - b.startedAt || a.requestSeq - b.requestSeq);
1570
+ const sum = (kind) => lanes
1571
+ .filter(lane => lane.kind === kind)
1572
+ .reduce((n, lane) => n + lane.durationMs, 0);
1573
+ const endedAt = turn.endedAt ?? this.now();
1574
+ return {
1575
+ turnSeq: turn.turnSeq,
1576
+ startedAt: turn.startedAt,
1577
+ totalMs: Math.max(0, endedAt - turn.startedAt),
1578
+ buckets: {
1579
+ waitMs: sum('wait'),
1580
+ reasoningMs: sum('reasoning'),
1581
+ toolArgsMs: sum('tool-args'),
1582
+ textMs: sum('text'),
1583
+ toolExecMs: sum('tool-exec'),
1584
+ otherMs: sum('other'),
1585
+ },
1586
+ lanes,
1587
+ tools: [...tools.values()].sort((a, b) => b.totalMs - a.totalMs),
1588
+ generation: this.generation,
1589
+ ...filter?.scopes === undefined ? {} : {
1590
+ authorizedScopes: [...turn.scopes.values()]
1591
+ .filter(scope => scopeAuthorized(scope.scopeId, filter))
1592
+ .map(scope => scope.scopeId),
1593
+ },
1594
+ };
1595
+ }
1596
+ /** Session request headers only; excluded scopes contribute no rows or aggregates. */
1597
+ getSessionTrend(sessionId, filter) {
1598
+ const list = this.listTurns(sessionId, filter);
1599
+ return { ...list, requests: list.turns.flatMap(turn => (this.getTurn(sessionId, turn.turnSeq, filter)?.scopes ?? []).flatMap(scope => scope.requests.map(request => ({ ...request, turnSeq: turn.turnSeq })))) };
1600
+ }
1601
+ /** Release every session buffer (plugin dispose). */
1602
+ dispose() {
1603
+ const sessionIds = [...this.sessions.keys()];
1604
+ for (const sessionId of sessionIds) {
1605
+ const session = this.sessions.get(sessionId);
1606
+ if (session === undefined)
1607
+ continue;
1608
+ for (const turn of [...session.turns])
1609
+ this.evictTurn(session, turn);
1610
+ }
1611
+ this.sessions.clear();
1612
+ this.childToParent.clear();
1613
+ this.callOwners.clear();
1614
+ this.callNames.clear();
1615
+ this.totalChars = 0;
1616
+ }
1617
+ // ---------- eviction ----------
1618
+ /** @param counted - false for a window move or cross-session room, which the "collected then dropped" signal does not report. */
1619
+ evictTurn(session, turn, counted = true) {
1620
+ session.turns = session.turns.filter(candidate => candidate !== turn);
1621
+ if (counted)
1622
+ session.evictedTurns += 1;
1623
+ for (const [purpose, bucket] of session.buckets) {
1624
+ if (bucket === turn)
1625
+ session.buckets.delete(purpose);
1626
+ }
1627
+ if (session.openTurn === turn)
1628
+ session.openTurn = undefined;
1629
+ for (const scope of turn.scopes.values()) {
1630
+ for (const request of scope.requests) {
1631
+ for (const block of request.blocks) {
1632
+ if (block.callId !== undefined) {
1633
+ this.callOwners.delete(block.callId);
1634
+ this.callNames.delete(block.callId);
1635
+ }
1636
+ }
1637
+ this.totalChars -= request.totalChars + (request.responseChars ?? 0) + (request.executionChars ?? 0);
1638
+ }
1639
+ if (scope.kind === 'subagent')
1640
+ session.nextRequestNumbers.delete(scope.scopeId);
1641
+ // The per-session backfill cap tracks reconstructed main-scope rows.
1642
+ if (!turn.liveContent && scope.kind === 'main')
1643
+ session.backfilledRequests -= scope.requests.length;
1644
+ }
1645
+ // Pending delegation bindings are time-bracketed by their turn.
1646
+ const end = turn.endedAt ?? this.now();
1647
+ for (const [callId, pending] of session.pendingCalls) {
1648
+ if (pending.at >= turn.startedAt && pending.at <= end)
1649
+ session.pendingCalls.delete(callId);
1650
+ }
1651
+ this.onTurnEvicted?.(session.id, turn);
1652
+ // A session with nothing left releases its slot (and its child links) —
1653
+ // unless its backfill is only moving (window, cross-session room).
1654
+ if (session.turns.length === 0 && counted) {
1655
+ this.sessions.delete(session.id);
1656
+ for (const [childId, parentId] of this.childToParent) {
1657
+ if (parentId === session.id)
1658
+ this.childToParent.delete(childId);
1659
+ }
1660
+ }
1661
+ }
1662
+ turnIsLive(turn) {
1663
+ if (turn.endedAt === undefined && turn.purpose === undefined)
1664
+ return true;
1665
+ for (const scope of turn.scopes.values()) {
1666
+ if (scope.requests.some(request => request.status === 'streaming'))
1667
+ return true;
1668
+ }
1669
+ return false;
1670
+ }
1671
+ /** The globally oldest closed backfilled turn (char-pressure eviction order). */
1672
+ oldestBackfilledTurn() {
1673
+ let victim;
1674
+ for (const session of this.sessions.values()) {
1675
+ for (const turn of session.turns) {
1676
+ if (turn.liveContent || this.turnIsLive(turn))
1677
+ continue;
1678
+ if (victim === undefined || turn.startedAt < victim.turn.startedAt)
1679
+ victim = { session, turn };
1680
+ }
1681
+ }
1682
+ return victim;
1683
+ }
1684
+ enforceCaps() {
1685
+ for (const session of this.sessions.values()) {
1686
+ // Live-captured turns and the backfill window are capped separately, so
1687
+ // a window moved to older turns never pushes out the live ones (or vice versa).
1688
+ const real = (live) => session.turns.filter(turn => turn.hostTurn !== undefined && turn.liveContent === live);
1689
+ while (real(true).length > this.maxTurnsPerSession) {
1690
+ const victim = real(true).sort((x, y) => x.startedAt - y.startedAt).find(turn => !this.turnIsLive(turn));
1691
+ if (victim === undefined)
1692
+ break;
1693
+ this.evictTurn(session, victim);
1694
+ }
1695
+ while (real(false).length > this.maxTurnsPerSession) {
1696
+ const victim = real(false).sort((x, y) => x.startedAt - y.startedAt)[0];
1697
+ if (victim === undefined)
1698
+ break;
1699
+ this.evictTurn(session, victim);
1700
+ }
1701
+ }
1702
+ // The global char budget bounds stored segment text across sessions:
1703
+ // oldest backfilled turns evict first; live content never yields to it.
1704
+ while (this.totalChars > this.maxStoredCharsTotal) {
1705
+ const victim = this.oldestBackfilledTurn();
1706
+ if (victim === undefined)
1707
+ return;
1708
+ this.evictTurn(victim.session, victim.turn);
1709
+ // Its window has a hole now: the next read refills it.
1710
+ if (victim.session.backfillState === 'complete')
1711
+ victim.session.backfillState = 'partial';
1712
+ }
1713
+ // Turn-less residue (a stray tool/call before any turn/start) frees its slot.
1714
+ for (const session of [...this.sessions.values()]) {
1715
+ if (session.turns.length === 0 && session.openTurn === undefined) {
1716
+ this.sessions.delete(session.id);
1717
+ for (const [childId, parentId] of this.childToParent) {
1718
+ if (parentId === session.id)
1719
+ this.childToParent.delete(childId);
1720
+ }
1721
+ }
1722
+ }
1723
+ }
1724
+ }
1725
+ /** The six-way timing split of one request, computed from its recorded spans. */
1726
+ export function requestTiming(request) {
1727
+ const closed = request.blocks.filter((block) => block.end !== undefined);
1728
+ const sum = (kind) => closed
1729
+ .filter(block => block.kind === kind)
1730
+ .reduce((n, block) => n + (block.end - block.start), 0);
1731
+ const closedExecs = request.execs.filter((exec) => exec.end !== undefined);
1732
+ const toolExecMs = closedExecs.reduce((n, exec) => n + (exec.end - exec.start), 0);
1733
+ return {
1734
+ ...request.firstChunkAt === undefined ? {} : { waitMs: request.firstChunkAt - request.startedAt },
1735
+ ...closed.some(block => block.kind === 'reasoning') ? { reasoningMs: sum('reasoning') } : {},
1736
+ ...closed.some(block => block.kind === 'tool-call') ? { toolArgsMs: sum('tool-call') } : {},
1737
+ ...closed.some(block => block.kind === 'text') ? { textMs: sum('text') } : {},
1738
+ ...closedExecs.length > 0 ? { toolExecMs } : {},
1739
+ ...request.settledAt === undefined ? {} : { totalMs: request.settledAt - request.startedAt },
1740
+ };
1741
+ }
1742
+ const LANE_KIND = {
1743
+ reasoning: 'reasoning',
1744
+ 'tool-call': 'tool-args',
1745
+ text: 'text',
1746
+ };
1747
+ /**
1748
+ * One request's lane partition of [startedAt, settledAt]: wait, then blocks in
1749
+ * time order, with the gaps between known activity as `other` lanes. Overlaps
1750
+ * clamp to the running cursor, so the lanes never double-count wall time.
1751
+ */
1752
+ function requestLanes(request) {
1753
+ if (request.settledAt === undefined)
1754
+ return [];
1755
+ const lanes = [];
1756
+ const base = { requestSeq: request.seq, scopeId: request.scopeId };
1757
+ let cursor = request.startedAt;
1758
+ const push = (kind, start, end, tool) => {
1759
+ if (end <= start)
1760
+ return;
1761
+ lanes.push({ kind, ...base, startedAt: start, durationMs: end - start, ...tool === undefined ? {} : { tool } });
1762
+ };
1763
+ if (request.firstChunkAt !== undefined && request.firstChunkAt > cursor) {
1764
+ push('wait', cursor, request.firstChunkAt);
1765
+ cursor = request.firstChunkAt;
1766
+ }
1767
+ const closed = request.blocks
1768
+ .filter((block) => block.end !== undefined)
1769
+ .sort((a, b) => a.start - b.start);
1770
+ for (const block of closed) {
1771
+ const start = Math.max(block.start, cursor);
1772
+ if (start > cursor)
1773
+ push('other', cursor, start);
1774
+ push(LANE_KIND[block.kind] ?? 'other', start, Math.max(start, block.end), block.toolName);
1775
+ cursor = Math.max(cursor, block.end);
1776
+ }
1777
+ if (request.settledAt > cursor)
1778
+ push('other', cursor, request.settledAt);
1779
+ return lanes;
1780
+ }
1781
+ /**
1782
+ * Assemble a request's own response from its recorded block spans: the
1783
+ * authoritative block-end content, falling back to delta-accumulated text for
1784
+ * blocks a cancelled stream never closed. `undefined` when nothing was
1785
+ * produced (a failure before any block — the `error` field covers that).
1786
+ */
1787
+ export function responseFromSpans(blocks) {
1788
+ const reasoning = blocks.filter(block => block.kind === 'reasoning').map(block => block.text ?? '').join('');
1789
+ const text = blocks.filter(block => block.kind === 'text').map(block => block.text ?? '').join('');
1790
+ const toolCalls = blocks
1791
+ .filter(block => block.kind === 'tool-call' && block.toolName !== undefined)
1792
+ .map(block => ({ ...block.callId === undefined ? {} : { callId: block.callId }, name: block.toolName, arguments: block.args ?? '' }));
1793
+ if (reasoning === '' && text === '' && toolCalls.length === 0)
1794
+ return undefined;
1795
+ return {
1796
+ ...reasoning === '' ? {} : { reasoning },
1797
+ ...text === '' ? {} : { text },
1798
+ toolCalls,
1799
+ };
1800
+ }
1801
+ /** 明细头锚点(结构化):响应流里的工具调用 → `{kind:'tool', name, hint?, more?}`;纯文本 → `{kind:'text'}`;纯思考 → `{kind:'reasoning'}`;空响应 → `{kind:'none'}`。 */
1802
+ function summarizeResponse(request) {
1803
+ const calls = request.blocks.filter(block => block.kind === 'tool-call' && block.toolName !== undefined);
1804
+ if (calls.length > 0) {
1805
+ const first = calls[0];
1806
+ const hint = toolHint(first.args);
1807
+ return {
1808
+ kind: 'tool',
1809
+ name: first.toolName,
1810
+ ...hint === undefined ? {} : { hint },
1811
+ ...calls.length > 1 ? { more: calls.length - 1, toolCounts: countToolCalls(calls.map(call => call.toolName)) } : {},
1812
+ };
1813
+ }
1814
+ const hasText = request.blocks.some(block => block.kind === 'text');
1815
+ if (hasText)
1816
+ return { kind: 'text' };
1817
+ return request.blocks.some(block => block.kind === 'reasoning') ? { kind: 'reasoning' } : { kind: 'none' };
1818
+ }
1819
+ /**
1820
+ * The one-line argument hint behind `tool:name` — the reference
1821
+ * reconstruction's key order (what → file basename → for → description),
1822
+ * truncated so the summary stays one line.
1823
+ */
1824
+ function toolHint(args) {
1825
+ if (args === undefined)
1826
+ return undefined;
1827
+ let parsed;
1828
+ try {
1829
+ parsed = JSON.parse(args);
1830
+ }
1831
+ catch {
1832
+ return undefined;
1833
+ }
1834
+ if (typeof parsed !== 'object' || parsed === null)
1835
+ return undefined;
1836
+ const value = parsed;
1837
+ const pick = (...keys) => {
1838
+ for (const key of keys) {
1839
+ const candidate = value[key];
1840
+ if (typeof candidate === 'string' && candidate.length > 0)
1841
+ return candidate;
1842
+ }
1843
+ return undefined;
1844
+ };
1845
+ const what = pick('what');
1846
+ if (what !== undefined)
1847
+ return clip(what);
1848
+ const file = pick('file_path', 'path', 'filePath');
1849
+ if (file !== undefined)
1850
+ return clip(file.split('/').pop() ?? file);
1851
+ const purpose = pick('for');
1852
+ if (purpose !== undefined)
1853
+ return clip(purpose);
1854
+ const description = pick('description');
1855
+ return description === undefined ? undefined : clip(description);
1856
+ }
1857
+ /** One-line clip for hint values. */
1858
+ function clip(text) {
1859
+ const collapsed = text.replace(/\s+/g, ' ').trim();
1860
+ return collapsed.length <= 40 ? collapsed : `${collapsed.slice(0, 39)}…`;
1861
+ }
1862
+ /** The delegation tool family whose calls pair with `subagent/catalog` events. */
1863
+ function isDelegationTool(name) {
1864
+ return /^subagent[_-]/.test(name);
1865
+ }
1866
+ /** The delegation label out of the call arguments (`description` is the subagent_dsh convention). */
1867
+ function delegationLabel(args) {
1868
+ try {
1869
+ const parsed = JSON.parse(args);
1870
+ if (typeof parsed === 'object' && parsed !== null) {
1871
+ const description = parsed.description;
1872
+ if (typeof description === 'string' && description.length > 0)
1873
+ return { label: description };
1874
+ }
1875
+ }
1876
+ catch {
1877
+ // Unparseable model arguments simply carry no label.
1878
+ }
1879
+ return {};
1880
+ }
1881
+ /** Content comparison is independent of durable message identity and provider cache behavior. */
1882
+ /** `provider/model`, or '' when the capture recorded neither. */
1883
+ function configKey(config) {
1884
+ return config.provider === undefined && config.model === undefined ? '' : `${config.provider ?? '?'}/${config.model ?? '?'}`;
1885
+ }
1886
+ function factLookup(session) {
1887
+ return { byNewId: id => session.replacements.get(id), byReplacedId: id => session.replacedBy.get(id) };
1888
+ }
1889
+ function comparePrefix(previous, current, facts) {
1890
+ const rows = (request) => [...request.segments.system, ...request.segments.history, ...request.segments.added]
1891
+ .sort((a, b) => a.index - b.index);
1892
+ const a = rows(previous), b = rows(current);
1893
+ let i = 0;
1894
+ while (i < a.length && i < b.length && a[i]?.full === b[i]?.full && a[i]?.category === b[i]?.category)
1895
+ i++;
1896
+ // Appending new messages preserves the old prefix; deletion or replacement breaks it.
1897
+ const text = i < a.length ? prefixTextChange(a[i]?.full ?? '', b[i]?.full ?? '') : undefined;
1898
+ const changed = text === undefined ? undefined : {
1899
+ index: b[i]?.index ?? a[i]?.index ?? 0,
1900
+ before: `${text.leadingOmitted ? '…' : ''}${text.leading}${text.removed}${text.removedChars > 160 ? '…' : ''}${text.trailing}${text.trailingOmitted ? '…' : ''}`,
1901
+ after: `${text.leadingOmitted ? '…' : ''}${text.leading}${text.added}${text.addedChars > 160 ? '…' : ''}${text.trailing}${text.trailingOmitted ? '…' : ''}`,
1902
+ ...a[i] === undefined ? {} : { beforeCategory: a[i].category },
1903
+ ...b[i] === undefined ? {} : { afterCategory: b[i].category },
1904
+ text,
1905
+ };
1906
+ const listed = diffRequests({ rows: a, tools: previous.segments.tools }, { rows: b, tools: current.segments.tools }, facts, changed === undefined ? undefined : changed.index);
1907
+ return { previousSeq: previous.seq, ...previous.requestNumber === undefined ? {} : { previousRequestNumber: previous.requestNumber }, gapMs: Math.max(0, current.startedAt - (previous.settledAt ?? previous.startedAt)),
1908
+ changes: listed.changes, toolChanges: listed.tools, ...listed.tail === undefined ? {} : { tail: listed.tail },
1909
+ ...configKey(previous.config) === configKey(current.config) || configKey(previous.config) === '' || configKey(current.config) === '' ? {} : { configChanged: { from: configKey(previous.config), to: configKey(current.config) } },
1910
+ toolsChanged: JSON.stringify(previous.segments.tools.map(({ added, ...tool }) => tool)) !== JSON.stringify(current.segments.tools.map(({ added, ...tool }) => tool)),
1911
+ ...changed === undefined ? {} : { changed } };
1912
+ }
1913
+ /** Extra captured bodies consume retention budget, never input character totals. */
1914
+ function executionSize(request) {
1915
+ return request.execs.reduce((n, exec) => n + (exec.result?.length ?? 0), 0);
1916
+ }
1917
+ function responseSize(response) {
1918
+ return (response?.reasoning?.length ?? 0) + (response?.text?.length ?? 0)
1919
+ + (response?.toolCalls.reduce((n, call) => n + call.name.length + call.arguments.length, 0) ?? 0);
1920
+ }