dsh-code 1.0.2 → 1.0.4
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 +21 -13
- package/README.md +285 -271
- package/bin/deepseek.mjs +26 -3
- package/lib/index.mjs +2962 -1560
- package/lib/types/app.d.ts +13 -2
- package/lib/types/commands.d.ts +13 -0
- package/lib/types/editor-keys.d.ts +105 -0
- package/lib/types/git-workflow.d.ts +6 -2
- package/lib/types/index.d.ts +28 -0
- package/lib/types/input-split.d.ts +54 -0
- package/lib/types/kernel-panels.d.ts +3 -1
- package/lib/types/keyboard.d.ts +8 -0
- package/lib/types/model-capabilities.d.ts +82 -0
- package/lib/types/provider-settings.d.ts +84 -0
- package/lib/types/render/lines.d.ts +25 -0
- package/lib/types/render/markdown.d.ts +1 -1
- package/lib/types/render/projection.d.ts +22 -2
- package/lib/types/render/status.d.ts +22 -15
- package/lib/types/render/text.d.ts +15 -9
- package/lib/types/render/width.d.ts +29 -0
- package/lib/types/session-directory.d.ts +27 -0
- package/lib/types/settings-file.d.ts +33 -0
- package/lib/types/skills.d.ts +1 -1
- package/lib/types/store.d.ts +10 -0
- package/lib/types/subagents.d.ts +13 -3
- package/package.json +159 -159
- package/src/app.ts +4514 -3892
- package/src/approval.ts +8 -3
- package/src/authorization-panel.ts +2 -4
- package/src/commands.ts +27 -3
- package/src/editor-keys.ts +371 -0
- package/src/git-workflow.ts +10 -6
- package/src/index.ts +1752 -1523
- package/src/input-split.ts +191 -0
- package/src/internals.ts +26 -8
- package/src/kernel-panels.ts +26 -10
- package/src/keyboard.ts +123 -88
- package/src/mentions.ts +42 -9
- package/src/model-capabilities.ts +318 -0
- package/src/provider-settings.ts +220 -0
- package/src/questions.ts +20 -0
- package/src/render/lines.ts +415 -356
- package/src/render/markdown.ts +18 -19
- package/src/render/projection.ts +162 -52
- package/src/render/status.ts +76 -71
- package/src/render/text.ts +158 -150
- package/src/render/width.ts +189 -0
- package/src/session-directory.ts +56 -0
- package/src/settings-file.ts +56 -0
- package/src/skills.ts +19 -6
- package/src/store.ts +26 -7
- package/src/subagents.ts +39 -6
- package/src/theme-panel.ts +79 -72
package/src/render/markdown.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* @module @deepseek-ai/dsh-code/render/markdown
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { graphemeWidth, splitGraphemes, stringWidth } from './width.ts'
|
|
13
|
+
|
|
12
14
|
/** Style classes the renderer emits; the app maps them to colors/props. */
|
|
13
15
|
export type MdStyle = 'plain' | 'bold' | 'italic' | 'boldItalic' | 'code' | 'accent' | 'accentBold' | 'dim' | 'strike'
|
|
14
16
|
|
|
@@ -30,14 +32,9 @@ function seg(text: string, style: MdStyle = 'plain'): MdSegment {
|
|
|
30
32
|
return { text, style }
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
/** Visible width of a run in columns (
|
|
35
|
+
/** Visible width of a run in columns (grapheme-cluster and emoji aware). */
|
|
34
36
|
export function visibleColumns(text: string): number {
|
|
35
|
-
|
|
36
|
-
for (const char of text) {
|
|
37
|
-
const code = char.codePointAt(0) ?? 0
|
|
38
|
-
columns += code > 0x2e7f ? 2 : 1
|
|
39
|
-
}
|
|
40
|
-
return columns
|
|
37
|
+
return stringWidth(text)
|
|
41
38
|
}
|
|
42
39
|
|
|
43
40
|
interface WrapUnit {
|
|
@@ -63,15 +60,17 @@ function wrapUnits(segments: readonly MdSegment[]): readonly WrapUnit[] {
|
|
|
63
60
|
if (word !== '') units.push({ text: word, style: segment.style })
|
|
64
61
|
word = ''
|
|
65
62
|
}
|
|
66
|
-
|
|
67
|
-
|
|
63
|
+
// Grapheme clusters keep ZWJ families, flags, and combining sequences
|
|
64
|
+
// whole; a cluster wider than one cell is its own break opportunity.
|
|
65
|
+
for (const cluster of splitGraphemes(segment.text)) {
|
|
66
|
+
if (cluster === ' ') {
|
|
68
67
|
flushWord()
|
|
69
|
-
units.push({ text:
|
|
70
|
-
} else if (
|
|
68
|
+
units.push({ text: cluster, style: segment.style })
|
|
69
|
+
} else if (graphemeWidth(cluster) > 1) {
|
|
71
70
|
flushWord()
|
|
72
|
-
units.push({ text:
|
|
71
|
+
units.push({ text: cluster, style: segment.style })
|
|
73
72
|
} else {
|
|
74
|
-
word +=
|
|
73
|
+
word += cluster
|
|
75
74
|
}
|
|
76
75
|
}
|
|
77
76
|
flushWord()
|
|
@@ -102,7 +101,7 @@ function wrapSegments(segments: readonly MdSegment[], width: number): readonly (
|
|
|
102
101
|
const append = (unit: WrapUnit): void => {
|
|
103
102
|
const columns = visibleColumns(unit.text)
|
|
104
103
|
if (used === 0 && columns > limit) {
|
|
105
|
-
for (const
|
|
104
|
+
for (const cluster of splitGraphemes(unit.text)) appendAtom({ text: cluster, style: unit.style })
|
|
106
105
|
return
|
|
107
106
|
}
|
|
108
107
|
if (used + columns <= limit || current.length === 0) {
|
|
@@ -124,7 +123,7 @@ function wrapSegments(segments: readonly MdSegment[], width: number): readonly (
|
|
|
124
123
|
}
|
|
125
124
|
flush()
|
|
126
125
|
if (columns > limit) {
|
|
127
|
-
for (const
|
|
126
|
+
for (const cluster of splitGraphemes(unit.text)) appendAtom({ text: cluster, style: unit.style })
|
|
128
127
|
} else {
|
|
129
128
|
appendAtom(unit)
|
|
130
129
|
}
|
|
@@ -339,12 +338,12 @@ function hardWrapSegments(segments: readonly MdSegment[], width: number): readon
|
|
|
339
338
|
used = 0
|
|
340
339
|
}
|
|
341
340
|
for (const segment of segments) {
|
|
342
|
-
for (const
|
|
343
|
-
const cells =
|
|
341
|
+
for (const cluster of splitGraphemes(segment.text)) {
|
|
342
|
+
const cells = graphemeWidth(cluster)
|
|
344
343
|
if (used > 0 && used + cells > width) flush()
|
|
345
344
|
const previous = current.at(-1)
|
|
346
|
-
if (previous?.style === segment.style) previous.text +=
|
|
347
|
-
else current.push({ text:
|
|
345
|
+
if (previous?.style === segment.style) previous.text += cluster
|
|
346
|
+
else current.push({ text: cluster, style: segment.style })
|
|
348
347
|
used += cells
|
|
349
348
|
}
|
|
350
349
|
}
|
package/src/render/projection.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { boundContextSummary, type ContentBlock, type ImageBlock, type MessageId } from '@deepseek-ai/dsh-llm'
|
|
11
11
|
import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session'
|
|
12
|
+
import { graphemeWidth, splitGraphemes } from './width.ts'
|
|
12
13
|
// Type-only imports merge the plugin-owned SessionEventMap variants
|
|
13
14
|
// (agent/inbox/spliced, command/*, compaction/*, goal/change, llm/retry*,
|
|
14
15
|
// plan/mode, permission/preset, sandbox/mode, session/title) into the union
|
|
@@ -158,6 +159,8 @@ export interface RetryEntry {
|
|
|
158
159
|
kind: 'retry'
|
|
159
160
|
/** Correlation id shared with the matching `llm/retry-started`. */
|
|
160
161
|
retryId: string
|
|
162
|
+
/** Retry policy mode from the event: `always` has no attempt cap. */
|
|
163
|
+
mode: 'normal' | 'always'
|
|
161
164
|
/** Attempt ordinal and its cap. */
|
|
162
165
|
attempt: number
|
|
163
166
|
max: number
|
|
@@ -165,7 +168,11 @@ export interface RetryEntry {
|
|
|
165
168
|
code: string
|
|
166
169
|
/** Backoff wait before the next attempt, in ms. */
|
|
167
170
|
delayMs: number
|
|
168
|
-
/**
|
|
171
|
+
/**
|
|
172
|
+
* `running` while the backoff waits, `done` once the attempt started — or
|
|
173
|
+
* when the turn ended first (the turn-end sweep finalizes orphans so they
|
|
174
|
+
* never pin the settled boundary).
|
|
175
|
+
*/
|
|
169
176
|
state: 'running' | 'done'
|
|
170
177
|
}
|
|
171
178
|
|
|
@@ -327,6 +334,25 @@ function textOf(content: readonly ContentBlock[]): string {
|
|
|
327
334
|
return content.filter(block => block.type === 'text').map(block => block.text).join('')
|
|
328
335
|
}
|
|
329
336
|
|
|
337
|
+
/**
|
|
338
|
+
* Snapshot-isolate one anchors block (Maps and their nested Sets): a view
|
|
339
|
+
* already handed to the renderer must never observe a later fold through a
|
|
340
|
+
* shared container. The collections are small and turn-bounded, so cloning
|
|
341
|
+
* per event is cheap next to the entries copy the reducer already makes.
|
|
342
|
+
*/
|
|
343
|
+
function cloneViewAnchors(anchors: TranscriptView['anchors']): TranscriptView['anchors'] {
|
|
344
|
+
return {
|
|
345
|
+
stepStart: new Map(anchors.stepStart),
|
|
346
|
+
toolStart: new Map(anchors.toolStart),
|
|
347
|
+
firstChunkAt: new Map(anchors.firstChunkAt),
|
|
348
|
+
compactionTokens: new Map(anchors.compactionTokens),
|
|
349
|
+
lastPruneTokens: anchors.lastPruneTokens,
|
|
350
|
+
turnFiles: new Map([...anchors.turnFiles].map(([turn, files]) => [turn, new Set(files)])),
|
|
351
|
+
turnSteps: new Map(anchors.turnSteps),
|
|
352
|
+
turnTools: new Map([...anchors.turnTools].map(([turn, tools]) => [turn, new Set(tools)])),
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
330
356
|
/** Durable image references in their model-visible order. */
|
|
331
357
|
function imagesOf(content: readonly ContentBlock[]): readonly ImageBlock['attachment'][] {
|
|
332
358
|
return content.filter((block): block is ImageBlock => block.type === 'image').map(block => block.attachment)
|
|
@@ -336,13 +362,13 @@ function imagesOf(content: readonly ContentBlock[]): readonly ImageBlock['attach
|
|
|
336
362
|
export function imageLabels(images: readonly ImageBlock['attachment'][] | undefined): string {
|
|
337
363
|
if (images === undefined || images.length === 0) return ''
|
|
338
364
|
return images.map((image, index) => {
|
|
339
|
-
const rawName = image.name?.trim() || `image ${index + 1}`
|
|
340
|
-
const name = rawName.length <= 80 ? rawName : `${rawName.slice(0, 79)}…`
|
|
341
|
-
const original = image.originalDimensions
|
|
342
|
-
const dimensions = original === undefined
|
|
343
|
-
? `${image.width}×${image.height}`
|
|
344
|
-
: `${image.width}×${image.height} · original ${original.width}×${original.height}`
|
|
345
|
-
return `[image: ${name} · ${dimensions} · ${image.bytes} B]`
|
|
365
|
+
const rawName = image.name?.trim() || `image ${index + 1}`
|
|
366
|
+
const name = rawName.length <= 80 ? rawName : `${rawName.slice(0, 79)}…`
|
|
367
|
+
const original = image.originalDimensions
|
|
368
|
+
const dimensions = original === undefined
|
|
369
|
+
? `${image.width}×${image.height}`
|
|
370
|
+
: `${image.width}×${image.height} · original ${original.width}×${original.height}`
|
|
371
|
+
return `[image: ${name} · ${dimensions} · ${image.bytes} B]`
|
|
346
372
|
}).join('\n')
|
|
347
373
|
}
|
|
348
374
|
|
|
@@ -368,8 +394,8 @@ function reasoningOf(content: readonly ContentBlock[]): string {
|
|
|
368
394
|
function estimateTokens(text: string): number {
|
|
369
395
|
let wide = 0
|
|
370
396
|
let narrow = 0
|
|
371
|
-
for (const
|
|
372
|
-
if ((
|
|
397
|
+
for (const cluster of splitGraphemes(text)) {
|
|
398
|
+
if (graphemeWidth(cluster) > 1) wide += 1
|
|
373
399
|
else narrow += 1
|
|
374
400
|
}
|
|
375
401
|
return wide + Math.ceil(narrow / 4)
|
|
@@ -409,6 +435,10 @@ function pendingText(content: readonly ContentBlock[]): string {
|
|
|
409
435
|
* @returns the view after the event; the input view is never mutated.
|
|
410
436
|
*/
|
|
411
437
|
export function projectEvent(view: TranscriptView, event: SessionEvent): TranscriptView {
|
|
438
|
+
// Fold against a private anchors block so the documented contract holds —
|
|
439
|
+
// "the input view is never mutated" — even for the in-place anchor sweeps
|
|
440
|
+
// below; without this, every handed-out view shared live Maps.
|
|
441
|
+
view = { ...view, anchors: cloneViewAnchors(view.anchors) }
|
|
412
442
|
switch (event.type) {
|
|
413
443
|
case 'user/message': {
|
|
414
444
|
// A queued row retires when its durable user message lands (the agent
|
|
@@ -466,10 +496,16 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
|
|
|
466
496
|
const { target, start, removedCount = 0, inserted } = event.data
|
|
467
497
|
const ids = view.pending[target]
|
|
468
498
|
const removed = ids.slice(start, start + removedCount)
|
|
499
|
+
// In-place upstream semantics: the kernel's authoritative fold is
|
|
500
|
+
// `inbox.splice(start, removedCount, ...inserted)` — inserted ids land
|
|
501
|
+
// AT the splice position (prepend/replace shapes), never at the tail.
|
|
502
|
+
// A tail append diverged the id order, so later coordinate-based events
|
|
503
|
+
// (next-turn head claims, positioned remove/replace) tombstoned the
|
|
504
|
+
// wrong pending row.
|
|
469
505
|
const nextIds = [
|
|
470
506
|
...ids.slice(0, start),
|
|
471
|
-
...ids.slice(start + removedCount),
|
|
472
507
|
...inserted.map(message => message.id),
|
|
508
|
+
...ids.slice(start + removedCount),
|
|
473
509
|
]
|
|
474
510
|
let entries = view.entries
|
|
475
511
|
if (removed.length > 0) {
|
|
@@ -713,8 +749,26 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
|
|
|
713
749
|
for (const callId of turnToolSet) view.anchors.toolStart.delete(callId)
|
|
714
750
|
view.anchors.turnTools.delete(event.data.turn)
|
|
715
751
|
}
|
|
752
|
+
// Orphaned retry/command rows can never be resolved after the turn
|
|
753
|
+
// ends: an aborted retry backoff returns upstream without its
|
|
754
|
+
// `llm/retry-started`, and crash repair synthesizes only tool/step/
|
|
755
|
+
// turn closers. Left `running` they pin the settled boundary forever,
|
|
756
|
+
// so the turn end finalizes them exactly like the anchor sweep above.
|
|
757
|
+
let orphans = false
|
|
758
|
+
const swept = view.entries.map((entry) => {
|
|
759
|
+
if (entry.kind === 'retry' && entry.state === 'running') {
|
|
760
|
+
orphans = true
|
|
761
|
+
return { ...entry, state: 'done' as const }
|
|
762
|
+
}
|
|
763
|
+
if (entry.kind === 'command' && entry.state === 'running') {
|
|
764
|
+
orphans = true
|
|
765
|
+
return { ...entry, state: 'error' as const, summary: 'interrupted before the turn ended' }
|
|
766
|
+
}
|
|
767
|
+
return entry
|
|
768
|
+
})
|
|
769
|
+
const entries = orphans ? swept : view.entries
|
|
716
770
|
if (appended.length === 0) {
|
|
717
|
-
return { ...view, busy: false, busySince: 0, streaming: '', streamingReasoning: '' }
|
|
771
|
+
return { ...view, busy: false, busySince: 0, streaming: '', streamingReasoning: '', entries }
|
|
718
772
|
}
|
|
719
773
|
return {
|
|
720
774
|
...view,
|
|
@@ -722,7 +776,7 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
|
|
|
722
776
|
busySince: 0,
|
|
723
777
|
streaming: '',
|
|
724
778
|
streamingReasoning: '',
|
|
725
|
-
entries: [...
|
|
779
|
+
entries: [...entries, ...appended],
|
|
726
780
|
}
|
|
727
781
|
}
|
|
728
782
|
case 'llm/retry': {
|
|
@@ -734,6 +788,7 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
|
|
|
734
788
|
entries: [...view.entries, {
|
|
735
789
|
kind: 'retry',
|
|
736
790
|
retryId: data.retryId,
|
|
791
|
+
mode: data.mode,
|
|
737
792
|
attempt: data.retry,
|
|
738
793
|
max: 'maxRetries' in data ? data.maxRetries : data.retry,
|
|
739
794
|
code: data.failure.code,
|
|
@@ -994,6 +1049,31 @@ function indexList(map: Map<string, number[]>, id: string): number[] {
|
|
|
994
1049
|
return list
|
|
995
1050
|
}
|
|
996
1051
|
|
|
1052
|
+
/**
|
|
1053
|
+
* Finalize replay rows the ended turn left `running`, mirroring the reducer's
|
|
1054
|
+
* turn-end orphan sweep: an orphaned retry settles `done`, an orphaned command
|
|
1055
|
+
* settles `error` with an interruption note. Only the id-indexed rows are
|
|
1056
|
+
* visited, so the sweep stays O(retries+commands of the log), never a scan.
|
|
1057
|
+
*/
|
|
1058
|
+
function finalizeReplayOrphans(acc: ReplayAccumulator): void {
|
|
1059
|
+
for (const list of acc.retryIndex.values()) {
|
|
1060
|
+
for (const index of list) {
|
|
1061
|
+
const entry = acc.entries[index]
|
|
1062
|
+
if (entry !== undefined && entry.kind === 'retry' && entry.state === 'running') {
|
|
1063
|
+
acc.entries[index] = { ...entry, state: 'done' }
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
for (const list of acc.commandIndex.values()) {
|
|
1068
|
+
for (const index of list) {
|
|
1069
|
+
const entry = acc.entries[index]
|
|
1070
|
+
if (entry !== undefined && entry.kind === 'command' && entry.state === 'running') {
|
|
1071
|
+
acc.entries[index] = { ...entry, state: 'error', summary: 'interrupted before the turn ended' }
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
997
1077
|
/**
|
|
998
1078
|
* Apply an id-keyed update to every row that registered the id, mirroring the
|
|
999
1079
|
* copy-on-write reducer's full-array map semantics (all matching rows update,
|
|
@@ -1035,8 +1115,11 @@ function retireReplayEntry(acc: ReplayAccumulator, index: number): void {
|
|
|
1035
1115
|
* to a sequential fold; only the `entries` container operations are mutable.
|
|
1036
1116
|
*
|
|
1037
1117
|
* @internal Test-instrumentation path; `projectEvents` is the public entry.
|
|
1118
|
+
* @returns whether the event changed the accumulated state — the live store
|
|
1119
|
+
* stays silent and keeps its snapshot identity for ignored events, exactly
|
|
1120
|
+
* like the copy-on-write reducer returning its input view unchanged.
|
|
1038
1121
|
*/
|
|
1039
|
-
export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
|
|
1122
|
+
export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent): boolean {
|
|
1040
1123
|
switch (event.type) {
|
|
1041
1124
|
case 'user/message': {
|
|
1042
1125
|
const message = event.data
|
|
@@ -1066,7 +1149,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
|
|
|
1066
1149
|
prompt: acc.stats.contextSegments.prompt + estimateTokens(text),
|
|
1067
1150
|
},
|
|
1068
1151
|
}
|
|
1069
|
-
return
|
|
1152
|
+
return true
|
|
1070
1153
|
}
|
|
1071
1154
|
const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
|
|
1072
1155
|
? message.source.summary
|
|
@@ -1080,7 +1163,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
|
|
|
1080
1163
|
system: acc.stats.contextSegments.system + estimateTokens(summary),
|
|
1081
1164
|
},
|
|
1082
1165
|
}
|
|
1083
|
-
return
|
|
1166
|
+
return true
|
|
1084
1167
|
}
|
|
1085
1168
|
case 'agent/inbox/spliced': {
|
|
1086
1169
|
const { target, start, removedCount = 0, inserted } = event.data
|
|
@@ -1099,14 +1182,18 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
|
|
|
1099
1182
|
}
|
|
1100
1183
|
}
|
|
1101
1184
|
}
|
|
1185
|
+
// Mirror the reducer's in-place order: inserted ids join at the splice
|
|
1186
|
+
// position (upstream `splice(start, removedCount, ...inserted)`), never
|
|
1187
|
+
// at the tail — the id list must stay coordinate-compatible with every
|
|
1188
|
+
// later inbox event.
|
|
1189
|
+
ids.splice(start, 0, ...inserted.map(message => message.id))
|
|
1102
1190
|
for (const message of inserted) {
|
|
1103
1191
|
const images = imagesOf(message.content)
|
|
1104
1192
|
appendReplayEntry(acc, { kind: 'pending', messageId: message.id, target, text: pendingText(message.content), ...(images.length === 0 ? {} : { images }) })
|
|
1105
1193
|
indexList(acc.pendingIndex, message.id).push(acc.entries.length - 1)
|
|
1106
|
-
ids.push(message.id)
|
|
1107
1194
|
acc.ops += 1
|
|
1108
1195
|
}
|
|
1109
|
-
return
|
|
1196
|
+
return true
|
|
1110
1197
|
}
|
|
1111
1198
|
case 'assistant/chunk': {
|
|
1112
1199
|
const chunk = event.data.chunk
|
|
@@ -1125,13 +1212,13 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
|
|
|
1125
1212
|
}
|
|
1126
1213
|
if (chunk.type === 'text-delta') {
|
|
1127
1214
|
acc.streaming = appendStreamingTail(acc.streaming, chunk.text)
|
|
1128
|
-
return
|
|
1215
|
+
return true
|
|
1129
1216
|
}
|
|
1130
1217
|
if (chunk.type === 'reasoning-delta') {
|
|
1131
1218
|
acc.streamingReasoning = appendStreamingTail(acc.streamingReasoning, chunk.text)
|
|
1132
|
-
return
|
|
1219
|
+
return true
|
|
1133
1220
|
}
|
|
1134
|
-
return
|
|
1221
|
+
return false
|
|
1135
1222
|
}
|
|
1136
1223
|
case 'assistant/message': {
|
|
1137
1224
|
const key = `${event.data.turn}:${event.data.step}`
|
|
@@ -1165,7 +1252,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
|
|
|
1165
1252
|
assistant: acc.stats.contextSegments.assistant + estimateTokens(text),
|
|
1166
1253
|
},
|
|
1167
1254
|
}
|
|
1168
|
-
return
|
|
1255
|
+
return true
|
|
1169
1256
|
}
|
|
1170
1257
|
case 'tool/call': {
|
|
1171
1258
|
const data = event.data
|
|
@@ -1195,7 +1282,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
|
|
|
1195
1282
|
+ (typeof data.arguments === 'string' ? estimateTokens(data.arguments) : 0),
|
|
1196
1283
|
},
|
|
1197
1284
|
}
|
|
1198
|
-
return
|
|
1285
|
+
return true
|
|
1199
1286
|
}
|
|
1200
1287
|
case 'tool/result': {
|
|
1201
1288
|
const block = event.data.message.content[0]
|
|
@@ -1231,18 +1318,18 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
|
|
|
1231
1318
|
tools: acc.stats.contextSegments.tools + estimateTokens(rawText),
|
|
1232
1319
|
},
|
|
1233
1320
|
}
|
|
1234
|
-
return
|
|
1321
|
+
return true
|
|
1235
1322
|
}
|
|
1236
1323
|
case 'todo/write':
|
|
1237
1324
|
acc.todos = event.data.todos
|
|
1238
|
-
return
|
|
1325
|
+
return true
|
|
1239
1326
|
case 'turn/start': {
|
|
1240
1327
|
const wasBusy = acc.busy
|
|
1241
1328
|
acc.busy = true
|
|
1242
1329
|
acc.busySince = wasBusy ? acc.busySince : event.time
|
|
1243
1330
|
acc.todos = []
|
|
1244
1331
|
acc.stats = { ...acc.stats, turns: acc.stats.turns + 1 }
|
|
1245
|
-
return
|
|
1332
|
+
return true
|
|
1246
1333
|
}
|
|
1247
1334
|
case 'step/start': {
|
|
1248
1335
|
const key = `${event.data.turn}:${event.data.step}`
|
|
@@ -1256,7 +1343,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
|
|
|
1256
1343
|
acc.streaming = ''
|
|
1257
1344
|
acc.streamingReasoning = ''
|
|
1258
1345
|
acc.stats = { ...acc.stats, steps: acc.stats.steps + 1 }
|
|
1259
|
-
return
|
|
1346
|
+
return true
|
|
1260
1347
|
}
|
|
1261
1348
|
case 'turn/end': {
|
|
1262
1349
|
const reason = event.data.reason
|
|
@@ -1294,10 +1381,11 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
|
|
|
1294
1381
|
for (const callId of turnToolSet) acc.toolStart.delete(callId)
|
|
1295
1382
|
acc.turnTools.delete(event.data.turn)
|
|
1296
1383
|
}
|
|
1384
|
+
finalizeReplayOrphans(acc)
|
|
1297
1385
|
acc.busy = false
|
|
1298
1386
|
acc.busySince = 0
|
|
1299
1387
|
for (const entry of appended) appendReplayEntry(acc, entry)
|
|
1300
|
-
return
|
|
1388
|
+
return true
|
|
1301
1389
|
}
|
|
1302
1390
|
case 'llm/retry': {
|
|
1303
1391
|
const data = event.data
|
|
@@ -1306,6 +1394,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
|
|
|
1306
1394
|
appendReplayEntry(acc, {
|
|
1307
1395
|
kind: 'retry',
|
|
1308
1396
|
retryId: data.retryId,
|
|
1397
|
+
mode: data.mode,
|
|
1309
1398
|
attempt: data.retry,
|
|
1310
1399
|
max: 'maxRetries' in data ? data.maxRetries : data.retry,
|
|
1311
1400
|
code: data.failure.code,
|
|
@@ -1313,23 +1402,23 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
|
|
|
1313
1402
|
state: 'running',
|
|
1314
1403
|
})
|
|
1315
1404
|
indexList(acc.retryIndex, data.retryId).push(acc.entries.length - 1)
|
|
1316
|
-
return
|
|
1405
|
+
return true
|
|
1317
1406
|
}
|
|
1318
1407
|
case 'llm/retry-started': {
|
|
1319
1408
|
const data = event.data
|
|
1320
1409
|
updateReplayById<RetryEntry>(acc, acc.retryIndex, data.retryId, entry => entry.retryId === data.retryId, entry => ({ ...entry, state: 'done' as const }))
|
|
1321
|
-
return
|
|
1410
|
+
return true
|
|
1322
1411
|
}
|
|
1323
1412
|
case 'sandbox/mode':
|
|
1324
1413
|
acc.sandbox = event.data.mode
|
|
1325
|
-
return
|
|
1414
|
+
return true
|
|
1326
1415
|
case 'goal/change': {
|
|
1327
1416
|
const data = event.data
|
|
1328
1417
|
const clip = (text: string): string => (text.length > 60 ? `${text.slice(0, 59)}…` : text)
|
|
1329
1418
|
if (data.operation === 'clear') {
|
|
1330
1419
|
acc.goal = undefined
|
|
1331
1420
|
appendReplayEntry(acc, { kind: 'turn-marker', text: '◎ goal cleared' })
|
|
1332
|
-
return
|
|
1421
|
+
return true
|
|
1333
1422
|
}
|
|
1334
1423
|
const goal: GoalFold = {
|
|
1335
1424
|
objective: data.goal.objective,
|
|
@@ -1351,31 +1440,31 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
|
|
|
1351
1440
|
: undefined
|
|
1352
1441
|
acc.goal = goal
|
|
1353
1442
|
if (line !== undefined) appendReplayEntry(acc, { kind: 'turn-marker', text: line })
|
|
1354
|
-
return
|
|
1443
|
+
return true
|
|
1355
1444
|
}
|
|
1356
1445
|
case 'session/title':
|
|
1357
1446
|
acc.title = event.data.title
|
|
1358
|
-
return
|
|
1447
|
+
return true
|
|
1359
1448
|
case 'compaction/summary':
|
|
1360
1449
|
if (acc.compactionTokens.size >= MAX_COMPACTION_SUMMARY_RESIDUE) {
|
|
1361
1450
|
const oldest = acc.compactionTokens.keys().next().value
|
|
1362
1451
|
if (oldest !== undefined) acc.compactionTokens.delete(oldest)
|
|
1363
1452
|
}
|
|
1364
1453
|
acc.compactionTokens.set(event.data.compactionId, event.data.shadowedTokenCount)
|
|
1365
|
-
return
|
|
1454
|
+
return true
|
|
1366
1455
|
case 'compaction/prune':
|
|
1367
1456
|
acc.lastPruneTokens = event.data.shadowedTokenCount
|
|
1368
|
-
return
|
|
1457
|
+
return true
|
|
1369
1458
|
case 'compaction/end': {
|
|
1370
1459
|
const ok = event.data.error === undefined
|
|
1371
1460
|
const tokens = acc.compactionTokens.get(event.data.compactionId) ?? acc.lastPruneTokens
|
|
1372
1461
|
acc.compactionTokens.delete(event.data.compactionId)
|
|
1373
1462
|
appendReplayEntry(acc, { kind: 'compaction', ok, tokens, error: event.data.error ?? '' })
|
|
1374
|
-
return
|
|
1463
|
+
return true
|
|
1375
1464
|
}
|
|
1376
1465
|
case 'request/context':
|
|
1377
1466
|
acc.stats = { ...acc.stats, contextWindow: event.data.contextWindow ?? acc.stats.contextWindow }
|
|
1378
|
-
return
|
|
1467
|
+
return true
|
|
1379
1468
|
case 'request/header': {
|
|
1380
1469
|
const config = event.data.header.config
|
|
1381
1470
|
acc.model = `${config.provider}/${config.model}`
|
|
@@ -1387,14 +1476,14 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
|
|
|
1387
1476
|
system: estimateTokens(event.data.header.system ?? ''),
|
|
1388
1477
|
},
|
|
1389
1478
|
}
|
|
1390
|
-
return
|
|
1479
|
+
return true
|
|
1391
1480
|
}
|
|
1392
1481
|
case 'plan/mode':
|
|
1393
1482
|
acc.plan = event.data.active
|
|
1394
|
-
return
|
|
1483
|
+
return true
|
|
1395
1484
|
case 'permission/preset':
|
|
1396
1485
|
acc.permission = event.data.preset
|
|
1397
|
-
return
|
|
1486
|
+
return true
|
|
1398
1487
|
case 'command/run': {
|
|
1399
1488
|
const data = event.data
|
|
1400
1489
|
appendReplayEntry(acc, {
|
|
@@ -1406,7 +1495,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
|
|
|
1406
1495
|
summary: '',
|
|
1407
1496
|
})
|
|
1408
1497
|
indexList(acc.commandIndex, data.commandId).push(acc.entries.length - 1)
|
|
1409
|
-
return
|
|
1498
|
+
return true
|
|
1410
1499
|
}
|
|
1411
1500
|
case 'command/done': {
|
|
1412
1501
|
const data = event.data
|
|
@@ -1416,10 +1505,10 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
|
|
|
1416
1505
|
summary: boundContextSummary(data.text ?? ''),
|
|
1417
1506
|
})
|
|
1418
1507
|
updateReplayById<CommandEntry>(acc, acc.commandIndex, data.commandId, entry => entry.commandId === data.commandId, update)
|
|
1419
|
-
return
|
|
1508
|
+
return true
|
|
1420
1509
|
}
|
|
1421
1510
|
default:
|
|
1422
|
-
return
|
|
1511
|
+
return false
|
|
1423
1512
|
}
|
|
1424
1513
|
}
|
|
1425
1514
|
|
|
@@ -1431,8 +1520,27 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
|
|
|
1431
1520
|
* @internal Test-instrumentation path; `projectEvents` is the public entry.
|
|
1432
1521
|
*/
|
|
1433
1522
|
export function finishReplay(acc: ReplayAccumulator): TranscriptView {
|
|
1523
|
+
return materializeReplayView(acc, false)
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/**
|
|
1527
|
+
* Materialize the accumulated fold as a fresh immutable snapshot for the
|
|
1528
|
+
* live store. Unlike {@link finishReplay} — the one-shot replay entry, which
|
|
1529
|
+
* hands the accumulator's own arrays through because the accumulator is
|
|
1530
|
+
* discarded — every array a renderer can hold is copied here, so later
|
|
1531
|
+
* folds never mutate a snapshot already handed out. Same fields, same
|
|
1532
|
+
* tombstone compaction.
|
|
1533
|
+
*
|
|
1534
|
+
* @internal Live-store path; `projectEvents` is the public entry.
|
|
1535
|
+
*/
|
|
1536
|
+
export function snapshotReplayView(acc: ReplayAccumulator): TranscriptView {
|
|
1537
|
+
return materializeReplayView(acc, true)
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
/** Field-for-field materialization; `copy` selects snapshot array isolation. */
|
|
1541
|
+
function materializeReplayView(acc: ReplayAccumulator, copy: boolean): TranscriptView {
|
|
1434
1542
|
const entries: readonly TranscriptEntry[] = acc.removedCount === 0
|
|
1435
|
-
? acc.entries as TranscriptEntry[]
|
|
1543
|
+
? (copy ? [...acc.entries] : acc.entries) as TranscriptEntry[]
|
|
1436
1544
|
: acc.entries.filter((entry): entry is TranscriptEntry => entry !== undefined)
|
|
1437
1545
|
if (acc.removedCount > 0) acc.ops += acc.entries.length
|
|
1438
1546
|
return {
|
|
@@ -1451,15 +1559,17 @@ export function finishReplay(acc: ReplayAccumulator): TranscriptView {
|
|
|
1451
1559
|
goal: acc.goal,
|
|
1452
1560
|
pending: { 'next-turn': [...acc.pendingTurn], 'next-step': [...acc.pendingStep] },
|
|
1453
1561
|
stats: acc.stats,
|
|
1562
|
+
// Handed-out views get their own anchors snapshot: the accumulator keeps
|
|
1563
|
+
// folding its live containers, and no consumer may observe that.
|
|
1454
1564
|
anchors: {
|
|
1455
|
-
stepStart: acc.stepStart,
|
|
1456
|
-
toolStart: acc.toolStart,
|
|
1457
|
-
firstChunkAt: acc.firstChunkAt,
|
|
1458
|
-
compactionTokens: acc.compactionTokens,
|
|
1565
|
+
stepStart: new Map(acc.stepStart),
|
|
1566
|
+
toolStart: new Map(acc.toolStart),
|
|
1567
|
+
firstChunkAt: new Map(acc.firstChunkAt),
|
|
1568
|
+
compactionTokens: new Map(acc.compactionTokens),
|
|
1459
1569
|
lastPruneTokens: acc.lastPruneTokens,
|
|
1460
|
-
turnFiles: acc.turnFiles,
|
|
1461
|
-
turnSteps: acc.turnSteps,
|
|
1462
|
-
turnTools: acc.turnTools,
|
|
1570
|
+
turnFiles: new Map([...acc.turnFiles].map(([turn, files]) => [turn, new Set(files)])),
|
|
1571
|
+
turnSteps: new Map(acc.turnSteps),
|
|
1572
|
+
turnTools: new Map([...acc.turnTools].map(([turn, tools]) => [turn, new Set(tools)])),
|
|
1463
1573
|
},
|
|
1464
1574
|
}
|
|
1465
1575
|
}
|