dsh-code 1.0.5 → 1.0.7

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 (47) hide show
  1. package/README.en.md +338 -286
  2. package/README.md +68 -16
  3. package/bin/deepseek.mjs +204 -4
  4. package/cordis.patch.yml +105 -7
  5. package/lib/index.mjs +2148 -439
  6. package/lib/session-query.mjs +149 -0
  7. package/lib/types/app.d.ts +34 -8
  8. package/lib/types/attachments.d.ts +36 -4
  9. package/lib/types/index.d.ts +38 -2
  10. package/lib/types/kernel-panels.d.ts +23 -0
  11. package/lib/types/provider-settings.d.ts +6 -11
  12. package/lib/types/render/animations.d.ts +74 -7
  13. package/lib/types/render/editor.d.ts +4 -3
  14. package/lib/types/render/export.d.ts +0 -6
  15. package/lib/types/render/fuzzy.d.ts +21 -0
  16. package/lib/types/render/ime-cursor.d.ts +60 -0
  17. package/lib/types/render/projection.d.ts +80 -4
  18. package/lib/types/render/status.d.ts +1 -1
  19. package/lib/types/session-directory.d.ts +48 -13
  20. package/lib/types/session-query.d.ts +92 -0
  21. package/lib/types/store.d.ts +3 -0
  22. package/lib/types/terminal-title.d.ts +58 -0
  23. package/lib/types/update-panel.d.ts +49 -0
  24. package/lib/types/update.d.ts +66 -0
  25. package/package.json +307 -162
  26. package/src/app.ts +730 -266
  27. package/src/attachments.ts +110 -11
  28. package/src/commands.ts +35 -5
  29. package/src/index.ts +1986 -1779
  30. package/src/internals.ts +66 -40
  31. package/src/kernel-panels.ts +89 -3
  32. package/src/provider-settings.ts +12 -12
  33. package/src/render/animations.ts +606 -403
  34. package/src/render/editor.ts +5 -4
  35. package/src/render/export.ts +13 -3
  36. package/src/render/fuzzy.ts +83 -0
  37. package/src/render/ime-cursor.ts +147 -0
  38. package/src/render/projection.ts +1974 -1621
  39. package/src/render/status.ts +18 -4
  40. package/src/session-directory.ts +94 -16
  41. package/src/session-query.ts +235 -0
  42. package/src/skills.ts +23 -9
  43. package/src/store.ts +39 -1
  44. package/src/subagents.ts +26 -3
  45. package/src/terminal-title.ts +173 -0
  46. package/src/update-panel.ts +246 -0
  47. package/src/update.ts +110 -0
@@ -1,1621 +1,1974 @@
1
- /**
2
- * Pure session-event-to-view projection for the TUI transcript: one reducer
3
- * over {@link SessionEvent}s producing the ordered entries the renderer draws.
4
- * Rendering never reads the session directly — this module owns the view
5
- * model, so tests drive it with plain event arrays.
6
- *
7
- * @module @deepseek-ai/dsh-tui/render/projection
8
- */
9
-
10
- import { boundContextSummary, type ContentBlock, type ImageBlock, type MessageId } from '@deepseek-ai/dsh-llm'
11
- import type { SessionEvent } from '@deepseek-ai/dsh-session'
12
- import type { TodoItem } from '@deepseek-ai/dsh-tool-todo'
13
- import { graphemeWidth, splitGraphemes } from './width.ts'
14
- // Type-only imports merge the plugin-owned SessionEventMap variants
15
- // (agent/inbox/spliced, command/*, compaction/*, goal/change, llm/retry*,
16
- // plan/mode, permission/preset, sandbox/mode, session/title) into the union
17
- // this reducer switches on.
18
- import type {} from '@deepseek-ai/dsh-agent'
19
- import type {} from '@deepseek-ai/dsh-commands'
20
- import type {} from '@deepseek-ai/dsh-compaction'
21
- import type {} from '@deepseek-ai/dsh-goal'
22
- import type {} from '@deepseek-ai/dsh-llm-retry'
23
- import type {} from '@deepseek-ai/dsh-plan-mode'
24
- import type {} from '@deepseek-ai/dsh-permission-presets'
25
- import type {} from '@deepseek-ai/dsh-sandbox-policy'
26
- import type {} from '@deepseek-ai/dsh-session-title'
27
- import { toolArgumentsPreview, toolPromptPreview } from './tool-preview.ts'
28
- import { toolResultDetail, type ToolDetail } from './tool-detail.ts'
29
-
30
- /** In-flight UI buffers are tails; the assembled assistant message is authoritative. */
31
- const MAX_STREAMING_CHARS = 65_536
32
-
33
- /**
34
- * Upper bound on remembered `compaction/summary` shadow prices waiting for a
35
- * matching `compaction/end`. Compactions are sequential and rare, so a few
36
- * slots suffice; an aborted compaction (summary without end) otherwise leaves
37
- * an unbounded residue in `anchors.compactionTokens`. An evicted price
38
- * degrades to the documented `lastPruneTokens` fallback, exactly like a
39
- * missing summary.
40
- */
41
- const MAX_COMPACTION_SUMMARY_RESIDUE = 16
42
-
43
- /** Append one delta without retaining an unbounded duplicate of the live reply. */
44
- function appendStreamingTail(current: string, delta: string): string {
45
- const next = current + delta
46
- return next.length <= MAX_STREAMING_CHARS ? next : next.slice(-MAX_STREAMING_CHARS)
47
- }
48
-
49
- /** One user prompt line. */
50
- export interface UserEntry {
51
- kind: 'user'
52
- /** Joined text blocks of the user message. */
53
- text: string
54
- /** True for collapsed injected context (plugin/continuation notices), which
55
- * the renderer marks with a dim ↳ instead of the user ❯ prompt. */
56
- notice: boolean
57
- /** Durable image references carried by this prompt. */
58
- images?: readonly ImageBlock['attachment'][]
59
- }
60
-
61
- /** One user message waiting in the agent inbox (the web's queued-message row). */
62
- export interface PendingEntry {
63
- kind: 'pending'
64
- /** Stable message identity shared with the durable `user/message` that retires it. */
65
- messageId: MessageId
66
- /** Which inbox list holds the message: steering is consumed at the next step boundary. */
67
- target: 'next-turn' | 'next-step'
68
- /** Full message text Codex PendingSteer renders queued prompts exactly like user rows. */
69
- text: string
70
- /** Durable image references queued with this prompt. */
71
- images?: readonly ImageBlock['attachment'][]
72
- }
73
-
74
- /** One authoritative assembled assistant reply. */
75
- export interface AssistantEntry {
76
- kind: 'assistant'
77
- /** Joined text blocks of the assistant message. */
78
- text: string
79
- /** Joined reasoning blocks from the same assembled message. */
80
- reasoning: string
81
- /** True when a cancelled stream's delivered prefix was finalized as this
82
- * entry (rc.8 `assistant/message.interrupted`) — rendered with a marker. */
83
- interrupted?: true
84
- }
85
-
86
- /** One model-requested tool invocation and its settled state. */
87
- export interface ToolEntry {
88
- kind: 'tool'
89
- /** Correlation id shared with the matching `tool/result`. */
90
- callId: string
91
- /**
92
- * Global tool-call ordinal across the whole transcript (1, 2, 3…, never
93
- * reset between turns). The tool-card badge and every error line that
94
- * references the failed call share this number, so "call N" in an error
95
- * always names the exact card the badge shows.
96
- */
97
- ordinal: number
98
- /** Tool name as the model addressed it. */
99
- name: string
100
- /** Raw arguments JSON string exactly as the model produced it. */
101
- arguments: string
102
- /** Bounded human-meaningful arguments preview for the tool card. */
103
- preview: string
104
- /** Bounded delegation prompt (subagent cards' second row), '' when none. */
105
- prompt: string
106
- /** Execution state; `running` until the paired result lands. */
107
- state: 'running' | 'done' | 'error'
108
- /** Bounded first text block of the result, empty until it lands. */
109
- summary: string
110
- /**
111
- * Bounded expansion payload for the verbose transcript (Ctrl+O), derived
112
- * from the tool's persisted presentation metadata; undefined until the
113
- * result lands and only when something renderable exists.
114
- */
115
- detail: ToolDetail | undefined
116
- }
117
-
118
- /** One slash-command execution dispatched through `ctx.commands`. */
119
- export interface CommandEntry {
120
- kind: 'command'
121
- /** Pairing id shared with the matching `command/done`. */
122
- commandId: string
123
- /** Lowercase command name without the leading slash. */
124
- name: string
125
- /** Verbatim text following the command name. */
126
- args: string
127
- /** Execution state; `running` until the paired lifecycle event lands. */
128
- state: 'running' | 'done' | 'error'
129
- /** Handler outcome text, empty until it lands. */
130
- summary: string
131
- }
132
-
133
- /** One turn-level failure surfaced from `turn/end`. */
134
- export interface ErrorEntry {
135
- kind: 'error'
136
- /** `code: message` of the failure. */
137
- text: string
138
- }
139
-
140
- /** One non-error turn outcome surfaced from `turn/end`. */
141
- export interface TurnMarkerEntry {
142
- kind: 'turn-marker'
143
- /** Human-readable outcome line, dim-rendered. */
144
- text: string
145
- }
146
-
147
- /** One completed compaction lifecycle surfaced from `compaction/end`. */
148
- export interface CompactionEntry {
149
- kind: 'compaction'
150
- /** True when the compaction completed, false when it failed. */
151
- ok: boolean
152
- /** Heuristic tokens shadowed by the compaction (summary or prune price). */
153
- tokens: number
154
- /** Failure text when `ok` is false, empty otherwise. */
155
- error: string
156
- }
157
-
158
- /** One provider-routed model-request retry (the `llm/retry` pair). */
159
- export interface RetryEntry {
160
- kind: 'retry'
161
- /** Correlation id shared with the matching `llm/retry-started`. */
162
- retryId: string
163
- /** Retry policy mode from the event: `always` has no attempt cap. */
164
- mode: 'normal' | 'always'
165
- /** Attempt ordinal and its cap. */
166
- attempt: number
167
- max: number
168
- /** Failure code that triggered the retry. */
169
- code: string
170
- /** Backoff wait before the next attempt, in ms. */
171
- delayMs: number
172
- /**
173
- * `running` while the backoff waits, `done` once the attempt started — or
174
- * when the turn ended first (the turn-end sweep finalizes orphans so they
175
- * never pin the settled boundary).
176
- */
177
- state: 'running' | 'done'
178
- }
179
-
180
- /** Turn-tail deliverables: files mutated by the turn's diff-bearing tools. */
181
- export interface FilesEntry {
182
- kind: 'files'
183
- /** Unique mutated paths in call order, bounded. */
184
- paths: readonly string[]
185
- }
186
-
187
- /** Ordered transcript items the renderer draws. */
188
- export type TranscriptEntry = UserEntry | PendingEntry | AssistantEntry | ToolEntry | CommandEntry | ErrorEntry | TurnMarkerEntry | CompactionEntry | RetryEntry | FilesEntry
189
-
190
- /** The live goal the status line badges, folded from `goal/change`. */
191
- export interface GoalFold {
192
- /** Human-requested completion objective. */
193
- objective: string
194
- /** Durable lifecycle phase. */
195
- phase: 'active' | 'paused' | 'blocked' | 'complete'
196
- /** Highest admitted continuation round and its cap. */
197
- rounds: number
198
- max: number
199
- /** Blocked explanation, empty outside the blocked phase. */
200
- blocked: string
201
- }
202
-
203
- /** Cumulative token accounting folded from `assistant/message` usage reports. */
204
- export interface UsageTotals {
205
- /** Prompt-side billed tokens: `inputTokens` plus both cache buckets. */
206
- inputTokens: number
207
- /** Completion-side tokens over the whole log. */
208
- outputTokens: number
209
- /** Cache-read tokens over the whole log (0 when the adapter reports none). */
210
- cacheReadTokens: number
211
- }
212
-
213
- /**
214
- * Estimated used tokens per context content type, folded from transcript
215
- * events via {@link estimateTokens}. The segmented context bar's composition
216
- * source: proportions across types are meaningful, absolute values are not
217
- * (they never touch billing or the reported `lastPromptTokens`).
218
- */
219
- export interface ContextSegments {
220
- /** Rendered system-prompt text (latest `request/header`) plus injected-context notices. */
221
- system: number
222
- /** Direct human prompts (durable `user/message` rows). */
223
- prompt: number
224
- /** Assistant text blocks (visible replies). */
225
- assistant: number
226
- /** Assistant reasoning blocks (hidden thinking). */
227
- thinking: number
228
- /** Tool call arguments plus result text. */
229
- tools: number
230
- }
231
-
232
- /** Window-scoped figures the status line shows; timing uses event timestamps. */
233
- export interface TranscriptStats {
234
- /** Durable turns opened (`turn/start` events). */
235
- turns: number
236
- /** Model requests made (`step/start` events). */
237
- steps: number
238
- /** Summed model wall time: `step/start` → `assistant/message`, in ms. */
239
- llmMs: number
240
- /** Summed tool wall time: `tool/call` → `tool/result`, in ms. */
241
- toolMs: number
242
- /** Cumulative token accounting; input stays 0 until a report lands. */
243
- usage: UsageTotals
244
- /** Prompt-side size of the most recent reported request (context pressure). */
245
- lastPromptTokens: number
246
- /** Newest advertised route capacity, 0 when no adapter ever advertised one. */
247
- contextWindow: number
248
- /** Estimated used tokens per content type (the segmented bar's composition). */
249
- contextSegments: ContextSegments
250
- /** Summed first-token waits: `step/start` → first non-empty chunk, in ms. */
251
- ttftMs: number
252
- /** Steps that produced a first chunk (the TTFT average's denominator). */
253
- ttftSteps: number
254
- /** Summed decode spans: first chunk → `assistant/message`, in ms. */
255
- decodeMs: number
256
- /** Completion tokens over timed decode spans (the tok/s numerator). */
257
- decodeTokens: number
258
- /**
259
- * Adapter-owned reasoning effort of the latest `request/header` config
260
- * the EFFECTIVE effort the session actually uses (a materialized model
261
- * default is included, exactly as the adapter resolved it). Empty when the
262
- * header carried none (provider-default behavior). The status line appends
263
- * it to the model segment as `provider/model@effort`.
264
- */
265
- reasoningEffort: string
266
- }
267
-
268
- /** The complete TUI transcript view for one session. */
269
- export interface TranscriptView {
270
- /** Settled entries in log order. */
271
- entries: readonly TranscriptEntry[]
272
- /** Bounded text tail accumulated from `assistant/chunk` deltas since the last flush. */
273
- streaming: string
274
- /** Bounded thinking tail accumulated from reasoning deltas since the last flush. */
275
- streamingReasoning: string
276
- /** Latest whole-list todo snapshot from `todo/write`, empty when none. */
277
- todos: readonly TodoItem[]
278
- /**
279
- * Global tool-call ordinal counter: the number the NEXT `tool/call` lands
280
- * with (1-based). Never reset, so the counter and the badges/error lines
281
- * stay consistent across turns and resumed sessions.
282
- */
283
- toolCallOrdinal: number
284
- /** True while a durable turn is open (`turn/start` … `turn/end`). */
285
- busy: boolean
286
- /** `turn/start` time of the open turn (0 while idle) — the web TurnStatus clock anchor. */
287
- busySince: number
288
- /** Figures the status line renders. */
289
- stats: TranscriptStats
290
- /**
291
- * The `provider/model` pair of the last `request/header` snapshot the
292
- * session's own model record, which a resumed TUI prefers over the
293
- * deployment default (mirrors the web host's resume selection order).
294
- * Empty before the session's first request.
295
- */
296
- model: string
297
- /** Plan mode state folded from the last `plan/mode` event. */
298
- plan: boolean
299
- /** Active permission preset folded from the last `permission/preset` event, empty before one. */
300
- permission: string
301
- /** Latest session title folded from the last `session/title` event, empty before one. */
302
- title: string
303
- /** Sandbox-mode override folded from the last `sandbox/mode` event, empty when never switched. */
304
- sandbox: string
305
- /** Current long-running goal folded from the last `goal/change`, undefined when cleared. */
306
- goal: GoalFold | undefined
307
- /**
308
- * Ordered live message ids per inbox target, mirrored from
309
- * `agent/inbox/spliced` exactly like the upstream Inbox projection — the
310
- * coordinates later removals resolve against.
311
- */
312
- pending: { 'next-turn': readonly string[]; 'next-step': readonly string[] }
313
- /**
314
- * Fold-internal timing anchors, never rendered: open step and tool-call
315
- * start timestamps the next `assistant/message` / `tool/result` resolves
316
- * against. Keyed `turn:step` and by call id. `turnSteps`/`turnTools`
317
- * track which step/tool anchors still belong to the open turn so
318
- * `turn/end` (and a superseding `step/start`) can sweep anchors an
319
- * interruption left behind; `turnFiles` keys mutated paths by turn.
320
- */
321
- readonly anchors: {
322
- stepStart: Map<string, number>
323
- toolStart: Map<string, number>
324
- firstChunkAt: Map<string, number>
325
- compactionTokens: Map<string, number>
326
- lastPruneTokens: number
327
- turnFiles: Map<number, Set<string>>
328
- turnSteps: Map<number, string>
329
- turnTools: Map<number, Set<string>>
330
- }
331
- }
332
-
333
- /** Join the text blocks of a content list; non-text blocks contribute nothing. */
334
- function textOf(content: readonly ContentBlock[]): string {
335
- return content.filter(block => block.type === 'text').map(block => block.text).join('')
336
- }
337
-
338
- /**
339
- * Snapshot-isolate one anchors block (Maps and their nested Sets): a view
340
- * already handed to the renderer must never observe a later fold through a
341
- * shared container. The collections are small and turn-bounded, so cloning
342
- * per event is cheap next to the entries copy the reducer already makes.
343
- */
344
- function cloneViewAnchors(anchors: TranscriptView['anchors']): TranscriptView['anchors'] {
345
- return {
346
- stepStart: new Map(anchors.stepStart),
347
- toolStart: new Map(anchors.toolStart),
348
- firstChunkAt: new Map(anchors.firstChunkAt),
349
- compactionTokens: new Map(anchors.compactionTokens),
350
- lastPruneTokens: anchors.lastPruneTokens,
351
- turnFiles: new Map([...anchors.turnFiles].map(([turn, files]) => [turn, new Set(files)])),
352
- turnSteps: new Map(anchors.turnSteps),
353
- turnTools: new Map([...anchors.turnTools].map(([turn, tools]) => [turn, new Set(tools)])),
354
- }
355
- }
356
-
357
- /** Durable image references in their model-visible order. */
358
- function imagesOf(content: readonly ContentBlock[]): readonly ImageBlock['attachment'][] {
359
- return content.filter((block): block is ImageBlock => block.type === 'image').map(block => block.attachment)
360
- }
361
-
362
- /** Human-readable bounded image labels for transcript, inspector, and export surfaces. */
363
- export function imageLabels(images: readonly ImageBlock['attachment'][] | undefined): string {
364
- if (images === undefined || images.length === 0) return ''
365
- return images.map((image, index) => {
366
- const rawName = image.name?.trim() || `image ${index + 1}`
367
- const name = rawName.length <= 80 ? rawName : `${rawName.slice(0, 79)}…`
368
- const original = image.originalDimensions
369
- const dimensions = original === undefined
370
- ? `${image.width}×${image.height}`
371
- : `${image.width}×${image.height} · original ${original.width}×${original.height}`
372
- return `[image: ${name} · ${dimensions} · ${image.bytes} B]`
373
- }).join('\n')
374
- }
375
-
376
- /** Prompt text with its durable image labels, without exposing local paths or bytes. */
377
- export function promptDisplayText(entry: Pick<UserEntry | PendingEntry, 'text' | 'images'>): string {
378
- const labels = imageLabels(entry.images)
379
- return entry.text === '' ? labels : labels === '' ? entry.text : `${entry.text}\n${labels}`
380
- }
381
-
382
- /** Join the reasoning blocks of a content list; non-reasoning blocks contribute nothing. */
383
- function reasoningOf(content: readonly ContentBlock[]): string {
384
- return content.filter(block => block.type === 'reasoning').map(block => block.text).join('')
385
- }
386
-
387
- /**
388
- * Rough token estimate for the segmented context bar (pi-nano-context's ~4
389
- * chars/token heuristic, CJK-aware so a Chinese prompt is not quartered):
390
- * CJK/wide chars cost ~1 token each, ASCII ~4 chars per token. Estimates
391
- * drive bar PROPORTIONS, never billing, so precision is not required.
392
- * @param text - the text to estimate.
393
- * @returns an integer token estimate, 0 for empty text.
394
- */
395
- function estimateTokens(text: string): number {
396
- let wide = 0
397
- let narrow = 0
398
- for (const cluster of splitGraphemes(text)) {
399
- if (graphemeWidth(cluster) > 1) wide += 1
400
- else narrow += 1
401
- }
402
- return wide + Math.ceil(narrow / 4)
403
- }
404
-
405
- /** A fresh, empty transcript view. */
406
- export function createTranscriptView(): TranscriptView {
407
- return {
408
- entries: [],
409
- streaming: '',
410
- streamingReasoning: '',
411
- todos: [],
412
- toolCallOrdinal: 0,
413
- busy: false,
414
- busySince: 0,
415
- model: '',
416
- plan: false,
417
- permission: '',
418
- title: '',
419
- sandbox: '',
420
- goal: undefined,
421
- pending: { 'next-turn': [], 'next-step': [] },
422
- stats: { turns: 0, steps: 0, llmMs: 0, toolMs: 0, usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }, lastPromptTokens: 0, contextWindow: 0, contextSegments: { system: 0, prompt: 0, assistant: 0, thinking: 0, tools: 0 }, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0, reasoningEffort: '' },
423
- anchors: { stepStart: new Map(), toolStart: new Map(), firstChunkAt: new Map(), compactionTokens: new Map(), lastPruneTokens: 0, turnFiles: new Map(), turnSteps: new Map(), turnTools: new Map() },
424
- }
425
- }
426
-
427
- /** Full prompt text of a queued message (identical to the durable user row it retires into). */
428
- function pendingText(content: readonly ContentBlock[]): string {
429
- return textOf(content)
430
- }
431
-
432
- /**
433
- * Fold one session event into an updated view (copy-on-write).
434
- * @param view - the view before the event.
435
- * @param event - one durable session event from `session/event` or the log.
436
- * @returns the view after the event; the input view is never mutated.
437
- */
438
- export function projectEvent(view: TranscriptView, event: SessionEvent): TranscriptView {
439
- // Fold against a private anchors block so the documented contract holds —
440
- // "the input view is never mutated" — even for the in-place anchor sweeps
441
- // below; without this, every handed-out view shared live Maps.
442
- view = { ...view, anchors: cloneViewAnchors(view.anchors) }
443
- switch (event.type) {
444
- case 'user/message': {
445
- // A queued row retires when its durable user message lands (the agent
446
- // claims the inbox and logs the same message identity) the transient
447
- // steering/queued preview yields to the real transcript entry.
448
- const message = event.data
449
- let entries = view.entries
450
- let pending = view.pending
451
- for (const target of ['next-turn', 'next-step'] as const) {
452
- const index = pending[target].indexOf(message.id)
453
- if (index < 0) continue
454
- pending = { ...pending, [target]: pending[target].filter((_, i) => i !== index) }
455
- entries = entries.filter(entry => !(entry.kind === 'pending' && entry.messageId === message.id))
456
- }
457
- // Injected context (plugin/model-continuation sources) stays collapsed
458
- // to a bounded notice row, exactly like collapsed transcript context
459
- // elsewhere in the product; only direct human prompts render in full.
460
- const text = textOf(message.content)
461
- const images = imagesOf(message.content)
462
- if (message.source.kind === 'user') {
463
- return {
464
- ...view,
465
- pending,
466
- entries: [...entries, { kind: 'user', text, notice: false, ...(images.length === 0 ? {} : { images }) }],
467
- stats: {
468
- ...view.stats,
469
- contextSegments: {
470
- ...view.stats.contextSegments,
471
- prompt: view.stats.contextSegments.prompt + estimateTokens(text),
472
- },
473
- },
474
- }
475
- }
476
- const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
477
- ? message.source.summary
478
- : message.source.kind
479
- const summary = boundContextSummary(notice)
480
- return {
481
- ...view,
482
- pending,
483
- entries: [...entries, { kind: 'user', text: summary, notice: true }],
484
- stats: {
485
- ...view.stats,
486
- contextSegments: {
487
- ...view.stats.contextSegments,
488
- system: view.stats.contextSegments.system + estimateTokens(summary),
489
- },
490
- },
491
- }
492
- }
493
- case 'agent/inbox/spliced': {
494
- // The durable inbox mutation (web queue-mirror contract, event-sourced):
495
- // removals drop the projected rows at their inbox coordinates, inserted
496
- // messages gain a pending row at their log position.
497
- const { target, start, removedCount = 0, inserted } = event.data
498
- const ids = view.pending[target]
499
- const removed = ids.slice(start, start + removedCount)
500
- // In-place upstream semantics: the kernel's authoritative fold is
501
- // `inbox.splice(start, removedCount, ...inserted)` inserted ids land
502
- // AT the splice position (prepend/replace shapes), never at the tail.
503
- // A tail append diverged the id order, so later coordinate-based events
504
- // (next-turn head claims, positioned remove/replace) tombstoned the
505
- // wrong pending row.
506
- const nextIds = [
507
- ...ids.slice(0, start),
508
- ...inserted.map(message => message.id),
509
- ...ids.slice(start + removedCount),
510
- ]
511
- let entries = view.entries
512
- if (removed.length > 0) {
513
- const removedSet = new Set(removed)
514
- entries = entries.filter(entry =>
515
- !(entry.kind === 'pending' && entry.target === target && removedSet.has(entry.messageId)))
516
- }
517
- for (const message of inserted) {
518
- entries = [...entries, {
519
- kind: 'pending',
520
- messageId: message.id,
521
- target,
522
- text: pendingText(message.content),
523
- ...imagesOf(message.content).length === 0 ? {} : { images: imagesOf(message.content) },
524
- }]
525
- }
526
- return { ...view, entries, pending: { ...view.pending, [target]: nextIds } }
527
- }
528
- case 'assistant/chunk': {
529
- const chunk = event.data.chunk
530
- // First-token latency: the first non-empty delta of a step anchors the
531
- // TTFT (empty keep-alive deltas do not count as tokens).
532
- const key = `${event.data.turn}:${event.data.step}`
533
- const delta = chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' ? chunk.text : ''
534
- let stats = view.stats
535
- if (delta !== '' && !view.anchors.firstChunkAt.has(key)) {
536
- view.anchors.firstChunkAt.set(key, event.time)
537
- const started = view.anchors.stepStart.get(key)
538
- if (started !== undefined) {
539
- stats = {
540
- ...stats,
541
- ttftMs: stats.ttftMs + Math.max(0, event.time - started),
542
- ttftSteps: stats.ttftSteps + 1,
543
- }
544
- }
545
- }
546
- if (chunk.type === 'text-delta') {
547
- return {
548
- ...view,
549
- streaming: appendStreamingTail(view.streaming, chunk.text),
550
- stats,
551
- }
552
- }
553
- if (chunk.type === 'reasoning-delta') {
554
- return {
555
- ...view,
556
- streamingReasoning: appendStreamingTail(view.streamingReasoning, chunk.text),
557
- stats,
558
- }
559
- }
560
- return view
561
- }
562
- case 'assistant/message': {
563
- // The assembled message is authoritative; drop the streamed buffers.
564
- const key = `${event.data.turn}:${event.data.step}`
565
- const started = view.anchors.stepStart.get(key)
566
- view.anchors.stepStart.delete(key)
567
- const firstChunk = view.anchors.firstChunkAt.get(key)
568
- view.anchors.firstChunkAt.delete(key)
569
- // The assembled message consumes the turn's current step anchor; a
570
- // later `turn/end` sweep then has nothing left to clean for this step.
571
- if (view.anchors.turnSteps.get(event.data.turn) === key) view.anchors.turnSteps.delete(event.data.turn)
572
- const usage = event.data.usage
573
- const totals = view.stats.usage
574
- const text = textOf(event.data.message.content)
575
- const reasoning = reasoningOf(event.data.message.content)
576
- return {
577
- ...view,
578
- streaming: '',
579
- streamingReasoning: '',
580
- entries: [...view.entries, { kind: 'assistant', text, reasoning, interrupted: event.data.interrupted === true ? true : undefined }],
581
- stats: {
582
- ...view.stats,
583
- llmMs: view.stats.llmMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
584
- usage: usage === undefined ? totals : {
585
- inputTokens: totals.inputTokens + usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
586
- outputTokens: totals.outputTokens + usage.outputTokens,
587
- cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0),
588
- },
589
- lastPromptTokens: usage === undefined ? view.stats.lastPromptTokens
590
- : usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
591
- // Decode span and its tokens pair up: an un-timed step (no first
592
- // chunk landed) contributes neither, so the rate stays honest.
593
- decodeMs: view.stats.decodeMs + (firstChunk === undefined ? 0 : Math.max(0, event.time - firstChunk)),
594
- decodeTokens: view.stats.decodeTokens + (firstChunk === undefined || usage === undefined ? 0 : usage.outputTokens),
595
- contextSegments: {
596
- ...view.stats.contextSegments,
597
- thinking: view.stats.contextSegments.thinking + estimateTokens(reasoning),
598
- assistant: view.stats.contextSegments.assistant + estimateTokens(text),
599
- },
600
- },
601
- }
602
- }
603
- case 'tool/call': {
604
- const data = event.data
605
- view.anchors.toolStart.set(data.callId, event.time)
606
- // Remember the call's turn so `turn/end` can sweep a start that never
607
- // pairs with a result (an interrupted tool otherwise leaks its anchor).
608
- const turnTools = view.anchors.turnTools.get(data.turn) ?? new Set<string>()
609
- turnTools.add(data.callId)
610
- view.anchors.turnTools.set(data.turn, turnTools)
611
- const ordinal = view.toolCallOrdinal + 1
612
- return {
613
- ...view,
614
- toolCallOrdinal: ordinal,
615
- entries: [
616
- ...view.entries,
617
- {
618
- kind: 'tool',
619
- callId: data.callId,
620
- ordinal,
621
- name: data.name,
622
- arguments: data.arguments,
623
- preview: toolArgumentsPreview(data.arguments, data.name),
624
- prompt: toolPromptPreview(data.name, data.arguments),
625
- state: 'running',
626
- summary: '',
627
- detail: undefined,
628
- }],
629
- stats: {
630
- ...view.stats,
631
- contextSegments: {
632
- ...view.stats.contextSegments,
633
- tools: view.stats.contextSegments.tools
634
- + (typeof data.arguments === 'string' ? estimateTokens(data.arguments) : 0),
635
- },
636
- },
637
- }
638
- }
639
- case 'tool/result': {
640
- const block = event.data.message.content[0]
641
- const started = view.anchors.toolStart.get(block.toolCallId)
642
- view.anchors.toolStart.delete(block.toolCallId)
643
- // Deregister the call from its turn's registry so `turn/end` does not
644
- // sweep a start that already paired with a result.
645
- const turnTools = view.anchors.turnTools.get(event.data.turn)
646
- if (turnTools !== undefined) {
647
- turnTools.delete(block.toolCallId)
648
- if (turnTools.size === 0) view.anchors.turnTools.delete(event.data.turn)
649
- }
650
- const rawText = textOf(block.content)
651
- const summary = boundContextSummary(rawText)
652
- // The verbose expansion self-serves from the persisted presentation
653
- // metadata (diffs, read windows, web sources) with the bounded raw text
654
- // as the universal fallback — the capable-UI degradation ladder.
655
- const detail = toolResultDetail(event.data.meta, rawText)
656
- // Turn-tail deliverables: a diff-bearing mutation records its paths.
657
- if (detail?.kind === 'diff') {
658
- const set = view.anchors.turnFiles.get(event.data.turn) ?? new Set<string>()
659
- for (const diff of detail.diffs) set.add(diff.path)
660
- view.anchors.turnFiles.set(event.data.turn, set)
661
- }
662
- const entries = view.entries.map((entry) => {
663
- if (entry.kind !== 'tool' || entry.callId !== block.toolCallId) return entry
664
- return { ...entry, state: block.isError === true ? 'error' as const : 'done' as const, summary, detail }
665
- })
666
- return {
667
- ...view,
668
- entries,
669
- stats: {
670
- ...view.stats,
671
- toolMs: view.stats.toolMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
672
- contextSegments: {
673
- ...view.stats.contextSegments,
674
- tools: view.stats.contextSegments.tools + estimateTokens(rawText),
675
- },
676
- },
677
- }
678
- }
679
- case 'todo/write':
680
- return { ...view, todos: event.data.todos }
681
- case 'turn/start':
682
- // The web todo projection clears on turn/start: a fresh turn's first
683
- // write is the authoritative list, and a stale snapshot must not linger
684
- // through a turn that has not written one yet.
685
- return {
686
- ...view,
687
- busy: true,
688
- busySince: view.busy ? view.busySince : event.time,
689
- todos: [],
690
- stats: { ...view.stats, turns: view.stats.turns + 1 },
691
- }
692
- case 'step/start': {
693
- // A step supersedes the turn's previous step: if that step never
694
- // assembled a message (interrupted), its timing anchors are stale the
695
- // moment the next step opens and are swept here instead of leaking.
696
- const key = `${event.data.turn}:${event.data.step}`
697
- const previous = view.anchors.turnSteps.get(event.data.turn)
698
- if (previous !== undefined && previous !== key) {
699
- view.anchors.stepStart.delete(previous)
700
- view.anchors.firstChunkAt.delete(previous)
701
- }
702
- view.anchors.turnSteps.set(event.data.turn, key)
703
- view.anchors.stepStart.set(key, event.time)
704
- return {
705
- ...view,
706
- streaming: '',
707
- streamingReasoning: '',
708
- stats: { ...view.stats, steps: view.stats.steps + 1 },
709
- }
710
- }
711
- case 'turn/end': {
712
- const reason = event.data.reason
713
- const appended: TranscriptEntry[] = []
714
- if (reason.kind === 'error') {
715
- const recovery = reason.error.code === 'MISSING_CREDENTIAL'
716
- ? ' · open /model to add an API key'
717
- : ''
718
- appended.push({ kind: 'error', text: `${reason.error.code}: ${reason.error.message}${recovery}` })
719
- } else {
720
- // Non-error outcomes deserve their own durable row (the web renders
721
- // distinct max-tokens / abort / interruption nodes); `completed` stays
722
- // silent so an ordinary turn never grows a marker.
723
- const marker = reason.kind === 'aborted'
724
- ? reason.reason.kind === 'user' ? 'turn cancelled by the user' : `turn cancelled (${reason.reason.kind})`
725
- : reason.kind === 'max-tokens'
726
- ? 'turn hit the output-token ceiling (max-tokens)'
727
- : reason.kind === 'blocked'
728
- ? 'turn ended blocked'
729
- : reason.kind === 'interrupted'
730
- ? 'turn was interrupted by a restart'
731
- : undefined
732
- if (marker !== undefined) appended.push({ kind: 'turn-marker', text: marker })
733
- }
734
- // Deliverables ride the turn tail (the web's turnTail chips): the
735
- // turn's mutated files flush as one bounded row, then the set resets.
736
- const files = view.anchors.turnFiles.get(event.data.turn)
737
- view.anchors.turnFiles.delete(event.data.turn)
738
- if (files !== undefined && files.size > 0) appended.push({ kind: 'files', paths: [...files].slice(0, 12) })
739
- // Derivable boundary sweep: the turn is over, so any step/tool anchors
740
- // it left behind (interruptions that never produced their message or
741
- // result) can never be resolved and are reclaimed now.
742
- const stepKey = view.anchors.turnSteps.get(event.data.turn)
743
- if (stepKey !== undefined) {
744
- view.anchors.stepStart.delete(stepKey)
745
- view.anchors.firstChunkAt.delete(stepKey)
746
- view.anchors.turnSteps.delete(event.data.turn)
747
- }
748
- const turnToolSet = view.anchors.turnTools.get(event.data.turn)
749
- if (turnToolSet !== undefined) {
750
- for (const callId of turnToolSet) view.anchors.toolStart.delete(callId)
751
- view.anchors.turnTools.delete(event.data.turn)
752
- }
753
- // Orphaned retry/command rows can never be resolved after the turn
754
- // ends: an aborted retry backoff returns upstream without its
755
- // `llm/retry-started`, and crash repair synthesizes only tool/step/
756
- // turn closers. Left `running` they pin the settled boundary forever,
757
- // so the turn end finalizes them exactly like the anchor sweep above.
758
- let orphans = false
759
- const swept = view.entries.map((entry) => {
760
- if (entry.kind === 'retry' && entry.state === 'running') {
761
- orphans = true
762
- return { ...entry, state: 'done' as const }
763
- }
764
- if (entry.kind === 'command' && entry.state === 'running') {
765
- orphans = true
766
- return { ...entry, state: 'error' as const, summary: 'interrupted before the turn ended' }
767
- }
768
- return entry
769
- })
770
- const entries = orphans ? swept : view.entries
771
- if (appended.length === 0) {
772
- return { ...view, busy: false, busySince: 0, streaming: '', streamingReasoning: '', entries }
773
- }
774
- return {
775
- ...view,
776
- busy: false,
777
- busySince: 0,
778
- streaming: '',
779
- streamingReasoning: '',
780
- entries: [...entries, ...appended],
781
- }
782
- }
783
- case 'llm/retry': {
784
- const data = event.data
785
- return {
786
- ...view,
787
- streaming: '',
788
- streamingReasoning: '',
789
- entries: [...view.entries, {
790
- kind: 'retry',
791
- retryId: data.retryId,
792
- mode: data.mode,
793
- attempt: data.retry,
794
- max: 'maxRetries' in data ? data.maxRetries : data.retry,
795
- code: data.failure.code,
796
- delayMs: data.delayMs,
797
- state: 'running',
798
- }],
799
- }
800
- }
801
- case 'llm/retry-started': {
802
- const data = event.data
803
- const entries = view.entries.map((entry) => {
804
- if (entry.kind !== 'retry' || entry.retryId !== data.retryId) return entry
805
- return { ...entry, state: 'done' as const }
806
- })
807
- return { ...view, entries }
808
- }
809
- case 'sandbox/mode':
810
- // Log-only override switch; last write wins for the status badge.
811
- return { ...view, sandbox: event.data.mode }
812
- case 'goal/change': {
813
- const data = event.data
814
- const clip = (text: string): string => (text.length > 60 ? `${text.slice(0, 59)}…` : text)
815
- if (data.operation === 'clear') {
816
- return {
817
- ...view,
818
- goal: undefined,
819
- entries: [...view.entries, { kind: 'turn-marker', text: '◎ goal cleared' }],
820
- }
821
- }
822
- const goal: GoalFold = {
823
- objective: data.goal.objective,
824
- phase: data.goal.phase,
825
- rounds: data.roundsStarted,
826
- max: data.goal.maxGoalRounds,
827
- blocked: data.goal.blockedReason?.message ?? '',
828
- }
829
- const line = data.operation === 'create'
830
- ? `◎ goal: ${clip(data.goal.objective)}`
831
- : data.operation === 'complete'
832
- ? '◎ goal complete'
833
- : data.operation === 'pause'
834
- ? '◎ goal paused'
835
- : data.operation === 'resume'
836
- ? '◎ goal resumed'
837
- : data.operation === 'block'
838
- ? `◎ goal blocked: ${clip(goal.blocked)}`
839
- : undefined
840
- return {
841
- ...view,
842
- goal,
843
- entries: line === undefined ? view.entries : [...view.entries, { kind: 'turn-marker', text: line }],
844
- }
845
- }
846
- case 'session/title':
847
- // Latest-wins title snapshot, log-only; the status line prefers it.
848
- return { ...view, title: event.data.title }
849
- case 'compaction/summary':
850
- // Remember the shadow price so the matching `compaction/end` row can
851
- // state what the compaction reclaimed. The map is capped so an aborted
852
- // compaction (summary without end) cannot leave an unbounded residue.
853
- if (view.anchors.compactionTokens.size >= MAX_COMPACTION_SUMMARY_RESIDUE) {
854
- const oldest = view.anchors.compactionTokens.keys().next().value
855
- if (oldest !== undefined) view.anchors.compactionTokens.delete(oldest)
856
- }
857
- view.anchors.compactionTokens.set(event.data.compactionId, event.data.shadowedTokenCount)
858
- return view
859
- case 'compaction/prune':
860
- // A model-free prune carries no compaction id; its price serves the next
861
- // `compaction/end` that cannot find a summary price.
862
- return { ...view, anchors: { ...view.anchors, lastPruneTokens: event.data.shadowedTokenCount } }
863
- case 'compaction/end': {
864
- const ok = event.data.error === undefined
865
- const tokens = view.anchors.compactionTokens.get(event.data.compactionId) ?? view.anchors.lastPruneTokens
866
- view.anchors.compactionTokens.delete(event.data.compactionId)
867
- return {
868
- ...view,
869
- entries: [...view.entries, { kind: 'compaction', ok, tokens, error: event.data.error ?? '' }],
870
- }
871
- }
872
- case 'request/context':
873
- // Route capacity, logged only when it changes; last one wins.
874
- return {
875
- ...view,
876
- stats: { ...view.stats, contextWindow: event.data.contextWindow ?? view.stats.contextWindow },
877
- }
878
- case 'request/header': {
879
- // The session's own model record: the latest snapshot's provider/model
880
- // pair, exactly what a resumed TUI restores as the selection, plus the
881
- // effective reasoning effort that snapshot carried (the adapter may
882
- // materialize the model default, which is what the status line shows).
883
- // The snapshot's rendered system prompt is the current system slot, so
884
- // it REPLACES the estimate (an older system prompt is not re-sent).
885
- const config = event.data.header.config
886
- return {
887
- ...view,
888
- model: `${config.provider}/${config.model}`,
889
- stats: {
890
- ...view.stats,
891
- reasoningEffort: config.reasoningEffort === undefined ? '' : String(config.reasoningEffort),
892
- contextSegments: {
893
- ...view.stats.contextSegments,
894
- system: estimateTokens(event.data.header.system ?? ''),
895
- },
896
- },
897
- }
898
- }
899
- case 'plan/mode':
900
- // Whole-value replace; the last one wins (upstream fold semantics).
901
- return { ...view, plan: event.data.active }
902
- case 'permission/preset':
903
- return { ...view, permission: event.data.preset }
904
- case 'command/run': {
905
- const data = event.data
906
- return {
907
- ...view,
908
- entries: [...view.entries, {
909
- kind: 'command',
910
- commandId: data.commandId,
911
- name: data.name,
912
- args: data.args ?? '',
913
- state: 'running',
914
- summary: '',
915
- }],
916
- }
917
- }
918
- case 'command/done': {
919
- const data = event.data
920
- const entries = view.entries.map((entry) => {
921
- if (entry.kind !== 'command' || entry.commandId !== data.commandId) return entry
922
- return {
923
- ...entry,
924
- state: data.kind === 'success' ? 'done' as const : 'error' as const,
925
- summary: boundContextSummary(data.text ?? ''),
926
- }
927
- })
928
- return { ...view, entries }
929
- }
930
- default:
931
- return view
932
- }
933
- }
934
-
935
- /**
936
- * Mutable replay accumulator: folds a persisted log into the identical view
937
- * `projectEvent` would produce, but in near-linear time. Where `projectEvent`
938
- * is copy-on-write — every append/scan rebuilds the whole `entries` array, so
939
- * folding a full log costs O(N²) — the accumulator appends by push, resolves
940
- * id-keyed updates (tool/result, command/done, retry-started) through index
941
- * maps, and tombstones retired pending rows, so the whole log folds in O(N)
942
- * plus one compaction pass when tombstones exist.
943
- *
944
- * Index maps never delete: every appended row registers its index, so an id
945
- * lookup miss provably means no matching row exists and the update is an O(1)
946
- * no-op (a malicious/orphan-heavy log cannot force per-orphan full-array
947
- * scans). Each id maps to ALL of its indices, so a duplicate id updates every
948
- * matching row exactly like the copy-on-write reducer.
949
- *
950
- * @internal Exported only so tests can (a) prove replay ≡ sequential
951
- * `projectEvent` folds and (b) assert the linear complexity deterministically
952
- * via {@link ReplayAccumulator.ops}, which counts entry-level container work
953
- * instead of relying on wall-clock thresholds. No public consumer.
954
- */
955
- export interface ReplayAccumulator {
956
- /** Working entry list; `undefined` marks a retired pending row (tombstone). */
957
- entries: (TranscriptEntry | undefined)[]
958
- /** callId every index into `entries` holding a `tool` row with that id. */
959
- toolIndex: Map<string, number[]>
960
- /** commandId every index into `entries` holding a `command` row with that id. */
961
- commandIndex: Map<string, number[]>
962
- /** retryId every index into `entries` holding a `retry` row with that id. */
963
- retryIndex: Map<string, number[]>
964
- /** messageId every index into `entries` holding a `pending` row with that id. */
965
- pendingIndex: Map<string, number[]>
966
- /** Tombstone count; zero means `entries` is already the final array. */
967
- removedCount: number
968
- /** Mutable inbox id lists, mirroring `view.pending` order per target. */
969
- pendingTurn: string[]
970
- pendingStep: string[]
971
- streaming: string
972
- streamingReasoning: string
973
- todos: readonly TodoItem[]
974
- /** Global tool-call ordinal counter (see `TranscriptView.toolCallOrdinal`). */
975
- toolCallOrdinal: number
976
- busy: boolean
977
- busySince: number
978
- model: string
979
- plan: boolean
980
- permission: string
981
- title: string
982
- sandbox: string
983
- goal: GoalFold | undefined
984
- stats: TranscriptStats
985
- stepStart: Map<string, number>
986
- toolStart: Map<string, number>
987
- firstChunkAt: Map<string, number>
988
- compactionTokens: Map<string, number>
989
- lastPruneTokens: number
990
- turnFiles: Map<number, Set<string>>
991
- turnSteps: Map<number, string>
992
- turnTools: Map<number, Set<string>>
993
- /** Entry-level container operations performed so far (test instrumentation). */
994
- ops: number
995
- }
996
-
997
- /** @internal A fresh replay accumulator whose state mirrors `createTranscriptView()`. */
998
- export function createReplayAccumulator(): ReplayAccumulator {
999
- return {
1000
- entries: [],
1001
- toolIndex: new Map(),
1002
- commandIndex: new Map(),
1003
- retryIndex: new Map(),
1004
- pendingIndex: new Map(),
1005
- removedCount: 0,
1006
- pendingTurn: [],
1007
- pendingStep: [],
1008
- streaming: '',
1009
- streamingReasoning: '',
1010
- todos: [],
1011
- toolCallOrdinal: 0,
1012
- busy: false,
1013
- busySince: 0,
1014
- model: '',
1015
- plan: false,
1016
- permission: '',
1017
- title: '',
1018
- sandbox: '',
1019
- goal: undefined,
1020
- stats: { turns: 0, steps: 0, llmMs: 0, toolMs: 0, usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }, lastPromptTokens: 0, contextWindow: 0, contextSegments: { system: 0, prompt: 0, assistant: 0, thinking: 0, tools: 0 }, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0, reasoningEffort: '' },
1021
- stepStart: new Map(),
1022
- toolStart: new Map(),
1023
- firstChunkAt: new Map(),
1024
- compactionTokens: new Map(),
1025
- lastPruneTokens: 0,
1026
- turnFiles: new Map(),
1027
- turnSteps: new Map(),
1028
- turnTools: new Map(),
1029
- ops: 0,
1030
- }
1031
- }
1032
-
1033
- /** Append one entry (O(1)) and account the push. */
1034
- function appendReplayEntry(acc: ReplayAccumulator, entry: TranscriptEntry): void {
1035
- acc.entries.push(entry)
1036
- acc.ops += 1
1037
- }
1038
-
1039
- /**
1040
- * Get (or create) the index list an id owns. Lists are never removed: every
1041
- * appended row registers its index, so a lookup miss later proves no matching
1042
- * row exists and the caller can no-op in O(1).
1043
- */
1044
- function indexList(map: Map<string, number[]>, id: string): number[] {
1045
- let list = map.get(id)
1046
- if (list === undefined) {
1047
- list = []
1048
- map.set(id, list)
1049
- }
1050
- return list
1051
- }
1052
-
1053
- /**
1054
- * Finalize replay rows the ended turn left `running`, mirroring the reducer's
1055
- * turn-end orphan sweep: an orphaned retry settles `done`, an orphaned command
1056
- * settles `error` with an interruption note. Only the id-indexed rows are
1057
- * visited, so the sweep stays O(retries+commands of the log), never a scan.
1058
- */
1059
- function finalizeReplayOrphans(acc: ReplayAccumulator): void {
1060
- for (const list of acc.retryIndex.values()) {
1061
- for (const index of list) {
1062
- const entry = acc.entries[index]
1063
- if (entry !== undefined && entry.kind === 'retry' && entry.state === 'running') {
1064
- acc.entries[index] = { ...entry, state: 'done' }
1065
- }
1066
- }
1067
- }
1068
- for (const list of acc.commandIndex.values()) {
1069
- for (const index of list) {
1070
- const entry = acc.entries[index]
1071
- if (entry !== undefined && entry.kind === 'command' && entry.state === 'running') {
1072
- acc.entries[index] = { ...entry, state: 'error', summary: 'interrupted before the turn ended' }
1073
- }
1074
- }
1075
- }
1076
- }
1077
-
1078
- /**
1079
- * Apply an id-keyed update to every row that registered the id, mirroring the
1080
- * copy-on-write reducer's full-array map semantics (all matching rows update,
1081
- * in order). Each registered index is O(1), so a duplicate id costs
1082
- * O(#duplicates) — never a full-array scan. The kind+id re-check is defensive:
1083
- * registered indices are valid by construction, because tool/command/retry
1084
- * rows are never removed and tombstones never shift indices.
1085
- */
1086
- function updateReplayById<T extends TranscriptEntry>(
1087
- acc: ReplayAccumulator,
1088
- map: Map<string, number[]>,
1089
- id: string,
1090
- isMatch: (entry: T) => boolean,
1091
- update: (entry: T) => T,
1092
- ): void {
1093
- const list = map.get(id)
1094
- if (list === undefined) return // miss provably means no matching row
1095
- for (const index of list) {
1096
- const entry = acc.entries[index]
1097
- if (entry === undefined || !isMatch(entry as T)) continue
1098
- acc.entries[index] = update(entry as T)
1099
- acc.ops += 1
1100
- }
1101
- }
1102
-
1103
- /** Tombstone a retired pending row, keeping every other index stable. */
1104
- function retireReplayEntry(acc: ReplayAccumulator, index: number): void {
1105
- if (acc.entries[index] !== undefined) {
1106
- acc.entries[index] = undefined
1107
- acc.removedCount += 1
1108
- acc.ops += 1
1109
- }
1110
- }
1111
-
1112
- /**
1113
- * Fold one session event into a replay accumulator. This mirrors
1114
- * {@link projectEvent} case for case — same stats arithmetic, same anchor
1115
- * set/delete behavior, same entry shapes so the finished view is identical
1116
- * to a sequential fold; only the `entries` container operations are mutable.
1117
- *
1118
- * @internal Test-instrumentation path; `projectEvents` is the public entry.
1119
- * @returns whether the event changed the accumulated state — the live store
1120
- * stays silent and keeps its snapshot identity for ignored events, exactly
1121
- * like the copy-on-write reducer returning its input view unchanged.
1122
- */
1123
- export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent): boolean {
1124
- switch (event.type) {
1125
- case 'user/message': {
1126
- const message = event.data
1127
- for (const target of ['next-turn', 'next-step'] as const) {
1128
- const ids = target === 'next-turn' ? acc.pendingTurn : acc.pendingStep
1129
- const index = ids.indexOf(message.id)
1130
- acc.ops += index < 0 ? ids.length : index + 1
1131
- if (index < 0) continue
1132
- ids.splice(index, 1)
1133
- acc.ops += 1
1134
- // Retire every pending row carrying this message id (duplicate ids
1135
- // included), exactly like the reducer's full-array filter.
1136
- const list = acc.pendingIndex.get(message.id)
1137
- if (list !== undefined) {
1138
- for (const entryIndex of list) retireReplayEntry(acc, entryIndex)
1139
- acc.ops += 1
1140
- }
1141
- }
1142
- const text = textOf(message.content)
1143
- const images = imagesOf(message.content)
1144
- if (message.source.kind === 'user') {
1145
- appendReplayEntry(acc, { kind: 'user', text, notice: false, ...(images.length === 0 ? {} : { images }) })
1146
- acc.stats = {
1147
- ...acc.stats,
1148
- contextSegments: {
1149
- ...acc.stats.contextSegments,
1150
- prompt: acc.stats.contextSegments.prompt + estimateTokens(text),
1151
- },
1152
- }
1153
- return true
1154
- }
1155
- const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
1156
- ? message.source.summary
1157
- : message.source.kind
1158
- const summary = boundContextSummary(notice)
1159
- appendReplayEntry(acc, { kind: 'user', text: summary, notice: true })
1160
- acc.stats = {
1161
- ...acc.stats,
1162
- contextSegments: {
1163
- ...acc.stats.contextSegments,
1164
- system: acc.stats.contextSegments.system + estimateTokens(summary),
1165
- },
1166
- }
1167
- return true
1168
- }
1169
- case 'agent/inbox/spliced': {
1170
- const { target, start, removedCount = 0, inserted } = event.data
1171
- const ids = target === 'next-turn' ? acc.pendingTurn : acc.pendingStep
1172
- const removed = ids.slice(start, start + removedCount)
1173
- acc.ops += removed.length
1174
- ids.splice(start, removedCount)
1175
- acc.ops += removed.length
1176
- for (const id of removed) {
1177
- const list = acc.pendingIndex.get(id)
1178
- if (list === undefined) continue
1179
- for (const entryIndex of list) {
1180
- const entry = acc.entries[entryIndex]
1181
- if (entry !== undefined && entry.kind === 'pending' && entry.target === target) {
1182
- retireReplayEntry(acc, entryIndex)
1183
- }
1184
- }
1185
- }
1186
- // Mirror the reducer's in-place order: inserted ids join at the splice
1187
- // position (upstream `splice(start, removedCount, ...inserted)`), never
1188
- // at the tail the id list must stay coordinate-compatible with every
1189
- // later inbox event.
1190
- ids.splice(start, 0, ...inserted.map(message => message.id))
1191
- for (const message of inserted) {
1192
- const images = imagesOf(message.content)
1193
- appendReplayEntry(acc, { kind: 'pending', messageId: message.id, target, text: pendingText(message.content), ...(images.length === 0 ? {} : { images }) })
1194
- indexList(acc.pendingIndex, message.id).push(acc.entries.length - 1)
1195
- acc.ops += 1
1196
- }
1197
- return true
1198
- }
1199
- case 'assistant/chunk': {
1200
- const chunk = event.data.chunk
1201
- const key = `${event.data.turn}:${event.data.step}`
1202
- const delta = chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' ? chunk.text : ''
1203
- if (delta !== '' && !acc.firstChunkAt.has(key)) {
1204
- acc.firstChunkAt.set(key, event.time)
1205
- const started = acc.stepStart.get(key)
1206
- if (started !== undefined) {
1207
- acc.stats = {
1208
- ...acc.stats,
1209
- ttftMs: acc.stats.ttftMs + Math.max(0, event.time - started),
1210
- ttftSteps: acc.stats.ttftSteps + 1,
1211
- }
1212
- }
1213
- }
1214
- if (chunk.type === 'text-delta') {
1215
- acc.streaming = appendStreamingTail(acc.streaming, chunk.text)
1216
- return true
1217
- }
1218
- if (chunk.type === 'reasoning-delta') {
1219
- acc.streamingReasoning = appendStreamingTail(acc.streamingReasoning, chunk.text)
1220
- return true
1221
- }
1222
- return false
1223
- }
1224
- case 'assistant/message': {
1225
- const key = `${event.data.turn}:${event.data.step}`
1226
- const started = acc.stepStart.get(key)
1227
- acc.stepStart.delete(key)
1228
- const firstChunk = acc.firstChunkAt.get(key)
1229
- acc.firstChunkAt.delete(key)
1230
- if (acc.turnSteps.get(event.data.turn) === key) acc.turnSteps.delete(event.data.turn)
1231
- const usage = event.data.usage
1232
- const totals = acc.stats.usage
1233
- const text = textOf(event.data.message.content)
1234
- const reasoning = reasoningOf(event.data.message.content)
1235
- acc.streaming = ''
1236
- acc.streamingReasoning = ''
1237
- appendReplayEntry(acc, { kind: 'assistant', text, reasoning, interrupted: event.data.interrupted === true ? true : undefined })
1238
- acc.stats = {
1239
- ...acc.stats,
1240
- llmMs: acc.stats.llmMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
1241
- usage: usage === undefined ? totals : {
1242
- inputTokens: totals.inputTokens + usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
1243
- outputTokens: totals.outputTokens + usage.outputTokens,
1244
- cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0),
1245
- },
1246
- lastPromptTokens: usage === undefined ? acc.stats.lastPromptTokens
1247
- : usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
1248
- decodeMs: acc.stats.decodeMs + (firstChunk === undefined ? 0 : Math.max(0, event.time - firstChunk)),
1249
- decodeTokens: acc.stats.decodeTokens + (firstChunk === undefined || usage === undefined ? 0 : usage.outputTokens),
1250
- contextSegments: {
1251
- ...acc.stats.contextSegments,
1252
- thinking: acc.stats.contextSegments.thinking + estimateTokens(reasoning),
1253
- assistant: acc.stats.contextSegments.assistant + estimateTokens(text),
1254
- },
1255
- }
1256
- return true
1257
- }
1258
- case 'tool/call': {
1259
- const data = event.data
1260
- acc.toolStart.set(data.callId, event.time)
1261
- const turnTools = acc.turnTools.get(data.turn) ?? new Set<string>()
1262
- turnTools.add(data.callId)
1263
- acc.turnTools.set(data.turn, turnTools)
1264
- acc.toolCallOrdinal += 1
1265
- appendReplayEntry(acc, {
1266
- kind: 'tool',
1267
- callId: data.callId,
1268
- ordinal: acc.toolCallOrdinal,
1269
- name: data.name,
1270
- arguments: data.arguments,
1271
- preview: toolArgumentsPreview(data.arguments, data.name),
1272
- prompt: toolPromptPreview(data.name, data.arguments),
1273
- state: 'running',
1274
- summary: '',
1275
- detail: undefined,
1276
- })
1277
- indexList(acc.toolIndex, data.callId).push(acc.entries.length - 1)
1278
- acc.stats = {
1279
- ...acc.stats,
1280
- contextSegments: {
1281
- ...acc.stats.contextSegments,
1282
- tools: acc.stats.contextSegments.tools
1283
- + (typeof data.arguments === 'string' ? estimateTokens(data.arguments) : 0),
1284
- },
1285
- }
1286
- return true
1287
- }
1288
- case 'tool/result': {
1289
- const block = event.data.message.content[0]
1290
- const started = acc.toolStart.get(block.toolCallId)
1291
- acc.toolStart.delete(block.toolCallId)
1292
- const turnTools = acc.turnTools.get(event.data.turn)
1293
- if (turnTools !== undefined) {
1294
- turnTools.delete(block.toolCallId)
1295
- if (turnTools.size === 0) acc.turnTools.delete(event.data.turn)
1296
- }
1297
- const rawText = textOf(block.content)
1298
- const summary = boundContextSummary(rawText)
1299
- const detail = toolResultDetail(event.data.meta, rawText)
1300
- if (detail?.kind === 'diff') {
1301
- const set = acc.turnFiles.get(event.data.turn) ?? new Set<string>()
1302
- for (const diff of detail.diffs) set.add(diff.path)
1303
- acc.turnFiles.set(event.data.turn, set)
1304
- }
1305
- const update = (entry: ToolEntry): ToolEntry => ({
1306
- ...entry,
1307
- state: block.isError === true ? 'error' as const : 'done' as const,
1308
- summary,
1309
- detail,
1310
- })
1311
- // Every matching row updates (duplicate callIds included); an id with no
1312
- // registered index is a provable no-op — no full-array fallback scan.
1313
- updateReplayById<ToolEntry>(acc, acc.toolIndex, block.toolCallId, entry => entry.callId === block.toolCallId, update)
1314
- acc.stats = {
1315
- ...acc.stats,
1316
- toolMs: acc.stats.toolMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
1317
- contextSegments: {
1318
- ...acc.stats.contextSegments,
1319
- tools: acc.stats.contextSegments.tools + estimateTokens(rawText),
1320
- },
1321
- }
1322
- return true
1323
- }
1324
- case 'todo/write':
1325
- acc.todos = event.data.todos
1326
- return true
1327
- case 'turn/start': {
1328
- const wasBusy = acc.busy
1329
- acc.busy = true
1330
- acc.busySince = wasBusy ? acc.busySince : event.time
1331
- acc.todos = []
1332
- acc.stats = { ...acc.stats, turns: acc.stats.turns + 1 }
1333
- return true
1334
- }
1335
- case 'step/start': {
1336
- const key = `${event.data.turn}:${event.data.step}`
1337
- const previous = acc.turnSteps.get(event.data.turn)
1338
- if (previous !== undefined && previous !== key) {
1339
- acc.stepStart.delete(previous)
1340
- acc.firstChunkAt.delete(previous)
1341
- }
1342
- acc.turnSteps.set(event.data.turn, key)
1343
- acc.stepStart.set(key, event.time)
1344
- acc.streaming = ''
1345
- acc.streamingReasoning = ''
1346
- acc.stats = { ...acc.stats, steps: acc.stats.steps + 1 }
1347
- return true
1348
- }
1349
- case 'turn/end': {
1350
- const reason = event.data.reason
1351
- const appended: TranscriptEntry[] = []
1352
- acc.streamingReasoning = ''
1353
- acc.streaming = ''
1354
- if (reason.kind === 'error') {
1355
- const recovery = reason.error.code === 'MISSING_CREDENTIAL'
1356
- ? ' · open /model to add an API key'
1357
- : ''
1358
- appended.push({ kind: 'error', text: `${reason.error.code}: ${reason.error.message}${recovery}` })
1359
- } else {
1360
- const marker = reason.kind === 'aborted'
1361
- ? reason.reason.kind === 'user' ? 'turn cancelled by the user' : `turn cancelled (${reason.reason.kind})`
1362
- : reason.kind === 'max-tokens'
1363
- ? 'turn hit the output-token ceiling (max-tokens)'
1364
- : reason.kind === 'blocked'
1365
- ? 'turn ended blocked'
1366
- : reason.kind === 'interrupted'
1367
- ? 'turn was interrupted by a restart'
1368
- : undefined
1369
- if (marker !== undefined) appended.push({ kind: 'turn-marker', text: marker })
1370
- }
1371
- const files = acc.turnFiles.get(event.data.turn)
1372
- acc.turnFiles.delete(event.data.turn)
1373
- if (files !== undefined && files.size > 0) appended.push({ kind: 'files', paths: [...files].slice(0, 12) })
1374
- const stepKey = acc.turnSteps.get(event.data.turn)
1375
- if (stepKey !== undefined) {
1376
- acc.stepStart.delete(stepKey)
1377
- acc.firstChunkAt.delete(stepKey)
1378
- acc.turnSteps.delete(event.data.turn)
1379
- }
1380
- const turnToolSet = acc.turnTools.get(event.data.turn)
1381
- if (turnToolSet !== undefined) {
1382
- for (const callId of turnToolSet) acc.toolStart.delete(callId)
1383
- acc.turnTools.delete(event.data.turn)
1384
- }
1385
- finalizeReplayOrphans(acc)
1386
- acc.busy = false
1387
- acc.busySince = 0
1388
- for (const entry of appended) appendReplayEntry(acc, entry)
1389
- return true
1390
- }
1391
- case 'llm/retry': {
1392
- const data = event.data
1393
- acc.streaming = ''
1394
- acc.streamingReasoning = ''
1395
- appendReplayEntry(acc, {
1396
- kind: 'retry',
1397
- retryId: data.retryId,
1398
- mode: data.mode,
1399
- attempt: data.retry,
1400
- max: 'maxRetries' in data ? data.maxRetries : data.retry,
1401
- code: data.failure.code,
1402
- delayMs: data.delayMs,
1403
- state: 'running',
1404
- })
1405
- indexList(acc.retryIndex, data.retryId).push(acc.entries.length - 1)
1406
- return true
1407
- }
1408
- case 'llm/retry-started': {
1409
- const data = event.data
1410
- updateReplayById<RetryEntry>(acc, acc.retryIndex, data.retryId, entry => entry.retryId === data.retryId, entry => ({ ...entry, state: 'done' as const }))
1411
- return true
1412
- }
1413
- case 'sandbox/mode':
1414
- acc.sandbox = event.data.mode
1415
- return true
1416
- case 'goal/change': {
1417
- const data = event.data
1418
- const clip = (text: string): string => (text.length > 60 ? `${text.slice(0, 59)}…` : text)
1419
- if (data.operation === 'clear') {
1420
- acc.goal = undefined
1421
- appendReplayEntry(acc, { kind: 'turn-marker', text: '◎ goal cleared' })
1422
- return true
1423
- }
1424
- const goal: GoalFold = {
1425
- objective: data.goal.objective,
1426
- phase: data.goal.phase,
1427
- rounds: data.roundsStarted,
1428
- max: data.goal.maxGoalRounds,
1429
- blocked: data.goal.blockedReason?.message ?? '',
1430
- }
1431
- const line = data.operation === 'create'
1432
- ? `◎ goal: ${clip(data.goal.objective)}`
1433
- : data.operation === 'complete'
1434
- ? ' goal complete'
1435
- : data.operation === 'pause'
1436
- ? ' goal paused'
1437
- : data.operation === 'resume'
1438
- ? '◎ goal resumed'
1439
- : data.operation === 'block'
1440
- ? `◎ goal blocked: ${clip(goal.blocked)}`
1441
- : undefined
1442
- acc.goal = goal
1443
- if (line !== undefined) appendReplayEntry(acc, { kind: 'turn-marker', text: line })
1444
- return true
1445
- }
1446
- case 'session/title':
1447
- acc.title = event.data.title
1448
- return true
1449
- case 'compaction/summary':
1450
- if (acc.compactionTokens.size >= MAX_COMPACTION_SUMMARY_RESIDUE) {
1451
- const oldest = acc.compactionTokens.keys().next().value
1452
- if (oldest !== undefined) acc.compactionTokens.delete(oldest)
1453
- }
1454
- acc.compactionTokens.set(event.data.compactionId, event.data.shadowedTokenCount)
1455
- return true
1456
- case 'compaction/prune':
1457
- acc.lastPruneTokens = event.data.shadowedTokenCount
1458
- return true
1459
- case 'compaction/end': {
1460
- const ok = event.data.error === undefined
1461
- const tokens = acc.compactionTokens.get(event.data.compactionId) ?? acc.lastPruneTokens
1462
- acc.compactionTokens.delete(event.data.compactionId)
1463
- appendReplayEntry(acc, { kind: 'compaction', ok, tokens, error: event.data.error ?? '' })
1464
- return true
1465
- }
1466
- case 'request/context':
1467
- acc.stats = { ...acc.stats, contextWindow: event.data.contextWindow ?? acc.stats.contextWindow }
1468
- return true
1469
- case 'request/header': {
1470
- const config = event.data.header.config
1471
- acc.model = `${config.provider}/${config.model}`
1472
- acc.stats = {
1473
- ...acc.stats,
1474
- reasoningEffort: config.reasoningEffort === undefined ? '' : String(config.reasoningEffort),
1475
- contextSegments: {
1476
- ...acc.stats.contextSegments,
1477
- system: estimateTokens(event.data.header.system ?? ''),
1478
- },
1479
- }
1480
- return true
1481
- }
1482
- case 'plan/mode':
1483
- acc.plan = event.data.active
1484
- return true
1485
- case 'permission/preset':
1486
- acc.permission = event.data.preset
1487
- return true
1488
- case 'command/run': {
1489
- const data = event.data
1490
- appendReplayEntry(acc, {
1491
- kind: 'command',
1492
- commandId: data.commandId,
1493
- name: data.name,
1494
- args: data.args ?? '',
1495
- state: 'running',
1496
- summary: '',
1497
- })
1498
- indexList(acc.commandIndex, data.commandId).push(acc.entries.length - 1)
1499
- return true
1500
- }
1501
- case 'command/done': {
1502
- const data = event.data
1503
- const update = (candidate: CommandEntry): CommandEntry => ({
1504
- ...candidate,
1505
- state: data.kind === 'success' ? 'done' as const : 'error' as const,
1506
- summary: boundContextSummary(data.text ?? ''),
1507
- })
1508
- updateReplayById<CommandEntry>(acc, acc.commandIndex, data.commandId, entry => entry.commandId === data.commandId, update)
1509
- return true
1510
- }
1511
- default:
1512
- return false
1513
- }
1514
- }
1515
-
1516
- /**
1517
- * Materialize the accumulated fold as a `TranscriptView`, compacting any
1518
- * retired tombstones. The anchors maps are handed through as-is (their
1519
- * content is identical to a sequential fold's).
1520
- *
1521
- * @internal Test-instrumentation path; `projectEvents` is the public entry.
1522
- */
1523
- export function finishReplay(acc: ReplayAccumulator): TranscriptView {
1524
- return materializeReplayView(acc, false)
1525
- }
1526
-
1527
- /**
1528
- * Materialize the accumulated fold as a fresh immutable snapshot for the
1529
- * live store. Unlike {@link finishReplay} — the one-shot replay entry, which
1530
- * hands the accumulator's own arrays through because the accumulator is
1531
- * discarded — every array a renderer can hold is copied here, so later
1532
- * folds never mutate a snapshot already handed out. Same fields, same
1533
- * tombstone compaction.
1534
- *
1535
- * @internal Live-store path; `projectEvents` is the public entry.
1536
- */
1537
- export function snapshotReplayView(acc: ReplayAccumulator): TranscriptView {
1538
- return materializeReplayView(acc, true)
1539
- }
1540
-
1541
- /** Field-for-field materialization; `copy` selects snapshot array isolation. */
1542
- function materializeReplayView(acc: ReplayAccumulator, copy: boolean): TranscriptView {
1543
- const entries: readonly TranscriptEntry[] = acc.removedCount === 0
1544
- ? (copy ? [...acc.entries] : acc.entries) as TranscriptEntry[]
1545
- : acc.entries.filter((entry): entry is TranscriptEntry => entry !== undefined)
1546
- if (acc.removedCount > 0) acc.ops += acc.entries.length
1547
- return {
1548
- entries,
1549
- streaming: acc.streaming,
1550
- streamingReasoning: acc.streamingReasoning,
1551
- todos: acc.todos,
1552
- toolCallOrdinal: acc.toolCallOrdinal,
1553
- busy: acc.busy,
1554
- busySince: acc.busySince,
1555
- model: acc.model,
1556
- plan: acc.plan,
1557
- permission: acc.permission,
1558
- title: acc.title,
1559
- sandbox: acc.sandbox,
1560
- goal: acc.goal,
1561
- pending: { 'next-turn': [...acc.pendingTurn], 'next-step': [...acc.pendingStep] },
1562
- stats: acc.stats,
1563
- // Handed-out views get their own anchors snapshot: the accumulator keeps
1564
- // folding its live containers, and no consumer may observe that.
1565
- anchors: {
1566
- stepStart: new Map(acc.stepStart),
1567
- toolStart: new Map(acc.toolStart),
1568
- firstChunkAt: new Map(acc.firstChunkAt),
1569
- compactionTokens: new Map(acc.compactionTokens),
1570
- lastPruneTokens: acc.lastPruneTokens,
1571
- turnFiles: new Map([...acc.turnFiles].map(([turn, files]) => [turn, new Set(files)])),
1572
- turnSteps: new Map(acc.turnSteps),
1573
- turnTools: new Map([...acc.turnTools].map(([turn, tools]) => [turn, new Set(tools)])),
1574
- },
1575
- }
1576
- }
1577
-
1578
- /**
1579
- * Fold a replayed event history into one view.
1580
- *
1581
- * Folding is near-linear in the log size: the mutable replay accumulator
1582
- * appends in place and resolves id-keyed updates through index maps, so a
1583
- * long persisted session replays without the O(N²) copy-on-write rebuilds a
1584
- * naive sequential fold would incur. The result is identical to folding
1585
- * {@link projectEvent} per event in order.
1586
- * @param events - events in `seq` order.
1587
- * @returns the folded view.
1588
- */
1589
- export function projectEvents(events: readonly SessionEvent[]): TranscriptView {
1590
- const acc = createReplayAccumulator()
1591
- for (const event of events) replayProjectEvent(acc, event)
1592
- return finishReplay(acc)
1593
- }
1594
-
1595
- /**
1596
- * The append-only flush boundary for a transcript view: the count of entries
1597
- * no later event can remove. Entries at or beyond this index are mutable and
1598
- * must stay in the live tree.
1599
- *
1600
- * `pending` rows are excluded even though they are not a running tool/retry:
1601
- * the inbox claims or cancels them durably (`agent/inbox/spliced` removals,
1602
- * `user/message` retirement), and an append-only `<Static>` flush cannot
1603
- * erase a row that vanishes from the view the retired row would ghost on
1604
- * screen until the next source-backed replay. Running commands join the
1605
- * mutable boundary for the same reason in reverse: `command/done` mutates the
1606
- * row's state/summary, so a flushed row would keep its stale running mark
1607
- * until a resize-triggered replay. Everything else (including a completed
1608
- * tail) is final: later events only APPEND new rows.
1609
- * @param entries - the view's transcript entries in order.
1610
- * @returns the count of entries safe to flush (0 for an empty transcript).
1611
- */
1612
- export function settledEntryCount(entries: readonly TranscriptEntry[]): number {
1613
- for (let index = 0; index < entries.length; index++) {
1614
- const entry = entries[index]
1615
- if (entry.kind === 'pending') return index
1616
- if (entry.kind === 'tool' && entry.state === 'running') return index
1617
- if (entry.kind === 'retry' && entry.state === 'running') return index
1618
- if (entry.kind === 'command' && entry.state === 'running') return index
1619
- }
1620
- return entries.length
1621
- }
1
+ /**
2
+ * Pure session-event-to-view projection for the TUI transcript: one reducer
3
+ * over {@link SessionEvent}s producing the ordered entries the renderer draws.
4
+ * Rendering never reads the session directly — this module owns the view
5
+ * model, so tests drive it with plain event arrays.
6
+ *
7
+ * @module @deepseek-ai/dsh-tui/render/projection
8
+ */
9
+
10
+ import { assistantStreamFirstTokenTime, boundContextSummary, isTokenDelta, type ContentBlock, type FileBlock, type ImageBlock, type MessageId, type StreamChunk } from '@deepseek-ai/dsh-llm'
11
+ import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment'
12
+ import type { SessionEvent } from '@deepseek-ai/dsh-session'
13
+ import type { TodoItem } from '@deepseek-ai/dsh-tool-todo'
14
+ import { graphemeWidth, splitGraphemes } from './width.ts'
15
+ // Type-only imports merge the plugin-owned SessionEventMap variants
16
+ // (agent/inbox/spliced, command/*, compaction/*, goal/change, llm/retry*,
17
+ // plan/mode, permission/preset, sandbox/mode, session/title) into the union
18
+ // this reducer switches on.
19
+ import type {} from '@deepseek-ai/dsh-agent'
20
+ import type {} from '@deepseek-ai/dsh-commands'
21
+ import type {} from '@deepseek-ai/dsh-compaction'
22
+ import type {} from '@deepseek-ai/dsh-goal'
23
+ import type {} from '@deepseek-ai/dsh-llm-retry'
24
+ import type {} from '@deepseek-ai/dsh-plan-mode'
25
+ import type {} from '@deepseek-ai/dsh-permission-presets'
26
+ import type {} from '@deepseek-ai/dsh-schedule'
27
+ import type {} from '@deepseek-ai/dsh-sandbox-policy'
28
+ import type {} from '@deepseek-ai/dsh-session-title'
29
+ // The subagent package's durable catalog event joins the union the same way
30
+ // (parent-owned facts; the fold itself lives with the live feed).
31
+ import type {} from '@deepseek-ai/dsh-subagent'
32
+ import { toolArgumentsPreview, toolPromptPreview } from './tool-preview.ts'
33
+ import { toolResultDetail, type ToolDetail } from './tool-detail.ts'
34
+
35
+ /** In-flight UI buffers are tails; the assembled assistant message is authoritative. */
36
+ const MAX_STREAMING_CHARS = 65_536
37
+
38
+ /**
39
+ * Upper bound on remembered `compaction/summary` shadow prices waiting for a
40
+ * matching `compaction/end`. Compactions are sequential and rare, so a few
41
+ * slots suffice; an aborted compaction (summary without end) otherwise leaves
42
+ * an unbounded residue in `anchors.compactionTokens`. An evicted price
43
+ * degrades to the documented `lastPruneTokens` fallback, exactly like a
44
+ * missing summary.
45
+ */
46
+ const MAX_COMPACTION_SUMMARY_RESIDUE = 16
47
+
48
+ /** Append one delta without retaining an unbounded duplicate of the live reply. */
49
+ function appendStreamingTail(current: string, delta: string): string {
50
+ const next = current + delta
51
+ return next.length <= MAX_STREAMING_CHARS ? next : next.slice(-MAX_STREAMING_CHARS)
52
+ }
53
+
54
+ /** One user prompt line. */
55
+ export interface UserEntry {
56
+ kind: 'user'
57
+ /** Joined text blocks of the user message. */
58
+ text: string
59
+ /** True for collapsed injected context (plugin/continuation notices), which
60
+ * the renderer marks with a dim ↳ instead of the user ❯ prompt. */
61
+ notice: boolean
62
+ /** Durable image references carried by this prompt. */
63
+ images?: readonly ImageBlock['attachment'][]
64
+ /** Durable file references carried by this prompt (0.1.5 file blocks). */
65
+ files?: readonly FileAttachmentRef[]
66
+ }
67
+
68
+ /** One user message waiting in the agent inbox (the web's queued-message row). */
69
+ export interface PendingEntry {
70
+ kind: 'pending'
71
+ /** Stable message identity shared with the durable `user/message` that retires it. */
72
+ messageId: MessageId
73
+ /** Which inbox list holds the message: steering is consumed at the next step boundary. */
74
+ target: 'next-turn' | 'next-step'
75
+ /** Full message text — Codex PendingSteer renders queued prompts exactly like user rows. */
76
+ text: string
77
+ /** Durable image references queued with this prompt. */
78
+ images?: readonly ImageBlock['attachment'][]
79
+ /** Durable file references queued with this prompt (0.1.5 file blocks). */
80
+ files?: readonly FileAttachmentRef[]
81
+ }
82
+
83
+ /** One authoritative assembled assistant reply. */
84
+ export interface AssistantEntry {
85
+ kind: 'assistant'
86
+ /** Joined text blocks of the assistant message. */
87
+ text: string
88
+ /** Joined reasoning blocks from the same assembled message. */
89
+ reasoning: string
90
+ /** True when a cancelled stream's delivered prefix was finalized as this
91
+ * entry (rc.8 `assistant/message.interrupted`) — rendered with a marker. */
92
+ interrupted?: true
93
+ }
94
+
95
+ /** One model-requested tool invocation and its settled state. */
96
+ export interface ToolEntry {
97
+ kind: 'tool'
98
+ /** Correlation id shared with the matching `tool/result`. */
99
+ callId: string
100
+ /**
101
+ * Global tool-call ordinal across the whole transcript (1, 2, 3…, never
102
+ * reset between turns). The tool-card badge and every error line that
103
+ * references the failed call share this number, so "call N" in an error
104
+ * always names the exact card the badge shows.
105
+ */
106
+ ordinal: number
107
+ /** Tool name as the model addressed it. */
108
+ name: string
109
+ /** Raw arguments JSON string exactly as the model produced it. */
110
+ arguments: string
111
+ /** Bounded human-meaningful arguments preview for the tool card. */
112
+ preview: string
113
+ /** Bounded delegation prompt (subagent cards' second row), '' when none. */
114
+ prompt: string
115
+ /** Execution state; `running` until the paired result lands. */
116
+ state: 'running' | 'done' | 'error'
117
+ /** Bounded first text block of the result, empty until it lands. */
118
+ summary: string
119
+ /**
120
+ * Bounded expansion payload for the verbose transcript (Ctrl+O), derived
121
+ * from the tool's persisted presentation metadata; undefined until the
122
+ * result lands and only when something renderable exists.
123
+ */
124
+ detail: ToolDetail | undefined
125
+ }
126
+
127
+ /** One slash-command execution dispatched through `ctx.commands`. */
128
+ export interface CommandEntry {
129
+ kind: 'command'
130
+ /** Pairing id shared with the matching `command/done`. */
131
+ commandId: string
132
+ /** Lowercase command name without the leading slash. */
133
+ name: string
134
+ /** Verbatim text following the command name. */
135
+ args: string
136
+ /** Execution state; `running` until the paired lifecycle event lands. */
137
+ state: 'running' | 'done' | 'error'
138
+ /** Handler outcome text, empty until it lands. */
139
+ summary: string
140
+ }
141
+
142
+ /** One turn-level failure surfaced from `turn/end`. */
143
+ export interface ErrorEntry {
144
+ kind: 'error'
145
+ /** `code: message` of the failure. */
146
+ text: string
147
+ }
148
+
149
+ /** One non-error turn outcome surfaced from `turn/end`. */
150
+ export interface TurnMarkerEntry {
151
+ kind: 'turn-marker'
152
+ /** Human-readable outcome line, dim-rendered. */
153
+ text: string
154
+ }
155
+
156
+ /** One completed compaction lifecycle surfaced from `compaction/end`. */
157
+ export interface CompactionEntry {
158
+ kind: 'compaction'
159
+ /** True when the compaction completed, false when it failed. */
160
+ ok: boolean
161
+ /** Heuristic tokens shadowed by the compaction (summary or prune price). */
162
+ tokens: number
163
+ /** Failure text when `ok` is false, empty otherwise. */
164
+ error: string
165
+ }
166
+
167
+ /** One provider-routed model-request retry (the `llm/retry` pair). */
168
+ export interface RetryEntry {
169
+ kind: 'retry'
170
+ /** Correlation id shared with the matching `llm/retry-started`. */
171
+ retryId: string
172
+ /** Retry policy mode from the event: `always` has no attempt cap. */
173
+ mode: 'normal' | 'always'
174
+ /** Attempt ordinal and its cap. */
175
+ attempt: number
176
+ max: number
177
+ /** Failure code that triggered the retry. */
178
+ code: string
179
+ /** Backoff wait before the next attempt, in ms. */
180
+ delayMs: number
181
+ /**
182
+ * `running` while the backoff waits, `done` once the attempt started — or
183
+ * when the turn ended first (the turn-end sweep finalizes orphans so they
184
+ * never pin the settled boundary).
185
+ */
186
+ state: 'running' | 'done'
187
+ }
188
+
189
+ /** Turn-tail deliverables: files mutated by the turn's diff-bearing tools. */
190
+ export interface FilesEntry {
191
+ kind: 'files'
192
+ /** Unique mutated paths in call order, bounded. */
193
+ paths: readonly string[]
194
+ }
195
+
196
+ /** Ordered transcript items the renderer draws. */
197
+ export type TranscriptEntry = UserEntry | PendingEntry | AssistantEntry | ToolEntry | CommandEntry | ErrorEntry | TurnMarkerEntry | CompactionEntry | RetryEntry | FilesEntry
198
+
199
+ /** The live goal the status line badges, folded from `goal/change`. */
200
+ export interface GoalFold {
201
+ /** Human-requested completion objective. */
202
+ objective: string
203
+ /** Durable lifecycle phase. */
204
+ phase: 'active' | 'paused' | 'blocked' | 'complete'
205
+ /** Highest admitted continuation round and its cap. */
206
+ rounds: number
207
+ max: number
208
+ /** Blocked explanation, empty outside the blocked phase. */
209
+ blocked: string
210
+ }
211
+
212
+ /** Cumulative token accounting folded from `assistant/message` usage reports. */
213
+ export interface UsageTotals {
214
+ /** Prompt-side billed tokens: `inputTokens` plus both cache buckets. */
215
+ inputTokens: number
216
+ /** Completion-side tokens over the whole log. */
217
+ outputTokens: number
218
+ /** Cache-read tokens over the whole log (0 when the adapter reports none). */
219
+ cacheReadTokens: number
220
+ }
221
+
222
+ /**
223
+ * Estimated used tokens per context content type, folded from transcript
224
+ * events via {@link estimateTokens}. The segmented context bar's composition
225
+ * source: proportions across types are meaningful, absolute values are not
226
+ * (they never touch billing or the reported `lastPromptTokens`).
227
+ */
228
+ export interface ContextSegments {
229
+ /** Rendered system-prompt text (latest `request/header`) plus injected-context notices. */
230
+ system: number
231
+ /** Direct human prompts (durable `user/message` rows). */
232
+ prompt: number
233
+ /** Assistant text blocks (visible replies). */
234
+ assistant: number
235
+ /** Assistant reasoning blocks (hidden thinking). */
236
+ thinking: number
237
+ /** Tool call arguments plus result text. */
238
+ tools: number
239
+ }
240
+
241
+ /** Window-scoped figures the status line shows; timing uses event timestamps. */
242
+ export interface TranscriptStats {
243
+ /** Durable turns opened (`turn/start` events). */
244
+ turns: number
245
+ /** Model requests made (`step/start` events). */
246
+ steps: number
247
+ /** Summed model wall time: `step/start` → `assistant/message`, in ms. */
248
+ llmMs: number
249
+ /** Summed tool wall time: `tool/call` → `tool/result`, in ms. */
250
+ toolMs: number
251
+ /** Cumulative token accounting; input stays 0 until a report lands. */
252
+ usage: UsageTotals
253
+ /** Prompt-side size of the most recent reported request (context pressure). */
254
+ lastPromptTokens: number
255
+ /** Newest advertised route capacity, 0 when no adapter ever advertised one. */
256
+ contextWindow: number
257
+ /** Estimated used tokens per content type (the segmented bar's composition). */
258
+ contextSegments: ContextSegments
259
+ /** Summed first-token waits: `step/start` first non-empty chunk, in ms. */
260
+ ttftMs: number
261
+ /** Steps that produced a first chunk (the TTFT average's denominator). */
262
+ ttftSteps: number
263
+ /** Summed decode spans: first chunk `assistant/message`, in ms. */
264
+ decodeMs: number
265
+ /** Completion tokens over timed decode spans (the tok/s numerator). */
266
+ decodeTokens: number
267
+ /**
268
+ * Adapter-owned reasoning effort of the latest `request/header` config
269
+ * the EFFECTIVE effort the session actually uses (a materialized model
270
+ * default is included, exactly as the adapter resolved it). Empty when the
271
+ * header carried none (provider-default behavior). The status line appends
272
+ * it to the model segment as `provider/model@effort`.
273
+ */
274
+ reasoningEffort: string
275
+ }
276
+
277
+ /** One active reminder folded from durable `schedule/change` events. */
278
+ export interface ScheduleRow {
279
+ readonly id: string
280
+ readonly kind: 'after' | 'at' | 'every'
281
+ readonly prompt: string
282
+ /** Next due time (epoch ms); the /schedule panel derives overdue/relative labels. */
283
+ readonly targetAt: number
284
+ /** Recurrence seconds for 'every' rows, undefined otherwise. */
285
+ readonly everySeconds?: number
286
+ }
287
+
288
+ /**
289
+ * The durable `schedule/change` payload shape this fold consumes. Upstream
290
+ * strict-decodes the whole transition stream before appending, so unknown
291
+ * ids here are corrupt-input edges that degrade to a no-op.
292
+ */
293
+ export interface ScheduleChangeLike {
294
+ readonly operation: 'create' | 'delete' | 'dispatch'
295
+ readonly schedule?: {
296
+ readonly id: string
297
+ readonly kind: 'after' | 'at' | 'every'
298
+ readonly prompt: string
299
+ readonly afterSeconds?: number
300
+ readonly everySeconds?: number
301
+ readonly scheduledAt: string
302
+ }
303
+ readonly id?: string
304
+ readonly acceptedAt?: string
305
+ }
306
+
307
+ /*
308
+ * Upstream record semantics (dsh-schedule types): `scheduledAt` is ALREADY
309
+ * the due instant — AfterScheduleRecord carries the RFC 3339 UTC target
310
+ * (delay included), EveryScheduleRecord carries the earliest anchor-aligned
311
+ * occurrence not yet dispatched. No kind ever adds its own interval on top.
312
+ */
313
+
314
+ /** Fold one `schedule/change` into the active-reminder list (create/delete/dispatch). */
315
+ export function applyScheduleChange(rows: readonly ScheduleRow[], data: ScheduleChangeLike): readonly ScheduleRow[] {
316
+ if (data.operation === 'create' && data.schedule !== undefined) {
317
+ const schedule = data.schedule
318
+ const row: ScheduleRow = {
319
+ id: schedule.id,
320
+ kind: schedule.kind,
321
+ prompt: schedule.prompt,
322
+ targetAt: Date.parse(schedule.scheduledAt),
323
+ ...(schedule.kind === 'every' ? { everySeconds: schedule.everySeconds ?? 0 } : {}),
324
+ }
325
+ return [...rows.filter(existing => existing.id !== schedule.id), row]
326
+ }
327
+ if (data.operation === 'delete' && data.id !== undefined) {
328
+ return rows.filter(existing => existing.id !== data.id)
329
+ }
330
+ if (data.operation === 'dispatch' && data.id !== undefined) {
331
+ // A dispatched one-shot reminder is finished. An 'every' reminder
332
+ // advances PAST every missed occurrence in one step: the next target is
333
+ // the first anchor-aligned instant strictly after acceptedAt, stepping
334
+ // from the previous aligned target (upstream advances the same way).
335
+ if (data.acceptedAt === undefined) return rows.filter(existing => existing.id !== data.id)
336
+ const accepted = Date.parse(data.acceptedAt)
337
+ return rows.map(existing => existing.id === data.id
338
+ ? { ...existing, targetAt: nextEveryTarget(existing.targetAt, accepted, existing.everySeconds ?? 0) }
339
+ : existing)
340
+ }
341
+ return rows
342
+ }
343
+
344
+ /** First anchor-aligned target after `acceptedAt`, stepping from the previous aligned target. */
345
+ export function nextEveryTarget(previousTarget: number, acceptedAt: number, everySeconds: number): number {
346
+ const interval = Math.max(1, everySeconds) * 1000
347
+ if (acceptedAt <= previousTarget) return previousTarget + interval
348
+ const missed = Math.ceil((acceptedAt - previousTarget + 1) / interval)
349
+ return previousTarget + missed * interval
350
+ }
351
+
352
+ /** Plugin snapshot sources folded into token stats but never rendered as rows. */
353
+ const HIDDEN_SNAPSHOT_PLUGINS = new Set(['time-context', 'tmux-context'])
354
+ /** Plugin prompt sources rendered as full user rows (they ARE the conversation). */
355
+ const REMINDER_PLUGINS = new Set(['schedule'])
356
+
357
+ /** The complete TUI transcript view for one session. */
358
+ export interface TranscriptView {
359
+ /** Settled entries in log order. */
360
+ entries: readonly TranscriptEntry[]
361
+ /** Bounded text tail accumulated from live stream frames since the last settlement. */
362
+ streaming: string
363
+ /** Bounded thinking tail accumulated from reasoning deltas since the last flush. */
364
+ streamingReasoning: string
365
+ /** Latest whole-list todo snapshot from `todo/write`, empty when none. */
366
+ todos: readonly TodoItem[]
367
+ /**
368
+ * Global tool-call ordinal counter: the number the NEXT `tool/call` lands
369
+ * with (1-based). Never reset, so the counter and the badges/error lines
370
+ * stay consistent across turns and resumed sessions.
371
+ */
372
+ toolCallOrdinal: number
373
+ /** True while a durable turn is open (`turn/start` … `turn/end`). */
374
+ busy: boolean
375
+ /** `turn/start` time of the open turn (0 while idle) — the web TurnStatus clock anchor. */
376
+ busySince: number
377
+ /** Figures the status line renders. */
378
+ stats: TranscriptStats
379
+ /**
380
+ * The `provider/model` pair of the last `request/header` snapshot — the
381
+ * session's own model record, which a resumed TUI prefers over the
382
+ * deployment default (mirrors the web host's resume selection order).
383
+ * Empty before the session's first request.
384
+ */
385
+ model: string
386
+ /** Plan mode state folded from the last `plan/mode` event. */
387
+ plan: boolean
388
+ /** Active permission preset folded from the last `permission/preset` event, empty before one. */
389
+ permission: string
390
+ /** Latest session title folded from the last `session/title` event, empty before one. */
391
+ title: string
392
+ /**
393
+ * Effective system prompt assembled from `system/message` surface nodes
394
+ * (v3): the head node's text joined with every later non-empty node, blank
395
+ * lines between. Empty before the first system node or when every node is
396
+ * empty ("no system prompt").
397
+ */
398
+ systemPrompt: string
399
+ /** Sandbox-mode override folded from the last `sandbox/mode` event, empty when never switched. */
400
+ sandbox: string
401
+ /** Current long-running goal folded from the last `goal/change`, undefined when cleared. */
402
+ goal: GoalFold | undefined
403
+ /** Active reminders folded from `schedule/change` events, oldest target first at render. */
404
+ schedules: readonly ScheduleRow[]
405
+ /**
406
+ * Ordered live message ids per inbox target, mirrored from
407
+ * `agent/inbox/spliced` exactly like the upstream Inbox projection — the
408
+ * coordinates later removals resolve against.
409
+ */
410
+ pending: { 'next-turn': readonly string[]; 'next-step': readonly string[] }
411
+ /**
412
+ * Fold-internal timing anchors, never rendered: open step and tool-call
413
+ * start timestamps the next `assistant/message` / `tool/result` resolves
414
+ * against. Keyed `turn:step` and by call id. `turnSteps`/`turnTools`
415
+ * track which step/tool anchors still belong to the open turn so
416
+ * `turn/end` (and a superseding `step/start`) can sweep anchors an
417
+ * interruption left behind; `turnFiles` keys mutated paths by turn.
418
+ */
419
+ readonly anchors: {
420
+ stepStart: Map<string, number>
421
+ toolStart: Map<string, number>
422
+ firstChunkAt: Map<string, number>
423
+ compactionTokens: Map<string, number>
424
+ lastPruneTokens: number
425
+ turnFiles: Map<number, Set<string>>
426
+ turnSteps: Map<number, string>
427
+ turnTools: Map<number, Set<string>>
428
+ /** Live `system/message` surface nodes by event seq (empty string = an empty node). */
429
+ systemNodes: Map<number, string>
430
+ }
431
+ }
432
+
433
+ /** Assemble the effective system prompt from surface nodes: head text plus every later non-empty node. */
434
+ function assembleSystemPrompt(nodes: ReadonlyMap<number, string>): string {
435
+ if (nodes.size === 0) return ''
436
+ const ordered = [...nodes.entries()].sort((left, right) => left[0] - right[0])
437
+ const texts = ordered
438
+ .map(([, text]) => text)
439
+ .filter(text => text !== '')
440
+ return texts.join('\n\n')
441
+ }
442
+
443
+ /**
444
+ * Apply one surface event's replace to the live system nodes. Any surface
445
+ * event may shadow system nodes the kernel's compaction summary lands as a
446
+ * `user/message` replace whose range can cover later system nodes (only node
447
+ * 0 is compaction-protected upstream) so every surface fold retires covered
448
+ * nodes, not just `system/message` itself.
449
+ * @param nodes - the live system-node map (mutated when the event replaces).
450
+ * @param surfaceOp - the surface operation the event carries, when it is a
451
+ * surface event (log-only events have none and change nothing).
452
+ * @returns the reassembled prompt when nodes were retired, `changed: false`
453
+ * when the event shadows nothing.
454
+ */
455
+ function retireShadowedSystemNodes(
456
+ nodes: Map<number, string>,
457
+ surfaceOp: 'append' | { readonly op: 'replace'; readonly startSeq: number; readonly endSeq: number } | undefined,
458
+ ): { readonly prompt: string; readonly changed: boolean } {
459
+ if (surfaceOp === undefined || surfaceOp === 'append') return { prompt: '', changed: false }
460
+ let changed = false
461
+ for (const seq of nodes.keys()) {
462
+ if (seq >= surfaceOp.startSeq && seq <= surfaceOp.endSeq) {
463
+ nodes.delete(seq)
464
+ changed = true
465
+ }
466
+ }
467
+ return changed ? { prompt: assembleSystemPrompt(nodes), changed } : { prompt: '', changed: false }
468
+ }
469
+
470
+ /** Join the text blocks of a content list; non-text blocks contribute nothing. */
471
+ function textOf(content: readonly ContentBlock[]): string {
472
+ return content.filter(block => block.type === 'text').map(block => block.text).join('')
473
+ }
474
+
475
+ /**
476
+ * Snapshot-isolate one anchors block (Maps and their nested Sets): a view
477
+ * already handed to the renderer must never observe a later fold through a
478
+ * shared container. The collections are small and turn-bounded, so cloning
479
+ * per event is cheap next to the entries copy the reducer already makes.
480
+ */
481
+ function cloneViewAnchors(anchors: TranscriptView['anchors']): TranscriptView['anchors'] {
482
+ return {
483
+ stepStart: new Map(anchors.stepStart),
484
+ toolStart: new Map(anchors.toolStart),
485
+ firstChunkAt: new Map(anchors.firstChunkAt),
486
+ compactionTokens: new Map(anchors.compactionTokens),
487
+ lastPruneTokens: anchors.lastPruneTokens,
488
+ turnFiles: new Map([...anchors.turnFiles].map(([turn, files]) => [turn, new Set(files)])),
489
+ turnSteps: new Map(anchors.turnSteps),
490
+ turnTools: new Map([...anchors.turnTools].map(([turn, tools]) => [turn, new Set(tools)])),
491
+ systemNodes: new Map(anchors.systemNodes),
492
+ }
493
+ }
494
+
495
+ /** Durable image references in their model-visible order. */
496
+ function imagesOf(content: readonly ContentBlock[]): readonly ImageBlock['attachment'][] {
497
+ return content.filter((block): block is ImageBlock => block.type === 'image').map(block => block.attachment)
498
+ }
499
+
500
+ /** Durable file references in their model-visible order. */
501
+ function filesOf(content: readonly ContentBlock[]): readonly FileAttachmentRef[] {
502
+ return content.filter((block): block is FileBlock => block.type === 'file').map(block => block.attachment)
503
+ }
504
+
505
+ /** Human-readable bounded image labels for transcript, inspector, and export surfaces. */
506
+ export function imageLabels(images: readonly ImageBlock['attachment'][] | undefined): string {
507
+ if (images === undefined || images.length === 0) return ''
508
+ return images.map((image, index) => {
509
+ const rawName = image.name?.trim() || `image ${index + 1}`
510
+ const name = rawName.length <= 80 ? rawName : `${rawName.slice(0, 79)}…`
511
+ const original = image.originalDimensions
512
+ const dimensions = original === undefined
513
+ ? `${image.width}×${image.height}`
514
+ : `${image.width}×${image.height} · original ${original.width}×${original.height}`
515
+ return `[image: ${name} · ${dimensions} · ${image.bytes} B]`
516
+ }).join('\n')
517
+ }
518
+
519
+ /** Human-readable bounded file labels for the same surfaces (0.1.5 file blocks). */
520
+ export function fileLabels(files: readonly FileAttachmentRef[] | undefined): string {
521
+ if (files === undefined || files.length === 0) return ''
522
+ return files.map((file, index) => {
523
+ const rawName = file.name?.trim() || `file ${index + 1}`
524
+ const name = rawName.length <= 80 ? rawName : `${rawName.slice(0, 79)}…`
525
+ return `[file: ${name} · ${file.bytes} B]`
526
+ }).join('\n')
527
+ }
528
+
529
+ /** Prompt text with its durable image and file labels, without exposing local paths or bytes. */
530
+ export function promptDisplayText(entry: Pick<UserEntry | PendingEntry, 'text' | 'images' | 'files'>): string {
531
+ const imageText = imageLabels(entry.images)
532
+ const fileText = fileLabels(entry.files)
533
+ const labels = imageText === '' ? fileText : fileText === '' ? imageText : `${imageText}\n${fileText}`
534
+ return entry.text === '' ? labels : labels === '' ? entry.text : `${entry.text}\n${labels}`
535
+ }
536
+
537
+ /** Join the reasoning blocks of a content list; non-reasoning blocks contribute nothing. */
538
+ function reasoningOf(content: readonly ContentBlock[]): string {
539
+ return content.filter(block => block.type === 'reasoning').map(block => block.text).join('')
540
+ }
541
+
542
+ /**
543
+ * Rough token estimate for the segmented context bar (pi-nano-context's ~4
544
+ * chars/token heuristic, CJK-aware so a Chinese prompt is not quartered):
545
+ * CJK/wide chars cost ~1 token each, ASCII ~4 chars per token. Estimates
546
+ * drive bar PROPORTIONS, never billing, so precision is not required.
547
+ * @param text - the text to estimate.
548
+ * @returns an integer token estimate, 0 for empty text.
549
+ */
550
+ function estimateTokens(text: string): number {
551
+ let wide = 0
552
+ let narrow = 0
553
+ for (const cluster of splitGraphemes(text)) {
554
+ if (graphemeWidth(cluster) > 1) wide += 1
555
+ else narrow += 1
556
+ }
557
+ return wide + Math.ceil(narrow / 4)
558
+ }
559
+
560
+ /** A fresh, empty transcript view. */
561
+ export function createTranscriptView(): TranscriptView {
562
+ return {
563
+ entries: [],
564
+ streaming: '',
565
+ streamingReasoning: '',
566
+ todos: [],
567
+ toolCallOrdinal: 0,
568
+ busy: false,
569
+ busySince: 0,
570
+ model: '',
571
+ plan: false,
572
+ permission: '',
573
+ title: '',
574
+ systemPrompt: '',
575
+ sandbox: '',
576
+ goal: undefined,
577
+ schedules: [],
578
+ pending: { 'next-turn': [], 'next-step': [] },
579
+ stats: { turns: 0, steps: 0, llmMs: 0, toolMs: 0, usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }, lastPromptTokens: 0, contextWindow: 0, contextSegments: { system: 0, prompt: 0, assistant: 0, thinking: 0, tools: 0 }, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0, reasoningEffort: '' },
580
+ anchors: { stepStart: new Map(), toolStart: new Map(), firstChunkAt: new Map(), compactionTokens: new Map(), lastPruneTokens: 0, turnFiles: new Map(), turnSteps: new Map(), turnTools: new Map(), systemNodes: new Map() },
581
+ }
582
+ }
583
+
584
+ /** Full prompt text of a queued message (identical to the durable user row it retires into). */
585
+ function pendingText(content: readonly ContentBlock[]): string {
586
+ return textOf(content)
587
+ }
588
+
589
+ /**
590
+ * Fold one session event into an updated view (copy-on-write).
591
+ * @param view - the view before the event.
592
+ * @param event - one durable session event from `session/event` or the log.
593
+ * @returns the view after the event; the input view is never mutated.
594
+ */
595
+ export function projectEvent(view: TranscriptView, event: SessionEvent): TranscriptView {
596
+ // Fold against a private anchors block so the documented contract holds —
597
+ // "the input view is never mutated" — even for the in-place anchor sweeps
598
+ // below; without this, every handed-out view shared live Maps.
599
+ view = { ...view, anchors: cloneViewAnchors(view.anchors) }
600
+ // Any surface event's replace may shadow system nodes (a compaction
601
+ // summary lands as a user/message replace whose range can cover later
602
+ // system nodes); the switch below folds against the shadowed view.
603
+ const shadow = retireShadowedSystemNodes(view.anchors.systemNodes, event.surfaceOp)
604
+ if (shadow.changed) {
605
+ view = {
606
+ ...view,
607
+ systemPrompt: shadow.prompt,
608
+ stats: { ...view.stats, contextSegments: { ...view.stats.contextSegments, system: estimateTokens(shadow.prompt) } },
609
+ }
610
+ }
611
+ switch (event.type) {
612
+ case 'user/message': {
613
+ // A queued row retires when its durable user message lands (the agent
614
+ // claims the inbox and logs the same message identity) — the transient
615
+ // steering/queued preview yields to the real transcript entry.
616
+ const message = event.data
617
+ let entries = view.entries
618
+ let pending = view.pending
619
+ for (const target of ['next-turn', 'next-step'] as const) {
620
+ const index = pending[target].indexOf(message.id)
621
+ if (index < 0) continue
622
+ pending = { ...pending, [target]: pending[target].filter((_, i) => i !== index) }
623
+ entries = entries.filter(entry => !(entry.kind === 'pending' && entry.messageId === message.id))
624
+ }
625
+ // Injected context (plugin/model-continuation sources) stays collapsed
626
+ // to a bounded notice row, exactly like collapsed transcript context
627
+ // elsewhere in the product; only direct human prompts render in full.
628
+ const text = textOf(message.content)
629
+ const images = imagesOf(message.content)
630
+ const files = filesOf(message.content)
631
+ if (message.source.kind === 'user') {
632
+ return {
633
+ ...view,
634
+ pending,
635
+ entries: [...entries, { kind: 'user', text, notice: false, ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) }],
636
+ stats: {
637
+ ...view.stats,
638
+ contextSegments: {
639
+ ...view.stats.contextSegments,
640
+ prompt: view.stats.contextSegments.prompt + estimateTokens(text),
641
+ },
642
+ },
643
+ }
644
+ }
645
+ // Snapshot injections (time/tmux context) still spend model context but
646
+ // render nothing; the schedule reminder is a real prompt and renders in
647
+ // full — the model acts on it, so the transcript must show it.
648
+ if (message.source.kind === 'plugin' && HIDDEN_SNAPSHOT_PLUGINS.has(message.source.plugin)) {
649
+ return {
650
+ ...view,
651
+ pending,
652
+ entries,
653
+ stats: {
654
+ ...view.stats,
655
+ contextSegments: {
656
+ ...view.stats.contextSegments,
657
+ system: view.stats.contextSegments.system + estimateTokens(text),
658
+ },
659
+ },
660
+ }
661
+ }
662
+ if (message.source.kind === 'plugin' && REMINDER_PLUGINS.has(message.source.plugin)) {
663
+ return {
664
+ ...view,
665
+ pending,
666
+ entries: [...entries, { kind: 'user', text, notice: false, ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) }],
667
+ stats: {
668
+ ...view.stats,
669
+ contextSegments: {
670
+ ...view.stats.contextSegments,
671
+ prompt: view.stats.contextSegments.prompt + estimateTokens(text),
672
+ },
673
+ },
674
+ }
675
+ }
676
+ const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
677
+ ? message.source.summary
678
+ : message.source.kind === 'plugin'
679
+ ? message.source.plugin
680
+ : message.source.kind
681
+ const summary = boundContextSummary(notice)
682
+ return {
683
+ ...view,
684
+ pending,
685
+ entries: [...entries, { kind: 'user', text: summary, notice: true }],
686
+ stats: {
687
+ ...view.stats,
688
+ contextSegments: {
689
+ ...view.stats.contextSegments,
690
+ system: view.stats.contextSegments.system + estimateTokens(summary),
691
+ },
692
+ },
693
+ }
694
+ }
695
+ case 'agent/inbox/spliced': {
696
+ // The durable inbox mutation (web queue-mirror contract, event-sourced):
697
+ // removals drop the projected rows at their inbox coordinates, inserted
698
+ // messages gain a pending row at their log position.
699
+ const { target, start, removedCount = 0, inserted } = event.data
700
+ const ids = view.pending[target]
701
+ const removed = ids.slice(start, start + removedCount)
702
+ // In-place upstream semantics: the kernel's authoritative fold is
703
+ // `inbox.splice(start, removedCount, ...inserted)` — inserted ids land
704
+ // AT the splice position (prepend/replace shapes), never at the tail.
705
+ // A tail append diverged the id order, so later coordinate-based events
706
+ // (next-turn head claims, positioned remove/replace) tombstoned the
707
+ // wrong pending row.
708
+ const nextIds = [
709
+ ...ids.slice(0, start),
710
+ ...inserted.map(message => message.id),
711
+ ...ids.slice(start + removedCount),
712
+ ]
713
+ let entries = view.entries
714
+ if (removed.length > 0) {
715
+ const removedSet = new Set(removed)
716
+ entries = entries.filter(entry =>
717
+ !(entry.kind === 'pending' && entry.target === target && removedSet.has(entry.messageId)))
718
+ }
719
+ for (const message of inserted) {
720
+ entries = [...entries, {
721
+ kind: 'pending',
722
+ messageId: message.id,
723
+ target,
724
+ text: pendingText(message.content),
725
+ ...imagesOf(message.content).length === 0 ? {} : { images: imagesOf(message.content) },
726
+ ...filesOf(message.content).length === 0 ? {} : { files: filesOf(message.content) },
727
+ }]
728
+ }
729
+ return { ...view, entries, pending: { ...view.pending, [target]: nextIds } }
730
+ }
731
+ case 'system/message': {
732
+ // Session-log v3 carries the system prompt as surface nodes (the
733
+ // `request/header.system` field is gone): append adds one node;
734
+ // replace(startSeq, endSeq) retires the covered nodes and this event's
735
+ // node takes their place. The effective prompt is the head text joined
736
+ // with every later non-empty node (kernel in-history assembly), and
737
+ // the context estimate prices that assembly — a tail-clearing
738
+ // replacement never zeroes a surviving head.
739
+ const text = textOf(event.data.message.content)
740
+ const nodes = view.anchors.systemNodes
741
+ retireShadowedSystemNodes(nodes, event.surfaceOp)
742
+ nodes.set(event.seq, text)
743
+ const systemPrompt = assembleSystemPrompt(nodes)
744
+ return {
745
+ ...view,
746
+ systemPrompt,
747
+ stats: {
748
+ ...view.stats,
749
+ contextSegments: {
750
+ ...view.stats.contextSegments,
751
+ system: estimateTokens(systemPrompt),
752
+ },
753
+ },
754
+ }
755
+ }
756
+ case 'assistant/attempt': {
757
+ // A failed, retried, cancelled, or stream-error attempt that produced no
758
+ // surface message (session-log v2+ folds its chunk stream in here). The
759
+ // step is NOT closed — the kernel's session-stats keeps one step start
760
+ // across in-step retries, so llmMs spans them; keep the anchors so a
761
+ // retrying attempt and its final settlement time the step from one
762
+ // start. An attempt whose first token streamed only live (the frames
763
+ // died before the fold) restores its first-token anchor from the
764
+ // embedded stream exactly like a replayed one.
765
+ const key = `${event.data.turn}:${event.data.step}`
766
+ let stats = view.stats
767
+ if (!view.anchors.firstChunkAt.has(key)) {
768
+ const first = assistantStreamFirstTokenTime(event.data.stream ?? [])
769
+ if (first !== undefined) {
770
+ view.anchors.firstChunkAt.set(key, first)
771
+ const started = view.anchors.stepStart.get(key)
772
+ if (started !== undefined) {
773
+ stats = {
774
+ ...stats,
775
+ ttftMs: stats.ttftMs + Math.max(0, first - started),
776
+ ttftSteps: stats.ttftSteps + 1,
777
+ }
778
+ }
779
+ }
780
+ }
781
+ if (view.streaming === '' && view.streamingReasoning === '' && stats === view.stats) return view
782
+ return { ...view, streaming: '', streamingReasoning: '', stats }
783
+ }
784
+ case 'assistant/message': {
785
+ // The assembled message is authoritative; drop the streamed buffers.
786
+ const key = `${event.data.turn}:${event.data.step}`
787
+ const started = view.anchors.stepStart.get(key)
788
+ view.anchors.stepStart.delete(key)
789
+ // Live streaming anchored the first token through the process-local
790
+ // assistant-stream frames; a replayed settlement has no live frames, so
791
+ // the embedded stream's own first-token time restores the same anchor
792
+ // AND the TTFT figures live frames accumulate on the live path.
793
+ let firstChunk = view.anchors.firstChunkAt.get(key)
794
+ let stats = view.stats
795
+ if (firstChunk === undefined) {
796
+ firstChunk = assistantStreamFirstTokenTime(event.data.stream ?? [])
797
+ if (firstChunk !== undefined && started !== undefined) {
798
+ stats = {
799
+ ...stats,
800
+ ttftMs: stats.ttftMs + Math.max(0, firstChunk - started),
801
+ ttftSteps: stats.ttftSteps + 1,
802
+ }
803
+ }
804
+ }
805
+ view.anchors.firstChunkAt.delete(key)
806
+ // The assembled message consumes the turn's current step anchor; a
807
+ // later `turn/end` sweep then has nothing left to clean for this step.
808
+ if (view.anchors.turnSteps.get(event.data.turn) === key) view.anchors.turnSteps.delete(event.data.turn)
809
+ const usage = event.data.usage
810
+ const totals = stats.usage
811
+ const text = textOf(event.data.message.content)
812
+ const reasoning = reasoningOf(event.data.message.content)
813
+ return {
814
+ ...view,
815
+ streaming: '',
816
+ streamingReasoning: '',
817
+ entries: [...view.entries, { kind: 'assistant', text, reasoning, interrupted: event.data.interrupted === true ? true : undefined }],
818
+ stats: {
819
+ ...stats,
820
+ llmMs: stats.llmMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
821
+ usage: usage === undefined ? totals : {
822
+ inputTokens: totals.inputTokens + usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
823
+ outputTokens: totals.outputTokens + usage.outputTokens,
824
+ cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0),
825
+ },
826
+ lastPromptTokens: usage === undefined ? stats.lastPromptTokens
827
+ : usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
828
+ // Decode span and its tokens pair up: an un-timed step (no first
829
+ // chunk landed) contributes neither, so the rate stays honest.
830
+ decodeMs: stats.decodeMs + (firstChunk === undefined ? 0 : Math.max(0, event.time - firstChunk)),
831
+ decodeTokens: stats.decodeTokens + (firstChunk === undefined || usage === undefined ? 0 : usage.outputTokens),
832
+ contextSegments: {
833
+ ...stats.contextSegments,
834
+ thinking: stats.contextSegments.thinking + estimateTokens(reasoning),
835
+ assistant: stats.contextSegments.assistant + estimateTokens(text),
836
+ },
837
+ },
838
+ }
839
+ }
840
+ case 'tool/call': {
841
+ const data = event.data
842
+ view.anchors.toolStart.set(data.callId, event.time)
843
+ // Remember the call's turn so `turn/end` can sweep a start that never
844
+ // pairs with a result (an interrupted tool otherwise leaks its anchor).
845
+ const turnTools = view.anchors.turnTools.get(data.turn) ?? new Set<string>()
846
+ turnTools.add(data.callId)
847
+ view.anchors.turnTools.set(data.turn, turnTools)
848
+ const ordinal = view.toolCallOrdinal + 1
849
+ return {
850
+ ...view,
851
+ toolCallOrdinal: ordinal,
852
+ entries: [
853
+ ...view.entries,
854
+ {
855
+ kind: 'tool',
856
+ callId: data.callId,
857
+ ordinal,
858
+ name: data.name,
859
+ arguments: data.arguments,
860
+ preview: toolArgumentsPreview(data.arguments, data.name),
861
+ prompt: toolPromptPreview(data.name, data.arguments),
862
+ state: 'running',
863
+ summary: '',
864
+ detail: undefined,
865
+ }],
866
+ stats: {
867
+ ...view.stats,
868
+ contextSegments: {
869
+ ...view.stats.contextSegments,
870
+ tools: view.stats.contextSegments.tools
871
+ + (typeof data.arguments === 'string' ? estimateTokens(data.arguments) : 0),
872
+ },
873
+ },
874
+ }
875
+ }
876
+ case 'tool/result': {
877
+ const block = event.data.message.content[0]
878
+ const started = view.anchors.toolStart.get(block.toolCallId)
879
+ view.anchors.toolStart.delete(block.toolCallId)
880
+ // Deregister the call from its turn's registry so `turn/end` does not
881
+ // sweep a start that already paired with a result.
882
+ const turnTools = view.anchors.turnTools.get(event.data.turn)
883
+ if (turnTools !== undefined) {
884
+ turnTools.delete(block.toolCallId)
885
+ if (turnTools.size === 0) view.anchors.turnTools.delete(event.data.turn)
886
+ }
887
+ const rawText = textOf(block.content)
888
+ const summary = boundContextSummary(rawText)
889
+ // The verbose expansion self-serves from the persisted presentation
890
+ // metadata (diffs, read windows, web sources) with the bounded raw text
891
+ // as the universal fallback the capable-UI degradation ladder.
892
+ const detail = toolResultDetail(event.data.meta, rawText)
893
+ // Turn-tail deliverables: a diff-bearing mutation records its paths.
894
+ if (detail?.kind === 'diff') {
895
+ const set = view.anchors.turnFiles.get(event.data.turn) ?? new Set<string>()
896
+ for (const diff of detail.diffs) set.add(diff.path)
897
+ view.anchors.turnFiles.set(event.data.turn, set)
898
+ }
899
+ const entries = view.entries.map((entry) => {
900
+ if (entry.kind !== 'tool' || entry.callId !== block.toolCallId) return entry
901
+ return { ...entry, state: block.isError === true ? 'error' as const : 'done' as const, summary, detail }
902
+ })
903
+ return {
904
+ ...view,
905
+ entries,
906
+ stats: {
907
+ ...view.stats,
908
+ toolMs: view.stats.toolMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
909
+ contextSegments: {
910
+ ...view.stats.contextSegments,
911
+ tools: view.stats.contextSegments.tools + estimateTokens(rawText),
912
+ },
913
+ },
914
+ }
915
+ }
916
+ case 'todo/write':
917
+ return { ...view, todos: event.data.todos }
918
+ case 'turn/start':
919
+ // The web todo projection clears on turn/start: a fresh turn's first
920
+ // write is the authoritative list, and a stale snapshot must not linger
921
+ // through a turn that has not written one yet.
922
+ return {
923
+ ...view,
924
+ busy: true,
925
+ busySince: view.busy ? view.busySince : event.time,
926
+ todos: [],
927
+ stats: { ...view.stats, turns: view.stats.turns + 1 },
928
+ }
929
+ case 'step/start': {
930
+ // A step supersedes the turn's previous step: if that step never
931
+ // assembled a message (interrupted), its timing anchors are stale the
932
+ // moment the next step opens and are swept here instead of leaking.
933
+ const key = `${event.data.turn}:${event.data.step}`
934
+ const previous = view.anchors.turnSteps.get(event.data.turn)
935
+ if (previous !== undefined && previous !== key) {
936
+ view.anchors.stepStart.delete(previous)
937
+ view.anchors.firstChunkAt.delete(previous)
938
+ }
939
+ view.anchors.turnSteps.set(event.data.turn, key)
940
+ view.anchors.stepStart.set(key, event.time)
941
+ return {
942
+ ...view,
943
+ streaming: '',
944
+ streamingReasoning: '',
945
+ stats: { ...view.stats, steps: view.stats.steps + 1 },
946
+ }
947
+ }
948
+ case 'turn/end': {
949
+ const reason = event.data.reason
950
+ const appended: TranscriptEntry[] = []
951
+ if (reason.kind === 'error') {
952
+ const recovery = reason.error.code === 'MISSING_CREDENTIAL'
953
+ ? ' · open /model to add an API key'
954
+ : ''
955
+ appended.push({ kind: 'error', text: `${reason.error.code}: ${reason.error.message}${recovery}` })
956
+ } else {
957
+ // Non-error outcomes deserve their own durable row (the web renders
958
+ // distinct max-tokens / abort / interruption nodes); `completed` stays
959
+ // silent so an ordinary turn never grows a marker.
960
+ const marker = reason.kind === 'aborted'
961
+ ? reason.reason.kind === 'user' ? 'turn cancelled by the user' : `turn cancelled (${reason.reason.kind})`
962
+ : reason.kind === 'max-tokens'
963
+ ? 'turn hit the output-token ceiling (max-tokens)'
964
+ : reason.kind === 'blocked'
965
+ ? 'turn ended blocked'
966
+ : reason.kind === 'interrupted'
967
+ ? 'turn was interrupted by a restart'
968
+ : undefined
969
+ if (marker !== undefined) appended.push({ kind: 'turn-marker', text: marker })
970
+ }
971
+ // Deliverables ride the turn tail (the web's turnTail chips): the
972
+ // turn's mutated files flush as one bounded row, then the set resets.
973
+ const files = view.anchors.turnFiles.get(event.data.turn)
974
+ view.anchors.turnFiles.delete(event.data.turn)
975
+ if (files !== undefined && files.size > 0) appended.push({ kind: 'files', paths: [...files].slice(0, 12) })
976
+ // Derivable boundary sweep: the turn is over, so any step/tool anchors
977
+ // it left behind (interruptions that never produced their message or
978
+ // result) can never be resolved and are reclaimed now.
979
+ const stepKey = view.anchors.turnSteps.get(event.data.turn)
980
+ if (stepKey !== undefined) {
981
+ view.anchors.stepStart.delete(stepKey)
982
+ view.anchors.firstChunkAt.delete(stepKey)
983
+ view.anchors.turnSteps.delete(event.data.turn)
984
+ }
985
+ const turnToolSet = view.anchors.turnTools.get(event.data.turn)
986
+ if (turnToolSet !== undefined) {
987
+ for (const callId of turnToolSet) view.anchors.toolStart.delete(callId)
988
+ view.anchors.turnTools.delete(event.data.turn)
989
+ }
990
+ // Orphaned retry/command rows can never be resolved after the turn
991
+ // ends: an aborted retry backoff returns upstream without its
992
+ // `llm/retry-started`, and crash repair synthesizes only tool/step/
993
+ // turn closers. Left `running` they pin the settled boundary forever,
994
+ // so the turn end finalizes them exactly like the anchor sweep above.
995
+ let orphans = false
996
+ const swept = view.entries.map((entry) => {
997
+ if (entry.kind === 'retry' && entry.state === 'running') {
998
+ orphans = true
999
+ return { ...entry, state: 'done' as const }
1000
+ }
1001
+ if (entry.kind === 'command' && entry.state === 'running') {
1002
+ orphans = true
1003
+ return { ...entry, state: 'error' as const, summary: 'interrupted before the turn ended' }
1004
+ }
1005
+ return entry
1006
+ })
1007
+ const entries = orphans ? swept : view.entries
1008
+ if (appended.length === 0) {
1009
+ return { ...view, busy: false, busySince: 0, streaming: '', streamingReasoning: '', entries }
1010
+ }
1011
+ return {
1012
+ ...view,
1013
+ busy: false,
1014
+ busySince: 0,
1015
+ streaming: '',
1016
+ streamingReasoning: '',
1017
+ entries: [...entries, ...appended],
1018
+ }
1019
+ }
1020
+ case 'llm/retry': {
1021
+ const data = event.data
1022
+ return {
1023
+ ...view,
1024
+ streaming: '',
1025
+ streamingReasoning: '',
1026
+ entries: [...view.entries, {
1027
+ kind: 'retry',
1028
+ retryId: data.retryId,
1029
+ mode: data.mode,
1030
+ attempt: data.retry,
1031
+ max: 'maxRetries' in data ? data.maxRetries : data.retry,
1032
+ code: data.failure.code,
1033
+ delayMs: data.delayMs,
1034
+ state: 'running',
1035
+ }],
1036
+ }
1037
+ }
1038
+ case 'llm/retry-started': {
1039
+ const data = event.data
1040
+ const entries = view.entries.map((entry) => {
1041
+ if (entry.kind !== 'retry' || entry.retryId !== data.retryId) return entry
1042
+ return { ...entry, state: 'done' as const }
1043
+ })
1044
+ return { ...view, entries }
1045
+ }
1046
+ case 'sandbox/mode':
1047
+ // Log-only override switch; last write wins for the status badge.
1048
+ return { ...view, sandbox: event.data.mode }
1049
+ case 'goal/change': {
1050
+ const data = event.data
1051
+ const clip = (text: string): string => (text.length > 60 ? `${text.slice(0, 59)}…` : text)
1052
+ if (data.operation === 'clear') {
1053
+ return {
1054
+ ...view,
1055
+ goal: undefined,
1056
+ entries: [...view.entries, { kind: 'turn-marker', text: '◎ goal cleared' }],
1057
+ }
1058
+ }
1059
+ const goal: GoalFold = {
1060
+ objective: data.goal.objective,
1061
+ phase: data.goal.phase,
1062
+ rounds: data.roundsStarted,
1063
+ max: data.goal.maxGoalRounds,
1064
+ blocked: data.goal.blockedReason?.message ?? '',
1065
+ }
1066
+ const line = data.operation === 'create'
1067
+ ? `◎ goal: ${clip(data.goal.objective)}`
1068
+ : data.operation === 'complete'
1069
+ ? '◎ goal complete'
1070
+ : data.operation === 'pause'
1071
+ ? ' goal paused'
1072
+ : data.operation === 'resume'
1073
+ ? '◎ goal resumed'
1074
+ : data.operation === 'block'
1075
+ ? `◎ goal blocked: ${clip(goal.blocked)}`
1076
+ : undefined
1077
+ return {
1078
+ ...view,
1079
+ goal,
1080
+ entries: line === undefined ? view.entries : [...view.entries, { kind: 'turn-marker', text: line }],
1081
+ }
1082
+ }
1083
+ case 'schedule/change':
1084
+ // Non-conversational catalog state: the /schedule panel renders the
1085
+ // active list, the transcript shows only the reminder prompts
1086
+ // (handled at user/message above).
1087
+ return { ...view, schedules: applyScheduleChange(view.schedules, event.data) }
1088
+ case 'session/title':
1089
+ // Latest-wins title snapshot, log-only; the status line prefers it.
1090
+ return { ...view, title: event.data.title }
1091
+ case 'compaction/summary':
1092
+ // Remember the shadow price so the matching `compaction/end` row can
1093
+ // state what the compaction reclaimed. The map is capped so an aborted
1094
+ // compaction (summary without end) cannot leave an unbounded residue.
1095
+ if (view.anchors.compactionTokens.size >= MAX_COMPACTION_SUMMARY_RESIDUE) {
1096
+ const oldest = view.anchors.compactionTokens.keys().next().value
1097
+ if (oldest !== undefined) view.anchors.compactionTokens.delete(oldest)
1098
+ }
1099
+ view.anchors.compactionTokens.set(event.data.compactionId, event.data.shadowedTokenCount)
1100
+ return view
1101
+ case 'compaction/prune':
1102
+ // A model-free prune carries no compaction id; its price serves the next
1103
+ // `compaction/end` that cannot find a summary price.
1104
+ return { ...view, anchors: { ...view.anchors, lastPruneTokens: event.data.shadowedTokenCount } }
1105
+ case 'compaction/end': {
1106
+ const ok = event.data.error === undefined
1107
+ const tokens = view.anchors.compactionTokens.get(event.data.compactionId) ?? view.anchors.lastPruneTokens
1108
+ view.anchors.compactionTokens.delete(event.data.compactionId)
1109
+ return {
1110
+ ...view,
1111
+ entries: [...view.entries, { kind: 'compaction', ok, tokens, error: event.data.error ?? '' }],
1112
+ }
1113
+ }
1114
+ case 'request/context':
1115
+ // Route capacity, logged only when it changes; last one wins.
1116
+ return {
1117
+ ...view,
1118
+ stats: { ...view.stats, contextWindow: event.data.contextWindow ?? view.stats.contextWindow },
1119
+ }
1120
+ case 'request/header': {
1121
+ // The session's own model record: the latest snapshot's provider/model
1122
+ // pair, exactly what a resumed TUI restores as the selection, plus the
1123
+ // effective reasoning effort that snapshot carried (the adapter may
1124
+ // materialize the model default, which is what the status line shows).
1125
+ // The system-prompt estimate lives with `system/message` events since
1126
+ // session-log v3 removed the header's `system` field.
1127
+ const config = event.data.header.config
1128
+ return {
1129
+ ...view,
1130
+ model: `${config.provider}/${config.model}`,
1131
+ stats: {
1132
+ ...view.stats,
1133
+ reasoningEffort: config.reasoningEffort === undefined ? '' : String(config.reasoningEffort),
1134
+ },
1135
+ }
1136
+ }
1137
+ case 'plan/mode':
1138
+ // Whole-value replace; the last one wins (upstream fold semantics).
1139
+ return { ...view, plan: event.data.active }
1140
+ case 'permission/preset':
1141
+ return { ...view, permission: event.data.preset }
1142
+ case 'command/run': {
1143
+ const data = event.data
1144
+ return {
1145
+ ...view,
1146
+ entries: [...view.entries, {
1147
+ kind: 'command',
1148
+ commandId: data.commandId,
1149
+ name: data.name,
1150
+ args: data.args ?? '',
1151
+ state: 'running',
1152
+ summary: '',
1153
+ }],
1154
+ }
1155
+ }
1156
+ case 'command/done': {
1157
+ const data = event.data
1158
+ const entries = view.entries.map((entry) => {
1159
+ if (entry.kind !== 'command' || entry.commandId !== data.commandId) return entry
1160
+ return {
1161
+ ...entry,
1162
+ state: data.kind === 'success' ? 'done' as const : 'error' as const,
1163
+ summary: boundContextSummary(data.text ?? ''),
1164
+ }
1165
+ })
1166
+ return { ...view, entries }
1167
+ }
1168
+ default:
1169
+ return view
1170
+ }
1171
+ }
1172
+
1173
+ /**
1174
+ * Mutable replay accumulator: folds a persisted log into the identical view
1175
+ * `projectEvent` would produce, but in near-linear time. Where `projectEvent`
1176
+ * is copy-on-write every append/scan rebuilds the whole `entries` array, so
1177
+ * folding a full log costs O() — the accumulator appends by push, resolves
1178
+ * id-keyed updates (tool/result, command/done, retry-started) through index
1179
+ * maps, and tombstones retired pending rows, so the whole log folds in O(N)
1180
+ * plus one compaction pass when tombstones exist.
1181
+ *
1182
+ * Index maps never delete: every appended row registers its index, so an id
1183
+ * lookup miss provably means no matching row exists and the update is an O(1)
1184
+ * no-op (a malicious/orphan-heavy log cannot force per-orphan full-array
1185
+ * scans). Each id maps to ALL of its indices, so a duplicate id updates every
1186
+ * matching row exactly like the copy-on-write reducer.
1187
+ *
1188
+ * @internal Exported only so tests can (a) prove replay sequential
1189
+ * `projectEvent` folds and (b) assert the linear complexity deterministically
1190
+ * via {@link ReplayAccumulator.ops}, which counts entry-level container work
1191
+ * instead of relying on wall-clock thresholds. No public consumer.
1192
+ */
1193
+ export interface ReplayAccumulator {
1194
+ /** Working entry list; `undefined` marks a retired pending row (tombstone). */
1195
+ entries: (TranscriptEntry | undefined)[]
1196
+ /** callId → every index into `entries` holding a `tool` row with that id. */
1197
+ toolIndex: Map<string, number[]>
1198
+ /** commandId → every index into `entries` holding a `command` row with that id. */
1199
+ commandIndex: Map<string, number[]>
1200
+ /** retryId every index into `entries` holding a `retry` row with that id. */
1201
+ retryIndex: Map<string, number[]>
1202
+ /** messageId every index into `entries` holding a `pending` row with that id. */
1203
+ pendingIndex: Map<string, number[]>
1204
+ /** Tombstone count; zero means `entries` is already the final array. */
1205
+ removedCount: number
1206
+ /** Mutable inbox id lists, mirroring `view.pending` order per target. */
1207
+ pendingTurn: string[]
1208
+ pendingStep: string[]
1209
+ streaming: string
1210
+ streamingReasoning: string
1211
+ todos: readonly TodoItem[]
1212
+ /** Global tool-call ordinal counter (see `TranscriptView.toolCallOrdinal`). */
1213
+ toolCallOrdinal: number
1214
+ busy: boolean
1215
+ busySince: number
1216
+ model: string
1217
+ plan: boolean
1218
+ permission: string
1219
+ title: string
1220
+ systemPrompt: string
1221
+ sandbox: string
1222
+ goal: GoalFold | undefined
1223
+ schedules: readonly ScheduleRow[]
1224
+ stats: TranscriptStats
1225
+ stepStart: Map<string, number>
1226
+ toolStart: Map<string, number>
1227
+ firstChunkAt: Map<string, number>
1228
+ compactionTokens: Map<string, number>
1229
+ lastPruneTokens: number
1230
+ turnFiles: Map<number, Set<string>>
1231
+ turnSteps: Map<number, string>
1232
+ turnTools: Map<number, Set<string>>
1233
+ /** Live `system/message` surface nodes by event seq (empty string = an empty node). */
1234
+ systemNodes: Map<number, string>
1235
+ /** Entry-level container operations performed so far (test instrumentation). */
1236
+ ops: number
1237
+ }
1238
+
1239
+ /** @internal A fresh replay accumulator whose state mirrors `createTranscriptView()`. */
1240
+ export function createReplayAccumulator(): ReplayAccumulator {
1241
+ return {
1242
+ entries: [],
1243
+ toolIndex: new Map(),
1244
+ commandIndex: new Map(),
1245
+ retryIndex: new Map(),
1246
+ pendingIndex: new Map(),
1247
+ removedCount: 0,
1248
+ pendingTurn: [],
1249
+ pendingStep: [],
1250
+ streaming: '',
1251
+ streamingReasoning: '',
1252
+ todos: [],
1253
+ toolCallOrdinal: 0,
1254
+ busy: false,
1255
+ busySince: 0,
1256
+ model: '',
1257
+ plan: false,
1258
+ permission: '',
1259
+ title: '',
1260
+ systemPrompt: '',
1261
+ sandbox: '',
1262
+ goal: undefined,
1263
+ schedules: [],
1264
+ stats: { turns: 0, steps: 0, llmMs: 0, toolMs: 0, usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }, lastPromptTokens: 0, contextWindow: 0, contextSegments: { system: 0, prompt: 0, assistant: 0, thinking: 0, tools: 0 }, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0, reasoningEffort: '' },
1265
+ stepStart: new Map(),
1266
+ toolStart: new Map(),
1267
+ firstChunkAt: new Map(),
1268
+ compactionTokens: new Map(),
1269
+ lastPruneTokens: 0,
1270
+ turnFiles: new Map(),
1271
+ turnSteps: new Map(),
1272
+ turnTools: new Map(),
1273
+ systemNodes: new Map(),
1274
+ ops: 0,
1275
+ }
1276
+ }
1277
+
1278
+ /** Append one entry (O(1)) and account the push. */
1279
+ function appendReplayEntry(acc: ReplayAccumulator, entry: TranscriptEntry): void {
1280
+ acc.entries.push(entry)
1281
+ acc.ops += 1
1282
+ }
1283
+
1284
+ /**
1285
+ * Get (or create) the index list an id owns. Lists are never removed: every
1286
+ * appended row registers its index, so a lookup miss later proves no matching
1287
+ * row exists and the caller can no-op in O(1).
1288
+ */
1289
+ function indexList(map: Map<string, number[]>, id: string): number[] {
1290
+ let list = map.get(id)
1291
+ if (list === undefined) {
1292
+ list = []
1293
+ map.set(id, list)
1294
+ }
1295
+ return list
1296
+ }
1297
+
1298
+ /**
1299
+ * Finalize replay rows the ended turn left `running`, mirroring the reducer's
1300
+ * turn-end orphan sweep: an orphaned retry settles `done`, an orphaned command
1301
+ * settles `error` with an interruption note. Only the id-indexed rows are
1302
+ * visited, so the sweep stays O(retries+commands of the log), never a scan.
1303
+ */
1304
+ function finalizeReplayOrphans(acc: ReplayAccumulator): void {
1305
+ for (const list of acc.retryIndex.values()) {
1306
+ for (const index of list) {
1307
+ const entry = acc.entries[index]
1308
+ if (entry !== undefined && entry.kind === 'retry' && entry.state === 'running') {
1309
+ acc.entries[index] = { ...entry, state: 'done' }
1310
+ }
1311
+ }
1312
+ }
1313
+ for (const list of acc.commandIndex.values()) {
1314
+ for (const index of list) {
1315
+ const entry = acc.entries[index]
1316
+ if (entry !== undefined && entry.kind === 'command' && entry.state === 'running') {
1317
+ acc.entries[index] = { ...entry, state: 'error', summary: 'interrupted before the turn ended' }
1318
+ }
1319
+ }
1320
+ }
1321
+ }
1322
+
1323
+ /**
1324
+ * Apply an id-keyed update to every row that registered the id, mirroring the
1325
+ * copy-on-write reducer's full-array map semantics (all matching rows update,
1326
+ * in order). Each registered index is O(1), so a duplicate id costs
1327
+ * O(#duplicates) — never a full-array scan. The kind+id re-check is defensive:
1328
+ * registered indices are valid by construction, because tool/command/retry
1329
+ * rows are never removed and tombstones never shift indices.
1330
+ */
1331
+ function updateReplayById<T extends TranscriptEntry>(
1332
+ acc: ReplayAccumulator,
1333
+ map: Map<string, number[]>,
1334
+ id: string,
1335
+ isMatch: (entry: T) => boolean,
1336
+ update: (entry: T) => T,
1337
+ ): void {
1338
+ const list = map.get(id)
1339
+ if (list === undefined) return // miss provably means no matching row
1340
+ for (const index of list) {
1341
+ const entry = acc.entries[index]
1342
+ if (entry === undefined || !isMatch(entry as T)) continue
1343
+ acc.entries[index] = update(entry as T)
1344
+ acc.ops += 1
1345
+ }
1346
+ }
1347
+
1348
+ /** Tombstone a retired pending row, keeping every other index stable. */
1349
+ function retireReplayEntry(acc: ReplayAccumulator, index: number): void {
1350
+ if (acc.entries[index] !== undefined) {
1351
+ acc.entries[index] = undefined
1352
+ acc.removedCount += 1
1353
+ acc.ops += 1
1354
+ }
1355
+ }
1356
+
1357
+ /**
1358
+ * Fold one session event into a replay accumulator. This mirrors
1359
+ * {@link projectEvent} case for case — same stats arithmetic, same anchor
1360
+ * set/delete behavior, same entry shapes — so the finished view is identical
1361
+ * to a sequential fold; only the `entries` container operations are mutable.
1362
+ *
1363
+ * @internal Test-instrumentation path; `projectEvents` is the public entry.
1364
+ * @returns whether the event changed the accumulated state — the live store
1365
+ * stays silent and keeps its snapshot identity for ignored events, exactly
1366
+ * like the copy-on-write reducer returning its input view unchanged.
1367
+ */
1368
+ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent): boolean {
1369
+ // Mirror of the reducer's entry shadow: any surface replace retires the
1370
+ // system nodes it covers before the per-event fold runs.
1371
+ const shadow = retireShadowedSystemNodes(acc.systemNodes, event.surfaceOp)
1372
+ if (shadow.changed) {
1373
+ acc.systemPrompt = shadow.prompt
1374
+ acc.stats = { ...acc.stats, contextSegments: { ...acc.stats.contextSegments, system: estimateTokens(shadow.prompt) } }
1375
+ }
1376
+ switch (event.type) {
1377
+ case 'user/message': {
1378
+ const message = event.data
1379
+ for (const target of ['next-turn', 'next-step'] as const) {
1380
+ const ids = target === 'next-turn' ? acc.pendingTurn : acc.pendingStep
1381
+ const index = ids.indexOf(message.id)
1382
+ acc.ops += index < 0 ? ids.length : index + 1
1383
+ if (index < 0) continue
1384
+ ids.splice(index, 1)
1385
+ acc.ops += 1
1386
+ // Retire every pending row carrying this message id (duplicate ids
1387
+ // included), exactly like the reducer's full-array filter.
1388
+ const list = acc.pendingIndex.get(message.id)
1389
+ if (list !== undefined) {
1390
+ for (const entryIndex of list) retireReplayEntry(acc, entryIndex)
1391
+ acc.ops += 1
1392
+ }
1393
+ }
1394
+ const text = textOf(message.content)
1395
+ const images = imagesOf(message.content)
1396
+ const files = filesOf(message.content)
1397
+ if (message.source.kind === 'user' || (message.source.kind === 'plugin' && REMINDER_PLUGINS.has(message.source.plugin))) {
1398
+ appendReplayEntry(acc, { kind: 'user', text, notice: false, ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) })
1399
+ acc.stats = {
1400
+ ...acc.stats,
1401
+ contextSegments: {
1402
+ ...acc.stats.contextSegments,
1403
+ prompt: acc.stats.contextSegments.prompt + estimateTokens(text),
1404
+ },
1405
+ }
1406
+ return true
1407
+ }
1408
+ if (message.source.kind === 'plugin' && HIDDEN_SNAPSHOT_PLUGINS.has(message.source.plugin)) {
1409
+ acc.stats = {
1410
+ ...acc.stats,
1411
+ contextSegments: {
1412
+ ...acc.stats.contextSegments,
1413
+ system: acc.stats.contextSegments.system + estimateTokens(text),
1414
+ },
1415
+ }
1416
+ return true
1417
+ }
1418
+ const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
1419
+ ? message.source.summary
1420
+ : message.source.kind === 'plugin'
1421
+ ? message.source.plugin
1422
+ : message.source.kind
1423
+ const summary = boundContextSummary(notice)
1424
+ appendReplayEntry(acc, { kind: 'user', text: summary, notice: true })
1425
+ acc.stats = {
1426
+ ...acc.stats,
1427
+ contextSegments: {
1428
+ ...acc.stats.contextSegments,
1429
+ system: acc.stats.contextSegments.system + estimateTokens(summary),
1430
+ },
1431
+ }
1432
+ return true
1433
+ }
1434
+ case 'agent/inbox/spliced': {
1435
+ const { target, start, removedCount = 0, inserted } = event.data
1436
+ const ids = target === 'next-turn' ? acc.pendingTurn : acc.pendingStep
1437
+ const removed = ids.slice(start, start + removedCount)
1438
+ acc.ops += removed.length
1439
+ ids.splice(start, removedCount)
1440
+ acc.ops += removed.length
1441
+ for (const id of removed) {
1442
+ const list = acc.pendingIndex.get(id)
1443
+ if (list === undefined) continue
1444
+ for (const entryIndex of list) {
1445
+ const entry = acc.entries[entryIndex]
1446
+ if (entry !== undefined && entry.kind === 'pending' && entry.target === target) {
1447
+ retireReplayEntry(acc, entryIndex)
1448
+ }
1449
+ }
1450
+ }
1451
+ // Mirror the reducer's in-place order: inserted ids join at the splice
1452
+ // position (upstream `splice(start, removedCount, ...inserted)`), never
1453
+ // at the tail — the id list must stay coordinate-compatible with every
1454
+ // later inbox event.
1455
+ ids.splice(start, 0, ...inserted.map(message => message.id))
1456
+ for (const message of inserted) {
1457
+ const images = imagesOf(message.content)
1458
+ const files = filesOf(message.content)
1459
+ appendReplayEntry(acc, { kind: 'pending', messageId: message.id, target, text: pendingText(message.content), ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) })
1460
+ indexList(acc.pendingIndex, message.id).push(acc.entries.length - 1)
1461
+ acc.ops += 1
1462
+ }
1463
+ return true
1464
+ }
1465
+ case 'system/message': {
1466
+ // Mirrors the reducer's per-node fold: append adds, replace retires the
1467
+ // covered seq range, and the estimate prices the assembled prompt.
1468
+ const text = textOf(event.data.message.content)
1469
+ retireShadowedSystemNodes(acc.systemNodes, event.surfaceOp)
1470
+ acc.systemNodes.set(event.seq, text)
1471
+ acc.systemPrompt = assembleSystemPrompt(acc.systemNodes)
1472
+ acc.stats = {
1473
+ ...acc.stats,
1474
+ contextSegments: {
1475
+ ...acc.stats.contextSegments,
1476
+ system: estimateTokens(acc.systemPrompt),
1477
+ },
1478
+ }
1479
+ return true
1480
+ }
1481
+ case 'assistant/attempt': {
1482
+ // Mirrors the reducer: the step stays open across in-step retries; a
1483
+ // missing first-token anchor is restored (and accrued) from the
1484
+ // attempt's embedded stream, and only the live tails are dropped.
1485
+ const key = `${event.data.turn}:${event.data.step}`
1486
+ let changed = false
1487
+ if (!acc.firstChunkAt.has(key)) {
1488
+ const first = assistantStreamFirstTokenTime(event.data.stream ?? [])
1489
+ if (first !== undefined) {
1490
+ acc.firstChunkAt.set(key, first)
1491
+ const started = acc.stepStart.get(key)
1492
+ if (started !== undefined) {
1493
+ acc.stats = {
1494
+ ...acc.stats,
1495
+ ttftMs: acc.stats.ttftMs + Math.max(0, first - started),
1496
+ ttftSteps: acc.stats.ttftSteps + 1,
1497
+ }
1498
+ }
1499
+ changed = true
1500
+ }
1501
+ }
1502
+ const streamed = acc.streaming !== '' || acc.streamingReasoning !== ''
1503
+ acc.streaming = ''
1504
+ acc.streamingReasoning = ''
1505
+ return changed || streamed
1506
+ }
1507
+ case 'assistant/message': {
1508
+ const key = `${event.data.turn}:${event.data.step}`
1509
+ const started = acc.stepStart.get(key)
1510
+ acc.stepStart.delete(key)
1511
+ let firstChunk = acc.firstChunkAt.get(key)
1512
+ if (firstChunk === undefined) {
1513
+ // A replayed settlement has no live frames; the embedded stream's
1514
+ // first-token time restores both the anchor and the TTFT figures the
1515
+ // live path accumulates in applyAssistantStreamChunk.
1516
+ firstChunk = assistantStreamFirstTokenTime(event.data.stream ?? [])
1517
+ if (firstChunk !== undefined && started !== undefined) {
1518
+ acc.stats = {
1519
+ ...acc.stats,
1520
+ ttftMs: acc.stats.ttftMs + Math.max(0, firstChunk - started),
1521
+ ttftSteps: acc.stats.ttftSteps + 1,
1522
+ }
1523
+ }
1524
+ }
1525
+ acc.firstChunkAt.delete(key)
1526
+ if (acc.turnSteps.get(event.data.turn) === key) acc.turnSteps.delete(event.data.turn)
1527
+ const usage = event.data.usage
1528
+ const totals = acc.stats.usage
1529
+ const text = textOf(event.data.message.content)
1530
+ const reasoning = reasoningOf(event.data.message.content)
1531
+ acc.streaming = ''
1532
+ acc.streamingReasoning = ''
1533
+ appendReplayEntry(acc, { kind: 'assistant', text, reasoning, interrupted: event.data.interrupted === true ? true : undefined })
1534
+ acc.stats = {
1535
+ ...acc.stats,
1536
+ llmMs: acc.stats.llmMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
1537
+ usage: usage === undefined ? totals : {
1538
+ inputTokens: totals.inputTokens + usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
1539
+ outputTokens: totals.outputTokens + usage.outputTokens,
1540
+ cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0),
1541
+ },
1542
+ lastPromptTokens: usage === undefined ? acc.stats.lastPromptTokens
1543
+ : usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
1544
+ decodeMs: acc.stats.decodeMs + (firstChunk === undefined ? 0 : Math.max(0, event.time - firstChunk)),
1545
+ decodeTokens: acc.stats.decodeTokens + (firstChunk === undefined || usage === undefined ? 0 : usage.outputTokens),
1546
+ contextSegments: {
1547
+ ...acc.stats.contextSegments,
1548
+ thinking: acc.stats.contextSegments.thinking + estimateTokens(reasoning),
1549
+ assistant: acc.stats.contextSegments.assistant + estimateTokens(text),
1550
+ },
1551
+ }
1552
+ return true
1553
+ }
1554
+ case 'tool/call': {
1555
+ const data = event.data
1556
+ acc.toolStart.set(data.callId, event.time)
1557
+ const turnTools = acc.turnTools.get(data.turn) ?? new Set<string>()
1558
+ turnTools.add(data.callId)
1559
+ acc.turnTools.set(data.turn, turnTools)
1560
+ acc.toolCallOrdinal += 1
1561
+ appendReplayEntry(acc, {
1562
+ kind: 'tool',
1563
+ callId: data.callId,
1564
+ ordinal: acc.toolCallOrdinal,
1565
+ name: data.name,
1566
+ arguments: data.arguments,
1567
+ preview: toolArgumentsPreview(data.arguments, data.name),
1568
+ prompt: toolPromptPreview(data.name, data.arguments),
1569
+ state: 'running',
1570
+ summary: '',
1571
+ detail: undefined,
1572
+ })
1573
+ indexList(acc.toolIndex, data.callId).push(acc.entries.length - 1)
1574
+ acc.stats = {
1575
+ ...acc.stats,
1576
+ contextSegments: {
1577
+ ...acc.stats.contextSegments,
1578
+ tools: acc.stats.contextSegments.tools
1579
+ + (typeof data.arguments === 'string' ? estimateTokens(data.arguments) : 0),
1580
+ },
1581
+ }
1582
+ return true
1583
+ }
1584
+ case 'tool/result': {
1585
+ const block = event.data.message.content[0]
1586
+ const started = acc.toolStart.get(block.toolCallId)
1587
+ acc.toolStart.delete(block.toolCallId)
1588
+ const turnTools = acc.turnTools.get(event.data.turn)
1589
+ if (turnTools !== undefined) {
1590
+ turnTools.delete(block.toolCallId)
1591
+ if (turnTools.size === 0) acc.turnTools.delete(event.data.turn)
1592
+ }
1593
+ const rawText = textOf(block.content)
1594
+ const summary = boundContextSummary(rawText)
1595
+ const detail = toolResultDetail(event.data.meta, rawText)
1596
+ if (detail?.kind === 'diff') {
1597
+ const set = acc.turnFiles.get(event.data.turn) ?? new Set<string>()
1598
+ for (const diff of detail.diffs) set.add(diff.path)
1599
+ acc.turnFiles.set(event.data.turn, set)
1600
+ }
1601
+ const update = (entry: ToolEntry): ToolEntry => ({
1602
+ ...entry,
1603
+ state: block.isError === true ? 'error' as const : 'done' as const,
1604
+ summary,
1605
+ detail,
1606
+ })
1607
+ // Every matching row updates (duplicate callIds included); an id with no
1608
+ // registered index is a provable no-op no full-array fallback scan.
1609
+ updateReplayById<ToolEntry>(acc, acc.toolIndex, block.toolCallId, entry => entry.callId === block.toolCallId, update)
1610
+ acc.stats = {
1611
+ ...acc.stats,
1612
+ toolMs: acc.stats.toolMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
1613
+ contextSegments: {
1614
+ ...acc.stats.contextSegments,
1615
+ tools: acc.stats.contextSegments.tools + estimateTokens(rawText),
1616
+ },
1617
+ }
1618
+ return true
1619
+ }
1620
+ case 'todo/write':
1621
+ acc.todos = event.data.todos
1622
+ return true
1623
+ case 'turn/start': {
1624
+ const wasBusy = acc.busy
1625
+ acc.busy = true
1626
+ acc.busySince = wasBusy ? acc.busySince : event.time
1627
+ acc.todos = []
1628
+ acc.stats = { ...acc.stats, turns: acc.stats.turns + 1 }
1629
+ return true
1630
+ }
1631
+ case 'step/start': {
1632
+ const key = `${event.data.turn}:${event.data.step}`
1633
+ const previous = acc.turnSteps.get(event.data.turn)
1634
+ if (previous !== undefined && previous !== key) {
1635
+ acc.stepStart.delete(previous)
1636
+ acc.firstChunkAt.delete(previous)
1637
+ }
1638
+ acc.turnSteps.set(event.data.turn, key)
1639
+ acc.stepStart.set(key, event.time)
1640
+ acc.streaming = ''
1641
+ acc.streamingReasoning = ''
1642
+ acc.stats = { ...acc.stats, steps: acc.stats.steps + 1 }
1643
+ return true
1644
+ }
1645
+ case 'turn/end': {
1646
+ const reason = event.data.reason
1647
+ const appended: TranscriptEntry[] = []
1648
+ acc.streamingReasoning = ''
1649
+ acc.streaming = ''
1650
+ if (reason.kind === 'error') {
1651
+ const recovery = reason.error.code === 'MISSING_CREDENTIAL'
1652
+ ? ' · open /model to add an API key'
1653
+ : ''
1654
+ appended.push({ kind: 'error', text: `${reason.error.code}: ${reason.error.message}${recovery}` })
1655
+ } else {
1656
+ const marker = reason.kind === 'aborted'
1657
+ ? reason.reason.kind === 'user' ? 'turn cancelled by the user' : `turn cancelled (${reason.reason.kind})`
1658
+ : reason.kind === 'max-tokens'
1659
+ ? 'turn hit the output-token ceiling (max-tokens)'
1660
+ : reason.kind === 'blocked'
1661
+ ? 'turn ended blocked'
1662
+ : reason.kind === 'interrupted'
1663
+ ? 'turn was interrupted by a restart'
1664
+ : undefined
1665
+ if (marker !== undefined) appended.push({ kind: 'turn-marker', text: marker })
1666
+ }
1667
+ const files = acc.turnFiles.get(event.data.turn)
1668
+ acc.turnFiles.delete(event.data.turn)
1669
+ if (files !== undefined && files.size > 0) appended.push({ kind: 'files', paths: [...files].slice(0, 12) })
1670
+ const stepKey = acc.turnSteps.get(event.data.turn)
1671
+ if (stepKey !== undefined) {
1672
+ acc.stepStart.delete(stepKey)
1673
+ acc.firstChunkAt.delete(stepKey)
1674
+ acc.turnSteps.delete(event.data.turn)
1675
+ }
1676
+ const turnToolSet = acc.turnTools.get(event.data.turn)
1677
+ if (turnToolSet !== undefined) {
1678
+ for (const callId of turnToolSet) acc.toolStart.delete(callId)
1679
+ acc.turnTools.delete(event.data.turn)
1680
+ }
1681
+ finalizeReplayOrphans(acc)
1682
+ acc.busy = false
1683
+ acc.busySince = 0
1684
+ for (const entry of appended) appendReplayEntry(acc, entry)
1685
+ return true
1686
+ }
1687
+ case 'llm/retry': {
1688
+ const data = event.data
1689
+ acc.streaming = ''
1690
+ acc.streamingReasoning = ''
1691
+ appendReplayEntry(acc, {
1692
+ kind: 'retry',
1693
+ retryId: data.retryId,
1694
+ mode: data.mode,
1695
+ attempt: data.retry,
1696
+ max: 'maxRetries' in data ? data.maxRetries : data.retry,
1697
+ code: data.failure.code,
1698
+ delayMs: data.delayMs,
1699
+ state: 'running',
1700
+ })
1701
+ indexList(acc.retryIndex, data.retryId).push(acc.entries.length - 1)
1702
+ return true
1703
+ }
1704
+ case 'llm/retry-started': {
1705
+ const data = event.data
1706
+ updateReplayById<RetryEntry>(acc, acc.retryIndex, data.retryId, entry => entry.retryId === data.retryId, entry => ({ ...entry, state: 'done' as const }))
1707
+ return true
1708
+ }
1709
+ case 'sandbox/mode':
1710
+ acc.sandbox = event.data.mode
1711
+ return true
1712
+ case 'goal/change': {
1713
+ const data = event.data
1714
+ const clip = (text: string): string => (text.length > 60 ? `${text.slice(0, 59)}…` : text)
1715
+ if (data.operation === 'clear') {
1716
+ acc.goal = undefined
1717
+ appendReplayEntry(acc, { kind: 'turn-marker', text: '◎ goal cleared' })
1718
+ return true
1719
+ }
1720
+ const goal: GoalFold = {
1721
+ objective: data.goal.objective,
1722
+ phase: data.goal.phase,
1723
+ rounds: data.roundsStarted,
1724
+ max: data.goal.maxGoalRounds,
1725
+ blocked: data.goal.blockedReason?.message ?? '',
1726
+ }
1727
+ const line = data.operation === 'create'
1728
+ ? `◎ goal: ${clip(data.goal.objective)}`
1729
+ : data.operation === 'complete'
1730
+ ? '◎ goal complete'
1731
+ : data.operation === 'pause'
1732
+ ? '◎ goal paused'
1733
+ : data.operation === 'resume'
1734
+ ? '◎ goal resumed'
1735
+ : data.operation === 'block'
1736
+ ? `◎ goal blocked: ${clip(goal.blocked)}`
1737
+ : undefined
1738
+ acc.goal = goal
1739
+ if (line !== undefined) appendReplayEntry(acc, { kind: 'turn-marker', text: line })
1740
+ return true
1741
+ }
1742
+ case 'schedule/change':
1743
+ acc.schedules = applyScheduleChange(acc.schedules, event.data)
1744
+ acc.ops += 1
1745
+ return true
1746
+ case 'session/title':
1747
+ acc.title = event.data.title
1748
+ return true
1749
+ case 'compaction/summary':
1750
+ if (acc.compactionTokens.size >= MAX_COMPACTION_SUMMARY_RESIDUE) {
1751
+ const oldest = acc.compactionTokens.keys().next().value
1752
+ if (oldest !== undefined) acc.compactionTokens.delete(oldest)
1753
+ }
1754
+ acc.compactionTokens.set(event.data.compactionId, event.data.shadowedTokenCount)
1755
+ return true
1756
+ case 'compaction/prune':
1757
+ acc.lastPruneTokens = event.data.shadowedTokenCount
1758
+ return true
1759
+ case 'compaction/end': {
1760
+ const ok = event.data.error === undefined
1761
+ const tokens = acc.compactionTokens.get(event.data.compactionId) ?? acc.lastPruneTokens
1762
+ acc.compactionTokens.delete(event.data.compactionId)
1763
+ appendReplayEntry(acc, { kind: 'compaction', ok, tokens, error: event.data.error ?? '' })
1764
+ return true
1765
+ }
1766
+ case 'request/context':
1767
+ acc.stats = { ...acc.stats, contextWindow: event.data.contextWindow ?? acc.stats.contextWindow }
1768
+ return true
1769
+ case 'request/header': {
1770
+ const config = event.data.header.config
1771
+ acc.model = `${config.provider}/${config.model}`
1772
+ acc.stats = {
1773
+ ...acc.stats,
1774
+ reasoningEffort: config.reasoningEffort === undefined ? '' : String(config.reasoningEffort),
1775
+ }
1776
+ return true
1777
+ }
1778
+ case 'plan/mode':
1779
+ acc.plan = event.data.active
1780
+ return true
1781
+ case 'permission/preset':
1782
+ acc.permission = event.data.preset
1783
+ return true
1784
+ case 'command/run': {
1785
+ const data = event.data
1786
+ appendReplayEntry(acc, {
1787
+ kind: 'command',
1788
+ commandId: data.commandId,
1789
+ name: data.name,
1790
+ args: data.args ?? '',
1791
+ state: 'running',
1792
+ summary: '',
1793
+ })
1794
+ indexList(acc.commandIndex, data.commandId).push(acc.entries.length - 1)
1795
+ return true
1796
+ }
1797
+ case 'command/done': {
1798
+ const data = event.data
1799
+ const update = (candidate: CommandEntry): CommandEntry => ({
1800
+ ...candidate,
1801
+ state: data.kind === 'success' ? 'done' as const : 'error' as const,
1802
+ summary: boundContextSummary(data.text ?? ''),
1803
+ })
1804
+ updateReplayById<CommandEntry>(acc, acc.commandIndex, data.commandId, entry => entry.commandId === data.commandId, update)
1805
+ return true
1806
+ }
1807
+ default:
1808
+ return false
1809
+ }
1810
+ }
1811
+
1812
+ /**
1813
+ * Materialize the accumulated fold as a `TranscriptView`, compacting any
1814
+ * retired tombstones. The anchors maps are handed through as-is (their
1815
+ * content is identical to a sequential fold's).
1816
+ *
1817
+ * @internal Test-instrumentation path; `projectEvents` is the public entry.
1818
+ */
1819
+ export function finishReplay(acc: ReplayAccumulator): TranscriptView {
1820
+ return materializeReplayView(acc, false)
1821
+ }
1822
+
1823
+ /**
1824
+ * Materialize the accumulated fold as a fresh immutable snapshot for the
1825
+ * live store. Unlike {@link finishReplay} — the one-shot replay entry, which
1826
+ * hands the accumulator's own arrays through because the accumulator is
1827
+ * discarded — every array a renderer can hold is copied here, so later
1828
+ * folds never mutate a snapshot already handed out. Same fields, same
1829
+ * tombstone compaction.
1830
+ *
1831
+ * @internal Live-store path; `projectEvents` is the public entry.
1832
+ */
1833
+ export function snapshotReplayView(acc: ReplayAccumulator): TranscriptView {
1834
+ return materializeReplayView(acc, true)
1835
+ }
1836
+
1837
+ /** Field-for-field materialization; `copy` selects snapshot array isolation. */
1838
+ function materializeReplayView(acc: ReplayAccumulator, copy: boolean): TranscriptView {
1839
+ const entries: readonly TranscriptEntry[] = acc.removedCount === 0
1840
+ ? (copy ? [...acc.entries] : acc.entries) as TranscriptEntry[]
1841
+ : acc.entries.filter((entry): entry is TranscriptEntry => entry !== undefined)
1842
+ if (acc.removedCount > 0) acc.ops += acc.entries.length
1843
+ return {
1844
+ entries,
1845
+ streaming: acc.streaming,
1846
+ streamingReasoning: acc.streamingReasoning,
1847
+ todos: acc.todos,
1848
+ toolCallOrdinal: acc.toolCallOrdinal,
1849
+ busy: acc.busy,
1850
+ busySince: acc.busySince,
1851
+ model: acc.model,
1852
+ plan: acc.plan,
1853
+ permission: acc.permission,
1854
+ title: acc.title,
1855
+ systemPrompt: acc.systemPrompt,
1856
+ sandbox: acc.sandbox,
1857
+ goal: acc.goal,
1858
+ schedules: acc.schedules,
1859
+ pending: { 'next-turn': [...acc.pendingTurn], 'next-step': [...acc.pendingStep] },
1860
+ stats: acc.stats,
1861
+ // Handed-out views get their own anchors snapshot: the accumulator keeps
1862
+ // folding its live containers, and no consumer may observe that.
1863
+ anchors: {
1864
+ stepStart: new Map(acc.stepStart),
1865
+ toolStart: new Map(acc.toolStart),
1866
+ firstChunkAt: new Map(acc.firstChunkAt),
1867
+ compactionTokens: new Map(acc.compactionTokens),
1868
+ lastPruneTokens: acc.lastPruneTokens,
1869
+ turnFiles: new Map([...acc.turnFiles].map(([turn, files]) => [turn, new Set(files)])),
1870
+ turnSteps: new Map(acc.turnSteps),
1871
+ turnTools: new Map([...acc.turnTools].map(([turn, tools]) => [turn, new Set(tools)])),
1872
+ systemNodes: new Map(acc.systemNodes),
1873
+ },
1874
+ }
1875
+ }
1876
+
1877
+ /**
1878
+ * Fold a replayed event history into one view.
1879
+ *
1880
+ * Folding is near-linear in the log size: the mutable replay accumulator
1881
+ * appends in place and resolves id-keyed updates through index maps, so a
1882
+ * long persisted session replays without the O(N²) copy-on-write rebuilds a
1883
+ * naive sequential fold would incur. The result is identical to folding
1884
+ * {@link projectEvent} per event in order.
1885
+ * @param events - events in `seq` order.
1886
+ * @returns the folded view.
1887
+ */
1888
+ export function projectEvents(events: readonly SessionEvent[]): TranscriptView {
1889
+ const acc = createReplayAccumulator()
1890
+ for (const event of events) replayProjectEvent(acc, event)
1891
+ return finishReplay(acc)
1892
+ }
1893
+
1894
+ /**
1895
+ * Fold one process-local assistant-stream chunk frame (session-log v2+ keeps
1896
+ * durable logs settlement-only; live typing rides the `agent/assistant-stream`
1897
+ * agent event). Same first-token anchoring the durable `assistant/chunk` event
1898
+ * used to carry: the first non-empty delta anchors the TTFT and empty
1899
+ * keep-alive deltas do not count. The caller maps the frame's attempt to the
1900
+ * `turn:step` key (the start frame owns turn/step; chunk frames do not).
1901
+ * @param acc - the live replay accumulator.
1902
+ * @param key - the `turn:step` key the attempt's start frame declared.
1903
+ * @param time - the frame's safe-integer timestamp.
1904
+ * @param chunk - the model chunk the frame carries.
1905
+ * @returns whether the accumulator changed (the store stays silent otherwise).
1906
+ */
1907
+ export function applyAssistantStreamChunk(acc: ReplayAccumulator, key: string, time: number, chunk: StreamChunk): boolean {
1908
+ // First-token latency uses the kernel's isTokenDelta rule (a non-empty
1909
+ // text, reasoning, or tool-call fragment counts; block/usage/finish chunks
1910
+ // do not), so live frames and replayed embedded streams time the same
1911
+ // token.
1912
+ if (isTokenDelta(chunk) && !acc.firstChunkAt.has(key)) {
1913
+ acc.firstChunkAt.set(key, time)
1914
+ const started = acc.stepStart.get(key)
1915
+ if (started !== undefined) {
1916
+ acc.stats = {
1917
+ ...acc.stats,
1918
+ ttftMs: acc.stats.ttftMs + Math.max(0, time - started),
1919
+ ttftSteps: acc.stats.ttftSteps + 1,
1920
+ }
1921
+ }
1922
+ }
1923
+ if (chunk.type === 'text-delta') {
1924
+ acc.streaming = appendStreamingTail(acc.streaming, chunk.text)
1925
+ return true
1926
+ }
1927
+ if (chunk.type === 'reasoning-delta') {
1928
+ acc.streamingReasoning = appendStreamingTail(acc.streamingReasoning, chunk.text)
1929
+ return true
1930
+ }
1931
+ return false
1932
+ }
1933
+
1934
+ /**
1935
+ * Drop the live streaming tails without a settlement (an `agent/assistant-stream`
1936
+ * end frame with an `abandoned` outcome, or a session switch). The next start
1937
+ * frame rebuilds from scratch.
1938
+ * @param acc - the live replay accumulator.
1939
+ * @returns whether any tail text was discarded.
1940
+ */
1941
+ export function clearAssistantStream(acc: ReplayAccumulator): boolean {
1942
+ const streamed = acc.streaming !== '' || acc.streamingReasoning !== ''
1943
+ acc.streaming = ''
1944
+ acc.streamingReasoning = ''
1945
+ return streamed
1946
+ }
1947
+
1948
+ /**
1949
+ * The append-only flush boundary for a transcript view: the count of entries
1950
+ * no later event can remove. Entries at or beyond this index are mutable and
1951
+ * must stay in the live tree.
1952
+ *
1953
+ * `pending` rows are excluded even though they are not a running tool/retry:
1954
+ * the inbox claims or cancels them durably (`agent/inbox/spliced` removals,
1955
+ * `user/message` retirement), and an append-only `<Static>` flush cannot
1956
+ * erase a row that vanishes from the view — the retired row would ghost on
1957
+ * screen until the next source-backed replay. Running commands join the
1958
+ * mutable boundary for the same reason in reverse: `command/done` mutates the
1959
+ * row's state/summary, so a flushed row would keep its stale running mark
1960
+ * until a resize-triggered replay. Everything else (including a completed
1961
+ * tail) is final: later events only APPEND new rows.
1962
+ * @param entries - the view's transcript entries in order.
1963
+ * @returns the count of entries safe to flush (0 for an empty transcript).
1964
+ */
1965
+ export function settledEntryCount(entries: readonly TranscriptEntry[]): number {
1966
+ for (let index = 0; index < entries.length; index++) {
1967
+ const entry = entries[index]
1968
+ if (entry.kind === 'pending') return index
1969
+ if (entry.kind === 'tool' && entry.state === 'running') return index
1970
+ if (entry.kind === 'retry' && entry.state === 'running') return index
1971
+ if (entry.kind === 'command' && entry.state === 'running') return index
1972
+ }
1973
+ return entries.length
1974
+ }