dsh-code 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +20 -6
- package/README.md +20 -6
- package/lib/index.mjs +2685 -622
- package/lib/types/app.d.ts +77 -1
- package/lib/types/history.d.ts +15 -4
- package/lib/types/index.d.ts +48 -0
- package/lib/types/kernel-panels.d.ts +7 -0
- package/lib/types/permissions.d.ts +37 -0
- package/lib/types/presets.d.ts +2 -0
- package/lib/types/provider-settings.d.ts +144 -0
- package/lib/types/questions.d.ts +2 -0
- package/lib/types/render/animations.d.ts +8 -6
- package/lib/types/render/lines.d.ts +6 -0
- package/lib/types/render/markdown.d.ts +3 -3
- package/lib/types/render/projection.d.ts +95 -3
- package/lib/types/render/status.d.ts +26 -36
- package/lib/types/render/text.d.ts +14 -7
- package/lib/types/render/tool-detail.d.ts +3 -1
- package/lib/types/render/tool-preview.d.ts +4 -1
- package/lib/types/session-directory.d.ts +15 -0
- package/lib/types/store.d.ts +13 -2
- package/lib/types/version.d.ts +5 -0
- package/package.json +1 -1
- package/src/app.ts +847 -150
- package/src/approval.ts +11 -2
- package/src/history.ts +20 -5
- package/src/index.ts +402 -159
- package/src/kernel-panels.ts +45 -8
- package/src/permissions.ts +85 -0
- package/src/presets.ts +12 -0
- package/src/provider-settings.ts +520 -0
- package/src/questions.ts +15 -5
- package/src/render/animations.ts +32 -18
- package/src/render/lines.ts +21 -6
- package/src/render/markdown.ts +302 -4
- package/src/render/projection.ts +665 -10
- package/src/render/status.ts +68 -162
- package/src/render/text.ts +28 -9
- package/src/render/tool-detail.ts +81 -40
- package/src/render/tool-preview.ts +18 -2
- package/src/session-directory.ts +44 -5
- package/src/skills.ts +8 -4
- package/src/store.ts +26 -8
- package/src/version.ts +16 -0
package/src/render/projection.ts
CHANGED
|
@@ -28,6 +28,16 @@ import { toolResultDetail, type ToolDetail } from './tool-detail.ts'
|
|
|
28
28
|
/** In-flight UI buffers are tails; the assembled assistant message is authoritative. */
|
|
29
29
|
const MAX_STREAMING_CHARS = 65_536
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Upper bound on remembered `compaction/summary` shadow prices waiting for a
|
|
33
|
+
* matching `compaction/end`. Compactions are sequential and rare, so a few
|
|
34
|
+
* slots suffice; an aborted compaction (summary without end) otherwise leaves
|
|
35
|
+
* an unbounded residue in `anchors.compactionTokens`. An evicted price
|
|
36
|
+
* degrades to the documented `lastPruneTokens` fallback, exactly like a
|
|
37
|
+
* missing summary.
|
|
38
|
+
*/
|
|
39
|
+
const MAX_COMPACTION_SUMMARY_RESIDUE = 16
|
|
40
|
+
|
|
31
41
|
/** Append one delta without retaining an unbounded duplicate of the live reply. */
|
|
32
42
|
function appendStreamingTail(current: string, delta: string): string {
|
|
33
43
|
const next = current + delta
|
|
@@ -273,9 +283,21 @@ export interface TranscriptView {
|
|
|
273
283
|
/**
|
|
274
284
|
* Fold-internal timing anchors, never rendered: open step and tool-call
|
|
275
285
|
* start timestamps the next `assistant/message` / `tool/result` resolves
|
|
276
|
-
* against. Keyed `turn:step` and by call id.
|
|
286
|
+
* against. Keyed `turn:step` and by call id. `turnSteps`/`turnTools`
|
|
287
|
+
* track which step/tool anchors still belong to the open turn so
|
|
288
|
+
* `turn/end` (and a superseding `step/start`) can sweep anchors an
|
|
289
|
+
* interruption left behind; `turnFiles` keys mutated paths by turn.
|
|
277
290
|
*/
|
|
278
|
-
readonly anchors: {
|
|
291
|
+
readonly anchors: {
|
|
292
|
+
stepStart: Map<string, number>
|
|
293
|
+
toolStart: Map<string, number>
|
|
294
|
+
firstChunkAt: Map<string, number>
|
|
295
|
+
compactionTokens: Map<string, number>
|
|
296
|
+
lastPruneTokens: number
|
|
297
|
+
turnFiles: Map<number, Set<string>>
|
|
298
|
+
turnSteps: Map<number, string>
|
|
299
|
+
turnTools: Map<number, Set<string>>
|
|
300
|
+
}
|
|
279
301
|
}
|
|
280
302
|
|
|
281
303
|
/** Join the text blocks of a content list; non-text blocks contribute nothing. */
|
|
@@ -323,7 +345,7 @@ export function createTranscriptView(): TranscriptView {
|
|
|
323
345
|
goal: undefined,
|
|
324
346
|
pending: { 'next-turn': [], 'next-step': [] },
|
|
325
347
|
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: '' },
|
|
326
|
-
anchors: { stepStart: new Map(), toolStart: new Map(), firstChunkAt: new Map(), compactionTokens: new Map(), lastPruneTokens: 0, turnFiles: new Map() },
|
|
348
|
+
anchors: { stepStart: new Map(), toolStart: new Map(), firstChunkAt: new Map(), compactionTokens: new Map(), lastPruneTokens: 0, turnFiles: new Map(), turnSteps: new Map(), turnTools: new Map() },
|
|
327
349
|
}
|
|
328
350
|
}
|
|
329
351
|
|
|
@@ -449,6 +471,9 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
|
|
|
449
471
|
view.anchors.stepStart.delete(key)
|
|
450
472
|
const firstChunk = view.anchors.firstChunkAt.get(key)
|
|
451
473
|
view.anchors.firstChunkAt.delete(key)
|
|
474
|
+
// The assembled message consumes the turn's current step anchor; a
|
|
475
|
+
// later `turn/end` sweep then has nothing left to clean for this step.
|
|
476
|
+
if (view.anchors.turnSteps.get(event.data.turn) === key) view.anchors.turnSteps.delete(event.data.turn)
|
|
452
477
|
const usage = event.data.usage
|
|
453
478
|
const totals = view.stats.usage
|
|
454
479
|
const text = textOf(event.data.message.content)
|
|
@@ -487,6 +512,11 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
|
|
|
487
512
|
case 'tool/call': {
|
|
488
513
|
const data = event.data
|
|
489
514
|
view.anchors.toolStart.set(data.callId, event.time)
|
|
515
|
+
// Remember the call's turn so `turn/end` can sweep a start that never
|
|
516
|
+
// pairs with a result (an interrupted tool otherwise leaks its anchor).
|
|
517
|
+
const turnTools = view.anchors.turnTools.get(data.turn) ?? new Set<string>()
|
|
518
|
+
turnTools.add(data.callId)
|
|
519
|
+
view.anchors.turnTools.set(data.turn, turnTools)
|
|
490
520
|
return {
|
|
491
521
|
...view,
|
|
492
522
|
entries: [...view.entries, {
|
|
@@ -513,6 +543,13 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
|
|
|
513
543
|
const block = event.data.message.content[0]
|
|
514
544
|
const started = view.anchors.toolStart.get(block.toolCallId)
|
|
515
545
|
view.anchors.toolStart.delete(block.toolCallId)
|
|
546
|
+
// Deregister the call from its turn's registry so `turn/end` does not
|
|
547
|
+
// sweep a start that already paired with a result.
|
|
548
|
+
const turnTools = view.anchors.turnTools.get(event.data.turn)
|
|
549
|
+
if (turnTools !== undefined) {
|
|
550
|
+
turnTools.delete(block.toolCallId)
|
|
551
|
+
if (turnTools.size === 0) view.anchors.turnTools.delete(event.data.turn)
|
|
552
|
+
}
|
|
516
553
|
const rawText = textOf(block.content)
|
|
517
554
|
const summary = boundContextSummary(rawText)
|
|
518
555
|
// The verbose expansion self-serves from the persisted presentation
|
|
@@ -555,14 +592,28 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
|
|
|
555
592
|
todos: [],
|
|
556
593
|
stats: { ...view.stats, turns: view.stats.turns + 1 },
|
|
557
594
|
}
|
|
558
|
-
case 'step/start':
|
|
559
|
-
|
|
595
|
+
case 'step/start': {
|
|
596
|
+
// A step supersedes the turn's previous step: if that step never
|
|
597
|
+
// assembled a message (interrupted), its timing anchors are stale the
|
|
598
|
+
// moment the next step opens and are swept here instead of leaking.
|
|
599
|
+
const key = `${event.data.turn}:${event.data.step}`
|
|
600
|
+
const previous = view.anchors.turnSteps.get(event.data.turn)
|
|
601
|
+
if (previous !== undefined && previous !== key) {
|
|
602
|
+
view.anchors.stepStart.delete(previous)
|
|
603
|
+
view.anchors.firstChunkAt.delete(previous)
|
|
604
|
+
}
|
|
605
|
+
view.anchors.turnSteps.set(event.data.turn, key)
|
|
606
|
+
view.anchors.stepStart.set(key, event.time)
|
|
560
607
|
return { ...view, stats: { ...view.stats, steps: view.stats.steps + 1 } }
|
|
608
|
+
}
|
|
561
609
|
case 'turn/end': {
|
|
562
610
|
const reason = event.data.reason
|
|
563
611
|
const appended: TranscriptEntry[] = []
|
|
564
612
|
if (reason.kind === 'error') {
|
|
565
|
-
|
|
613
|
+
const recovery = reason.error.code === 'MISSING_CREDENTIAL'
|
|
614
|
+
? ' · open /model to add an API key'
|
|
615
|
+
: ''
|
|
616
|
+
appended.push({ kind: 'error', text: `${reason.error.code}: ${reason.error.message}${recovery}` })
|
|
566
617
|
} else {
|
|
567
618
|
// Non-error outcomes deserve their own durable row (the web renders
|
|
568
619
|
// distinct max-tokens / abort / interruption nodes); `completed` stays
|
|
@@ -583,6 +634,20 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
|
|
|
583
634
|
const files = view.anchors.turnFiles.get(event.data.turn)
|
|
584
635
|
view.anchors.turnFiles.delete(event.data.turn)
|
|
585
636
|
if (files !== undefined && files.size > 0) appended.push({ kind: 'files', paths: [...files].slice(0, 12) })
|
|
637
|
+
// Derivable boundary sweep: the turn is over, so any step/tool anchors
|
|
638
|
+
// it left behind (interruptions that never produced their message or
|
|
639
|
+
// result) can never be resolved and are reclaimed now.
|
|
640
|
+
const stepKey = view.anchors.turnSteps.get(event.data.turn)
|
|
641
|
+
if (stepKey !== undefined) {
|
|
642
|
+
view.anchors.stepStart.delete(stepKey)
|
|
643
|
+
view.anchors.firstChunkAt.delete(stepKey)
|
|
644
|
+
view.anchors.turnSteps.delete(event.data.turn)
|
|
645
|
+
}
|
|
646
|
+
const turnToolSet = view.anchors.turnTools.get(event.data.turn)
|
|
647
|
+
if (turnToolSet !== undefined) {
|
|
648
|
+
for (const callId of turnToolSet) view.anchors.toolStart.delete(callId)
|
|
649
|
+
view.anchors.turnTools.delete(event.data.turn)
|
|
650
|
+
}
|
|
586
651
|
if (appended.length === 0) return { ...view, busy: false, busySince: 0 }
|
|
587
652
|
return { ...view, busy: false, busySince: 0, entries: [...view.entries, ...appended] }
|
|
588
653
|
}
|
|
@@ -651,7 +716,12 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
|
|
|
651
716
|
return { ...view, title: event.data.title }
|
|
652
717
|
case 'compaction/summary':
|
|
653
718
|
// Remember the shadow price so the matching `compaction/end` row can
|
|
654
|
-
// state what the compaction reclaimed.
|
|
719
|
+
// state what the compaction reclaimed. The map is capped so an aborted
|
|
720
|
+
// compaction (summary without end) cannot leave an unbounded residue.
|
|
721
|
+
if (view.anchors.compactionTokens.size >= MAX_COMPACTION_SUMMARY_RESIDUE) {
|
|
722
|
+
const oldest = view.anchors.compactionTokens.keys().next().value
|
|
723
|
+
if (oldest !== undefined) view.anchors.compactionTokens.delete(oldest)
|
|
724
|
+
}
|
|
655
725
|
view.anchors.compactionTokens.set(event.data.compactionId, event.data.shadowedTokenCount)
|
|
656
726
|
return view
|
|
657
727
|
case 'compaction/prune':
|
|
@@ -730,13 +800,594 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
|
|
|
730
800
|
}
|
|
731
801
|
}
|
|
732
802
|
|
|
803
|
+
/**
|
|
804
|
+
* Mutable replay accumulator: folds a persisted log into the identical view
|
|
805
|
+
* `projectEvent` would produce, but in near-linear time. Where `projectEvent`
|
|
806
|
+
* is copy-on-write — every append/scan rebuilds the whole `entries` array, so
|
|
807
|
+
* folding a full log costs O(N²) — the accumulator appends by push, resolves
|
|
808
|
+
* id-keyed updates (tool/result, command/done, retry-started) through index
|
|
809
|
+
* maps, and tombstones retired pending rows, so the whole log folds in O(N)
|
|
810
|
+
* plus one compaction pass when tombstones exist.
|
|
811
|
+
*
|
|
812
|
+
* Index maps never delete: every appended row registers its index, so an id
|
|
813
|
+
* lookup miss provably means no matching row exists and the update is an O(1)
|
|
814
|
+
* no-op (a malicious/orphan-heavy log cannot force per-orphan full-array
|
|
815
|
+
* scans). Each id maps to ALL of its indices, so a duplicate id updates every
|
|
816
|
+
* matching row exactly like the copy-on-write reducer.
|
|
817
|
+
*
|
|
818
|
+
* @internal Exported only so tests can (a) prove replay ≡ sequential
|
|
819
|
+
* `projectEvent` folds and (b) assert the linear complexity deterministically
|
|
820
|
+
* via {@link ReplayAccumulator.ops}, which counts entry-level container work
|
|
821
|
+
* instead of relying on wall-clock thresholds. No public consumer.
|
|
822
|
+
*/
|
|
823
|
+
export interface ReplayAccumulator {
|
|
824
|
+
/** Working entry list; `undefined` marks a retired pending row (tombstone). */
|
|
825
|
+
entries: (TranscriptEntry | undefined)[]
|
|
826
|
+
/** callId → every index into `entries` holding a `tool` row with that id. */
|
|
827
|
+
toolIndex: Map<string, number[]>
|
|
828
|
+
/** commandId → every index into `entries` holding a `command` row with that id. */
|
|
829
|
+
commandIndex: Map<string, number[]>
|
|
830
|
+
/** retryId → every index into `entries` holding a `retry` row with that id. */
|
|
831
|
+
retryIndex: Map<string, number[]>
|
|
832
|
+
/** messageId → every index into `entries` holding a `pending` row with that id. */
|
|
833
|
+
pendingIndex: Map<string, number[]>
|
|
834
|
+
/** Tombstone count; zero means `entries` is already the final array. */
|
|
835
|
+
removedCount: number
|
|
836
|
+
/** Mutable inbox id lists, mirroring `view.pending` order per target. */
|
|
837
|
+
pendingTurn: string[]
|
|
838
|
+
pendingStep: string[]
|
|
839
|
+
streaming: string
|
|
840
|
+
streamingReasoning: string
|
|
841
|
+
todos: readonly TodoItem[]
|
|
842
|
+
busy: boolean
|
|
843
|
+
busySince: number
|
|
844
|
+
model: string
|
|
845
|
+
plan: boolean
|
|
846
|
+
permission: string
|
|
847
|
+
title: string
|
|
848
|
+
sandbox: string
|
|
849
|
+
goal: GoalFold | undefined
|
|
850
|
+
stats: TranscriptStats
|
|
851
|
+
stepStart: Map<string, number>
|
|
852
|
+
toolStart: Map<string, number>
|
|
853
|
+
firstChunkAt: Map<string, number>
|
|
854
|
+
compactionTokens: Map<string, number>
|
|
855
|
+
lastPruneTokens: number
|
|
856
|
+
turnFiles: Map<number, Set<string>>
|
|
857
|
+
turnSteps: Map<number, string>
|
|
858
|
+
turnTools: Map<number, Set<string>>
|
|
859
|
+
/** Entry-level container operations performed so far (test instrumentation). */
|
|
860
|
+
ops: number
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/** @internal A fresh replay accumulator whose state mirrors `createTranscriptView()`. */
|
|
864
|
+
export function createReplayAccumulator(): ReplayAccumulator {
|
|
865
|
+
return {
|
|
866
|
+
entries: [],
|
|
867
|
+
toolIndex: new Map(),
|
|
868
|
+
commandIndex: new Map(),
|
|
869
|
+
retryIndex: new Map(),
|
|
870
|
+
pendingIndex: new Map(),
|
|
871
|
+
removedCount: 0,
|
|
872
|
+
pendingTurn: [],
|
|
873
|
+
pendingStep: [],
|
|
874
|
+
streaming: '',
|
|
875
|
+
streamingReasoning: '',
|
|
876
|
+
todos: [],
|
|
877
|
+
busy: false,
|
|
878
|
+
busySince: 0,
|
|
879
|
+
model: '',
|
|
880
|
+
plan: false,
|
|
881
|
+
permission: '',
|
|
882
|
+
title: '',
|
|
883
|
+
sandbox: '',
|
|
884
|
+
goal: undefined,
|
|
885
|
+
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: '' },
|
|
886
|
+
stepStart: new Map(),
|
|
887
|
+
toolStart: new Map(),
|
|
888
|
+
firstChunkAt: new Map(),
|
|
889
|
+
compactionTokens: new Map(),
|
|
890
|
+
lastPruneTokens: 0,
|
|
891
|
+
turnFiles: new Map(),
|
|
892
|
+
turnSteps: new Map(),
|
|
893
|
+
turnTools: new Map(),
|
|
894
|
+
ops: 0,
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** Append one entry (O(1)) and account the push. */
|
|
899
|
+
function appendReplayEntry(acc: ReplayAccumulator, entry: TranscriptEntry): void {
|
|
900
|
+
acc.entries.push(entry)
|
|
901
|
+
acc.ops += 1
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Get (or create) the index list an id owns. Lists are never removed: every
|
|
906
|
+
* appended row registers its index, so a lookup miss later proves no matching
|
|
907
|
+
* row exists and the caller can no-op in O(1).
|
|
908
|
+
*/
|
|
909
|
+
function indexList(map: Map<string, number[]>, id: string): number[] {
|
|
910
|
+
let list = map.get(id)
|
|
911
|
+
if (list === undefined) {
|
|
912
|
+
list = []
|
|
913
|
+
map.set(id, list)
|
|
914
|
+
}
|
|
915
|
+
return list
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Apply an id-keyed update to every row that registered the id, mirroring the
|
|
920
|
+
* copy-on-write reducer's full-array map semantics (all matching rows update,
|
|
921
|
+
* in order). Each registered index is O(1), so a duplicate id costs
|
|
922
|
+
* O(#duplicates) — never a full-array scan. The kind+id re-check is defensive:
|
|
923
|
+
* registered indices are valid by construction, because tool/command/retry
|
|
924
|
+
* rows are never removed and tombstones never shift indices.
|
|
925
|
+
*/
|
|
926
|
+
function updateReplayById<T extends TranscriptEntry>(
|
|
927
|
+
acc: ReplayAccumulator,
|
|
928
|
+
map: Map<string, number[]>,
|
|
929
|
+
id: string,
|
|
930
|
+
isMatch: (entry: T) => boolean,
|
|
931
|
+
update: (entry: T) => T,
|
|
932
|
+
): void {
|
|
933
|
+
const list = map.get(id)
|
|
934
|
+
if (list === undefined) return // miss provably means no matching row
|
|
935
|
+
for (const index of list) {
|
|
936
|
+
const entry = acc.entries[index]
|
|
937
|
+
if (entry === undefined || !isMatch(entry as T)) continue
|
|
938
|
+
acc.entries[index] = update(entry as T)
|
|
939
|
+
acc.ops += 1
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/** Tombstone a retired pending row, keeping every other index stable. */
|
|
944
|
+
function retireReplayEntry(acc: ReplayAccumulator, index: number): void {
|
|
945
|
+
if (acc.entries[index] !== undefined) {
|
|
946
|
+
acc.entries[index] = undefined
|
|
947
|
+
acc.removedCount += 1
|
|
948
|
+
acc.ops += 1
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Fold one session event into a replay accumulator. This mirrors
|
|
954
|
+
* {@link projectEvent} case for case — same stats arithmetic, same anchor
|
|
955
|
+
* set/delete behavior, same entry shapes — so the finished view is identical
|
|
956
|
+
* to a sequential fold; only the `entries` container operations are mutable.
|
|
957
|
+
*
|
|
958
|
+
* @internal Test-instrumentation path; `projectEvents` is the public entry.
|
|
959
|
+
*/
|
|
960
|
+
export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent): void {
|
|
961
|
+
switch (event.type) {
|
|
962
|
+
case 'user/message': {
|
|
963
|
+
const message = event.data
|
|
964
|
+
for (const target of ['next-turn', 'next-step'] as const) {
|
|
965
|
+
const ids = target === 'next-turn' ? acc.pendingTurn : acc.pendingStep
|
|
966
|
+
const index = ids.indexOf(message.id)
|
|
967
|
+
acc.ops += index < 0 ? ids.length : index + 1
|
|
968
|
+
if (index < 0) continue
|
|
969
|
+
ids.splice(index, 1)
|
|
970
|
+
acc.ops += 1
|
|
971
|
+
// Retire every pending row carrying this message id (duplicate ids
|
|
972
|
+
// included), exactly like the reducer's full-array filter.
|
|
973
|
+
const list = acc.pendingIndex.get(message.id)
|
|
974
|
+
if (list !== undefined) {
|
|
975
|
+
for (const entryIndex of list) retireReplayEntry(acc, entryIndex)
|
|
976
|
+
acc.ops += 1
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
const text = textOf(message.content)
|
|
980
|
+
if (message.source.kind === 'user') {
|
|
981
|
+
appendReplayEntry(acc, { kind: 'user', text, notice: false })
|
|
982
|
+
acc.stats = {
|
|
983
|
+
...acc.stats,
|
|
984
|
+
contextSegments: {
|
|
985
|
+
...acc.stats.contextSegments,
|
|
986
|
+
prompt: acc.stats.contextSegments.prompt + estimateTokens(text),
|
|
987
|
+
},
|
|
988
|
+
}
|
|
989
|
+
return
|
|
990
|
+
}
|
|
991
|
+
const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
|
|
992
|
+
? message.source.summary
|
|
993
|
+
: message.source.kind
|
|
994
|
+
const summary = boundContextSummary(notice)
|
|
995
|
+
appendReplayEntry(acc, { kind: 'user', text: summary, notice: true })
|
|
996
|
+
acc.stats = {
|
|
997
|
+
...acc.stats,
|
|
998
|
+
contextSegments: {
|
|
999
|
+
...acc.stats.contextSegments,
|
|
1000
|
+
system: acc.stats.contextSegments.system + estimateTokens(summary),
|
|
1001
|
+
},
|
|
1002
|
+
}
|
|
1003
|
+
return
|
|
1004
|
+
}
|
|
1005
|
+
case 'agent/inbox/spliced': {
|
|
1006
|
+
const { target, start, removedCount = 0, inserted } = event.data
|
|
1007
|
+
const ids = target === 'next-turn' ? acc.pendingTurn : acc.pendingStep
|
|
1008
|
+
const removed = ids.slice(start, start + removedCount)
|
|
1009
|
+
acc.ops += removed.length
|
|
1010
|
+
ids.splice(start, removedCount)
|
|
1011
|
+
acc.ops += removed.length
|
|
1012
|
+
for (const id of removed) {
|
|
1013
|
+
const list = acc.pendingIndex.get(id)
|
|
1014
|
+
if (list === undefined) continue
|
|
1015
|
+
for (const entryIndex of list) {
|
|
1016
|
+
const entry = acc.entries[entryIndex]
|
|
1017
|
+
if (entry !== undefined && entry.kind === 'pending' && entry.target === target) {
|
|
1018
|
+
retireReplayEntry(acc, entryIndex)
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
for (const message of inserted) {
|
|
1023
|
+
appendReplayEntry(acc, { kind: 'pending', messageId: message.id, target, text: pendingText(message.content) })
|
|
1024
|
+
indexList(acc.pendingIndex, message.id).push(acc.entries.length - 1)
|
|
1025
|
+
ids.push(message.id)
|
|
1026
|
+
acc.ops += 1
|
|
1027
|
+
}
|
|
1028
|
+
return
|
|
1029
|
+
}
|
|
1030
|
+
case 'assistant/chunk': {
|
|
1031
|
+
const chunk = event.data.chunk
|
|
1032
|
+
const key = `${event.data.turn}:${event.data.step}`
|
|
1033
|
+
const delta = chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' ? chunk.text : ''
|
|
1034
|
+
if (delta !== '' && !acc.firstChunkAt.has(key)) {
|
|
1035
|
+
acc.firstChunkAt.set(key, event.time)
|
|
1036
|
+
const started = acc.stepStart.get(key)
|
|
1037
|
+
if (started !== undefined) {
|
|
1038
|
+
acc.stats = {
|
|
1039
|
+
...acc.stats,
|
|
1040
|
+
ttftMs: acc.stats.ttftMs + Math.max(0, event.time - started),
|
|
1041
|
+
ttftSteps: acc.stats.ttftSteps + 1,
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
if (chunk.type === 'text-delta') {
|
|
1046
|
+
acc.streaming = appendStreamingTail(acc.streaming, chunk.text)
|
|
1047
|
+
return
|
|
1048
|
+
}
|
|
1049
|
+
if (chunk.type === 'reasoning-delta') {
|
|
1050
|
+
acc.streamingReasoning = appendStreamingTail(acc.streamingReasoning, chunk.text)
|
|
1051
|
+
return
|
|
1052
|
+
}
|
|
1053
|
+
return
|
|
1054
|
+
}
|
|
1055
|
+
case 'assistant/message': {
|
|
1056
|
+
const key = `${event.data.turn}:${event.data.step}`
|
|
1057
|
+
const started = acc.stepStart.get(key)
|
|
1058
|
+
acc.stepStart.delete(key)
|
|
1059
|
+
const firstChunk = acc.firstChunkAt.get(key)
|
|
1060
|
+
acc.firstChunkAt.delete(key)
|
|
1061
|
+
if (acc.turnSteps.get(event.data.turn) === key) acc.turnSteps.delete(event.data.turn)
|
|
1062
|
+
const usage = event.data.usage
|
|
1063
|
+
const totals = acc.stats.usage
|
|
1064
|
+
const text = textOf(event.data.message.content)
|
|
1065
|
+
const reasoning = reasoningOf(event.data.message.content)
|
|
1066
|
+
acc.streaming = ''
|
|
1067
|
+
acc.streamingReasoning = ''
|
|
1068
|
+
appendReplayEntry(acc, { kind: 'assistant', text, reasoning })
|
|
1069
|
+
acc.stats = {
|
|
1070
|
+
...acc.stats,
|
|
1071
|
+
llmMs: acc.stats.llmMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
|
|
1072
|
+
usage: usage === undefined ? totals : {
|
|
1073
|
+
inputTokens: totals.inputTokens + usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
|
|
1074
|
+
outputTokens: totals.outputTokens + usage.outputTokens,
|
|
1075
|
+
cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0),
|
|
1076
|
+
},
|
|
1077
|
+
lastPromptTokens: usage === undefined ? acc.stats.lastPromptTokens
|
|
1078
|
+
: usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
|
|
1079
|
+
decodeMs: acc.stats.decodeMs + (firstChunk === undefined ? 0 : Math.max(0, event.time - firstChunk)),
|
|
1080
|
+
decodeTokens: acc.stats.decodeTokens + (firstChunk === undefined || usage === undefined ? 0 : usage.outputTokens),
|
|
1081
|
+
contextSegments: {
|
|
1082
|
+
...acc.stats.contextSegments,
|
|
1083
|
+
thinking: acc.stats.contextSegments.thinking + estimateTokens(reasoning),
|
|
1084
|
+
assistant: acc.stats.contextSegments.assistant + estimateTokens(text),
|
|
1085
|
+
},
|
|
1086
|
+
}
|
|
1087
|
+
return
|
|
1088
|
+
}
|
|
1089
|
+
case 'tool/call': {
|
|
1090
|
+
const data = event.data
|
|
1091
|
+
acc.toolStart.set(data.callId, event.time)
|
|
1092
|
+
const turnTools = acc.turnTools.get(data.turn) ?? new Set<string>()
|
|
1093
|
+
turnTools.add(data.callId)
|
|
1094
|
+
acc.turnTools.set(data.turn, turnTools)
|
|
1095
|
+
appendReplayEntry(acc, {
|
|
1096
|
+
kind: 'tool',
|
|
1097
|
+
callId: data.callId,
|
|
1098
|
+
name: data.name,
|
|
1099
|
+
arguments: data.arguments,
|
|
1100
|
+
preview: toolArgumentsPreview(data.arguments, data.name),
|
|
1101
|
+
state: 'running',
|
|
1102
|
+
summary: '',
|
|
1103
|
+
detail: undefined,
|
|
1104
|
+
})
|
|
1105
|
+
indexList(acc.toolIndex, data.callId).push(acc.entries.length - 1)
|
|
1106
|
+
acc.stats = {
|
|
1107
|
+
...acc.stats,
|
|
1108
|
+
contextSegments: {
|
|
1109
|
+
...acc.stats.contextSegments,
|
|
1110
|
+
tools: acc.stats.contextSegments.tools
|
|
1111
|
+
+ (typeof data.arguments === 'string' ? estimateTokens(data.arguments) : 0),
|
|
1112
|
+
},
|
|
1113
|
+
}
|
|
1114
|
+
return
|
|
1115
|
+
}
|
|
1116
|
+
case 'tool/result': {
|
|
1117
|
+
const block = event.data.message.content[0]
|
|
1118
|
+
const started = acc.toolStart.get(block.toolCallId)
|
|
1119
|
+
acc.toolStart.delete(block.toolCallId)
|
|
1120
|
+
const turnTools = acc.turnTools.get(event.data.turn)
|
|
1121
|
+
if (turnTools !== undefined) {
|
|
1122
|
+
turnTools.delete(block.toolCallId)
|
|
1123
|
+
if (turnTools.size === 0) acc.turnTools.delete(event.data.turn)
|
|
1124
|
+
}
|
|
1125
|
+
const rawText = textOf(block.content)
|
|
1126
|
+
const summary = boundContextSummary(rawText)
|
|
1127
|
+
const detail = toolResultDetail(event.data.meta, rawText)
|
|
1128
|
+
if (detail?.kind === 'diff') {
|
|
1129
|
+
const set = acc.turnFiles.get(event.data.turn) ?? new Set<string>()
|
|
1130
|
+
for (const diff of detail.diffs) set.add(diff.path)
|
|
1131
|
+
acc.turnFiles.set(event.data.turn, set)
|
|
1132
|
+
}
|
|
1133
|
+
const update = (entry: ToolEntry): ToolEntry => ({
|
|
1134
|
+
...entry,
|
|
1135
|
+
state: block.isError === true ? 'error' as const : 'done' as const,
|
|
1136
|
+
summary,
|
|
1137
|
+
detail,
|
|
1138
|
+
})
|
|
1139
|
+
// Every matching row updates (duplicate callIds included); an id with no
|
|
1140
|
+
// registered index is a provable no-op — no full-array fallback scan.
|
|
1141
|
+
updateReplayById<ToolEntry>(acc, acc.toolIndex, block.toolCallId, entry => entry.callId === block.toolCallId, update)
|
|
1142
|
+
acc.stats = {
|
|
1143
|
+
...acc.stats,
|
|
1144
|
+
toolMs: acc.stats.toolMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
|
|
1145
|
+
contextSegments: {
|
|
1146
|
+
...acc.stats.contextSegments,
|
|
1147
|
+
tools: acc.stats.contextSegments.tools + estimateTokens(rawText),
|
|
1148
|
+
},
|
|
1149
|
+
}
|
|
1150
|
+
return
|
|
1151
|
+
}
|
|
1152
|
+
case 'todo/write':
|
|
1153
|
+
acc.todos = event.data.todos
|
|
1154
|
+
return
|
|
1155
|
+
case 'turn/start': {
|
|
1156
|
+
const wasBusy = acc.busy
|
|
1157
|
+
acc.busy = true
|
|
1158
|
+
acc.busySince = wasBusy ? acc.busySince : event.time
|
|
1159
|
+
acc.todos = []
|
|
1160
|
+
acc.stats = { ...acc.stats, turns: acc.stats.turns + 1 }
|
|
1161
|
+
return
|
|
1162
|
+
}
|
|
1163
|
+
case 'step/start': {
|
|
1164
|
+
const key = `${event.data.turn}:${event.data.step}`
|
|
1165
|
+
const previous = acc.turnSteps.get(event.data.turn)
|
|
1166
|
+
if (previous !== undefined && previous !== key) {
|
|
1167
|
+
acc.stepStart.delete(previous)
|
|
1168
|
+
acc.firstChunkAt.delete(previous)
|
|
1169
|
+
}
|
|
1170
|
+
acc.turnSteps.set(event.data.turn, key)
|
|
1171
|
+
acc.stepStart.set(key, event.time)
|
|
1172
|
+
acc.stats = { ...acc.stats, steps: acc.stats.steps + 1 }
|
|
1173
|
+
return
|
|
1174
|
+
}
|
|
1175
|
+
case 'turn/end': {
|
|
1176
|
+
const reason = event.data.reason
|
|
1177
|
+
const appended: TranscriptEntry[] = []
|
|
1178
|
+
if (reason.kind === 'error') {
|
|
1179
|
+
const recovery = reason.error.code === 'MISSING_CREDENTIAL'
|
|
1180
|
+
? ' · open /model to add an API key'
|
|
1181
|
+
: ''
|
|
1182
|
+
appended.push({ kind: 'error', text: `${reason.error.code}: ${reason.error.message}${recovery}` })
|
|
1183
|
+
} else {
|
|
1184
|
+
const marker = reason.kind === 'aborted'
|
|
1185
|
+
? reason.reason.kind === 'user' ? 'turn cancelled by the user' : `turn cancelled (${reason.reason.kind})`
|
|
1186
|
+
: reason.kind === 'max-tokens'
|
|
1187
|
+
? 'turn hit the output-token ceiling (max-tokens)'
|
|
1188
|
+
: reason.kind === 'blocked'
|
|
1189
|
+
? 'turn ended blocked'
|
|
1190
|
+
: reason.kind === 'interrupted'
|
|
1191
|
+
? 'turn was interrupted by a restart'
|
|
1192
|
+
: undefined
|
|
1193
|
+
if (marker !== undefined) appended.push({ kind: 'turn-marker', text: marker })
|
|
1194
|
+
}
|
|
1195
|
+
const files = acc.turnFiles.get(event.data.turn)
|
|
1196
|
+
acc.turnFiles.delete(event.data.turn)
|
|
1197
|
+
if (files !== undefined && files.size > 0) appended.push({ kind: 'files', paths: [...files].slice(0, 12) })
|
|
1198
|
+
const stepKey = acc.turnSteps.get(event.data.turn)
|
|
1199
|
+
if (stepKey !== undefined) {
|
|
1200
|
+
acc.stepStart.delete(stepKey)
|
|
1201
|
+
acc.firstChunkAt.delete(stepKey)
|
|
1202
|
+
acc.turnSteps.delete(event.data.turn)
|
|
1203
|
+
}
|
|
1204
|
+
const turnToolSet = acc.turnTools.get(event.data.turn)
|
|
1205
|
+
if (turnToolSet !== undefined) {
|
|
1206
|
+
for (const callId of turnToolSet) acc.toolStart.delete(callId)
|
|
1207
|
+
acc.turnTools.delete(event.data.turn)
|
|
1208
|
+
}
|
|
1209
|
+
acc.busy = false
|
|
1210
|
+
acc.busySince = 0
|
|
1211
|
+
for (const entry of appended) appendReplayEntry(acc, entry)
|
|
1212
|
+
return
|
|
1213
|
+
}
|
|
1214
|
+
case 'llm/retry': {
|
|
1215
|
+
const data = event.data
|
|
1216
|
+
appendReplayEntry(acc, {
|
|
1217
|
+
kind: 'retry',
|
|
1218
|
+
retryId: data.retryId,
|
|
1219
|
+
attempt: data.retry,
|
|
1220
|
+
max: 'maxRetries' in data ? data.maxRetries : data.retry,
|
|
1221
|
+
code: data.failure.code,
|
|
1222
|
+
delayMs: data.delayMs,
|
|
1223
|
+
state: 'running',
|
|
1224
|
+
})
|
|
1225
|
+
indexList(acc.retryIndex, data.retryId).push(acc.entries.length - 1)
|
|
1226
|
+
return
|
|
1227
|
+
}
|
|
1228
|
+
case 'llm/retry-started': {
|
|
1229
|
+
const data = event.data
|
|
1230
|
+
updateReplayById<RetryEntry>(acc, acc.retryIndex, data.retryId, entry => entry.retryId === data.retryId, entry => ({ ...entry, state: 'done' as const }))
|
|
1231
|
+
return
|
|
1232
|
+
}
|
|
1233
|
+
case 'sandbox/mode':
|
|
1234
|
+
acc.sandbox = event.data.mode
|
|
1235
|
+
return
|
|
1236
|
+
case 'goal/change': {
|
|
1237
|
+
const data = event.data
|
|
1238
|
+
const clip = (text: string): string => (text.length > 60 ? `${text.slice(0, 59)}…` : text)
|
|
1239
|
+
if (data.operation === 'clear') {
|
|
1240
|
+
acc.goal = undefined
|
|
1241
|
+
appendReplayEntry(acc, { kind: 'turn-marker', text: '◎ goal cleared' })
|
|
1242
|
+
return
|
|
1243
|
+
}
|
|
1244
|
+
const goal: GoalFold = {
|
|
1245
|
+
objective: data.goal.objective,
|
|
1246
|
+
phase: data.goal.phase,
|
|
1247
|
+
rounds: data.roundsStarted,
|
|
1248
|
+
max: data.goal.maxGoalRounds,
|
|
1249
|
+
blocked: data.goal.blockedReason?.message ?? '',
|
|
1250
|
+
}
|
|
1251
|
+
const line = data.operation === 'create'
|
|
1252
|
+
? `◎ goal: ${clip(data.goal.objective)}`
|
|
1253
|
+
: data.operation === 'complete'
|
|
1254
|
+
? '◎ goal complete'
|
|
1255
|
+
: data.operation === 'pause'
|
|
1256
|
+
? '◎ goal paused'
|
|
1257
|
+
: data.operation === 'resume'
|
|
1258
|
+
? '◎ goal resumed'
|
|
1259
|
+
: data.operation === 'block'
|
|
1260
|
+
? `◎ goal blocked: ${clip(goal.blocked)}`
|
|
1261
|
+
: undefined
|
|
1262
|
+
acc.goal = goal
|
|
1263
|
+
if (line !== undefined) appendReplayEntry(acc, { kind: 'turn-marker', text: line })
|
|
1264
|
+
return
|
|
1265
|
+
}
|
|
1266
|
+
case 'session/title':
|
|
1267
|
+
acc.title = event.data.title
|
|
1268
|
+
return
|
|
1269
|
+
case 'compaction/summary':
|
|
1270
|
+
if (acc.compactionTokens.size >= MAX_COMPACTION_SUMMARY_RESIDUE) {
|
|
1271
|
+
const oldest = acc.compactionTokens.keys().next().value
|
|
1272
|
+
if (oldest !== undefined) acc.compactionTokens.delete(oldest)
|
|
1273
|
+
}
|
|
1274
|
+
acc.compactionTokens.set(event.data.compactionId, event.data.shadowedTokenCount)
|
|
1275
|
+
return
|
|
1276
|
+
case 'compaction/prune':
|
|
1277
|
+
acc.lastPruneTokens = event.data.shadowedTokenCount
|
|
1278
|
+
return
|
|
1279
|
+
case 'compaction/end': {
|
|
1280
|
+
const ok = event.data.error === undefined
|
|
1281
|
+
const tokens = acc.compactionTokens.get(event.data.compactionId) ?? acc.lastPruneTokens
|
|
1282
|
+
acc.compactionTokens.delete(event.data.compactionId)
|
|
1283
|
+
appendReplayEntry(acc, { kind: 'compaction', ok, tokens, error: event.data.error ?? '' })
|
|
1284
|
+
return
|
|
1285
|
+
}
|
|
1286
|
+
case 'request/context':
|
|
1287
|
+
acc.stats = { ...acc.stats, contextWindow: event.data.contextWindow ?? acc.stats.contextWindow }
|
|
1288
|
+
return
|
|
1289
|
+
case 'request/header': {
|
|
1290
|
+
const config = event.data.header.config
|
|
1291
|
+
acc.model = `${config.provider}/${config.model}`
|
|
1292
|
+
acc.stats = {
|
|
1293
|
+
...acc.stats,
|
|
1294
|
+
reasoningEffort: config.reasoningEffort === undefined ? '' : String(config.reasoningEffort),
|
|
1295
|
+
contextSegments: {
|
|
1296
|
+
...acc.stats.contextSegments,
|
|
1297
|
+
system: estimateTokens(event.data.header.system ?? ''),
|
|
1298
|
+
},
|
|
1299
|
+
}
|
|
1300
|
+
return
|
|
1301
|
+
}
|
|
1302
|
+
case 'plan/mode':
|
|
1303
|
+
acc.plan = event.data.active
|
|
1304
|
+
return
|
|
1305
|
+
case 'permission/preset':
|
|
1306
|
+
acc.permission = event.data.preset
|
|
1307
|
+
return
|
|
1308
|
+
case 'command/run': {
|
|
1309
|
+
const data = event.data
|
|
1310
|
+
appendReplayEntry(acc, {
|
|
1311
|
+
kind: 'command',
|
|
1312
|
+
commandId: data.commandId,
|
|
1313
|
+
name: data.name,
|
|
1314
|
+
args: data.args ?? '',
|
|
1315
|
+
state: 'running',
|
|
1316
|
+
summary: '',
|
|
1317
|
+
})
|
|
1318
|
+
indexList(acc.commandIndex, data.commandId).push(acc.entries.length - 1)
|
|
1319
|
+
return
|
|
1320
|
+
}
|
|
1321
|
+
case 'command/done': {
|
|
1322
|
+
const data = event.data
|
|
1323
|
+
const update = (candidate: CommandEntry): CommandEntry => ({
|
|
1324
|
+
...candidate,
|
|
1325
|
+
state: data.kind === 'success' ? 'done' as const : 'error' as const,
|
|
1326
|
+
summary: boundContextSummary(data.text ?? ''),
|
|
1327
|
+
})
|
|
1328
|
+
updateReplayById<CommandEntry>(acc, acc.commandIndex, data.commandId, entry => entry.commandId === data.commandId, update)
|
|
1329
|
+
return
|
|
1330
|
+
}
|
|
1331
|
+
default:
|
|
1332
|
+
return
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/**
|
|
1337
|
+
* Materialize the accumulated fold as a `TranscriptView`, compacting any
|
|
1338
|
+
* retired tombstones. The anchors maps are handed through as-is (their
|
|
1339
|
+
* content is identical to a sequential fold's).
|
|
1340
|
+
*
|
|
1341
|
+
* @internal Test-instrumentation path; `projectEvents` is the public entry.
|
|
1342
|
+
*/
|
|
1343
|
+
export function finishReplay(acc: ReplayAccumulator): TranscriptView {
|
|
1344
|
+
const entries: readonly TranscriptEntry[] = acc.removedCount === 0
|
|
1345
|
+
? acc.entries as TranscriptEntry[]
|
|
1346
|
+
: acc.entries.filter((entry): entry is TranscriptEntry => entry !== undefined)
|
|
1347
|
+
if (acc.removedCount > 0) acc.ops += acc.entries.length
|
|
1348
|
+
return {
|
|
1349
|
+
entries,
|
|
1350
|
+
streaming: acc.streaming,
|
|
1351
|
+
streamingReasoning: acc.streamingReasoning,
|
|
1352
|
+
todos: acc.todos,
|
|
1353
|
+
busy: acc.busy,
|
|
1354
|
+
busySince: acc.busySince,
|
|
1355
|
+
model: acc.model,
|
|
1356
|
+
plan: acc.plan,
|
|
1357
|
+
permission: acc.permission,
|
|
1358
|
+
title: acc.title,
|
|
1359
|
+
sandbox: acc.sandbox,
|
|
1360
|
+
goal: acc.goal,
|
|
1361
|
+
pending: { 'next-turn': [...acc.pendingTurn], 'next-step': [...acc.pendingStep] },
|
|
1362
|
+
stats: acc.stats,
|
|
1363
|
+
anchors: {
|
|
1364
|
+
stepStart: acc.stepStart,
|
|
1365
|
+
toolStart: acc.toolStart,
|
|
1366
|
+
firstChunkAt: acc.firstChunkAt,
|
|
1367
|
+
compactionTokens: acc.compactionTokens,
|
|
1368
|
+
lastPruneTokens: acc.lastPruneTokens,
|
|
1369
|
+
turnFiles: acc.turnFiles,
|
|
1370
|
+
turnSteps: acc.turnSteps,
|
|
1371
|
+
turnTools: acc.turnTools,
|
|
1372
|
+
},
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
|
|
733
1376
|
/**
|
|
734
1377
|
* Fold a replayed event history into one view.
|
|
1378
|
+
*
|
|
1379
|
+
* Folding is near-linear in the log size: the mutable replay accumulator
|
|
1380
|
+
* appends in place and resolves id-keyed updates through index maps, so a
|
|
1381
|
+
* long persisted session replays without the O(N²) copy-on-write rebuilds a
|
|
1382
|
+
* naive sequential fold would incur. The result is identical to folding
|
|
1383
|
+
* {@link projectEvent} per event in order.
|
|
735
1384
|
* @param events - events in `seq` order.
|
|
736
1385
|
* @returns the folded view.
|
|
737
1386
|
*/
|
|
738
1387
|
export function projectEvents(events: readonly SessionEvent[]): TranscriptView {
|
|
739
|
-
|
|
1388
|
+
const acc = createReplayAccumulator()
|
|
1389
|
+
for (const event of events) replayProjectEvent(acc, event)
|
|
1390
|
+
return finishReplay(acc)
|
|
740
1391
|
}
|
|
741
1392
|
|
|
742
1393
|
/**
|
|
@@ -748,8 +1399,11 @@ export function projectEvents(events: readonly SessionEvent[]): TranscriptView {
|
|
|
748
1399
|
* the inbox claims or cancels them durably (`agent/inbox/spliced` removals,
|
|
749
1400
|
* `user/message` retirement), and an append-only `<Static>` flush cannot
|
|
750
1401
|
* erase a row that vanishes from the view — the retired row would ghost on
|
|
751
|
-
* screen until the next source-backed replay.
|
|
752
|
-
*
|
|
1402
|
+
* screen until the next source-backed replay. Running commands join the
|
|
1403
|
+
* mutable boundary for the same reason in reverse: `command/done` mutates the
|
|
1404
|
+
* row's state/summary, so a flushed row would keep its stale running mark
|
|
1405
|
+
* until a resize-triggered replay. Everything else (including a completed
|
|
1406
|
+
* tail) is final: later events only APPEND new rows.
|
|
753
1407
|
* @param entries - the view's transcript entries in order.
|
|
754
1408
|
* @returns the count of entries safe to flush (0 for an empty transcript).
|
|
755
1409
|
*/
|
|
@@ -759,6 +1413,7 @@ export function settledEntryCount(entries: readonly TranscriptEntry[]): number {
|
|
|
759
1413
|
if (entry.kind === 'pending') return index
|
|
760
1414
|
if (entry.kind === 'tool' && entry.state === 'running') return index
|
|
761
1415
|
if (entry.kind === 'retry' && entry.state === 'running') return index
|
|
1416
|
+
if (entry.kind === 'command' && entry.state === 'running') return index
|
|
762
1417
|
}
|
|
763
1418
|
return entries.length
|
|
764
1419
|
}
|