@deepseek-ai/dsh-session 0.2.2-alpha.3 → 0.2.2-rc.2

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 (41) hide show
  1. package/README.i18n.yaml +4 -4
  2. package/README.md +3 -1
  3. package/README.zh.md +3 -1
  4. package/lib/index.js +151 -79
  5. package/lib/types/fork.d.ts.map +1 -0
  6. package/lib/types/fork.js.map +1 -0
  7. package/lib/types/index.d.ts +17 -1
  8. package/lib/types/index.d.ts.map +1 -0
  9. package/lib/types/index.js +82 -63
  10. package/lib/types/index.js.map +1 -0
  11. package/lib/types/known-event-types.d.ts.map +1 -0
  12. package/lib/types/known-event-types.js +1 -0
  13. package/lib/types/known-event-types.js.map +1 -0
  14. package/lib/types/preparation.d.ts.map +1 -0
  15. package/lib/types/preparation.js.map +1 -0
  16. package/lib/types/repair.d.ts.map +1 -0
  17. package/lib/types/repair.js.map +1 -0
  18. package/lib/types/request-header.d.ts.map +1 -0
  19. package/lib/types/request-header.js.map +1 -0
  20. package/lib/types/seq-ranges.d.ts.map +1 -0
  21. package/lib/types/seq-ranges.js.map +1 -0
  22. package/lib/types/surface.d.ts +6 -0
  23. package/lib/types/surface.d.ts.map +1 -0
  24. package/lib/types/surface.js +57 -11
  25. package/lib/types/surface.js.map +1 -0
  26. package/lib/types/tool-history.d.ts.map +1 -0
  27. package/lib/types/tool-history.js.map +1 -0
  28. package/lib/types/types.d.ts +4 -0
  29. package/lib/types/types.d.ts.map +1 -0
  30. package/lib/types/types.js.map +1 -0
  31. package/package.json +14 -11
  32. package/src/fork.ts +30 -0
  33. package/src/index.ts +1555 -0
  34. package/src/known-event-types.ts +91 -0
  35. package/src/preparation.ts +49 -0
  36. package/src/repair.ts +211 -0
  37. package/src/request-header.ts +69 -0
  38. package/src/seq-ranges.ts +73 -0
  39. package/src/surface.ts +758 -0
  40. package/src/tool-history.ts +75 -0
  41. package/src/types.ts +574 -0
package/src/surface.ts ADDED
@@ -0,0 +1,758 @@
1
+ /**
2
+ * Surface layer on top of the session event log: an ordered view of events
3
+ * that produce LLM messages. The append-only log remains the source of truth.
4
+ *
5
+ * Browser-safe: web clients consume this subpath export, so it must stay free
6
+ * of `node:` imports (they break the vite bundle).
7
+ *
8
+ * @module @deepseek-ai/dsh-session/surface
9
+ */
10
+
11
+ import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
12
+ import { SessionLogOffset, SessionSeq } from './types.ts'
13
+ import { KNOWN_SESSION_EVENT_TYPES, MESSAGE_PROJECTION_EVENT_TYPES } from './known-event-types.ts'
14
+ import type {
15
+ SessionEvent,
16
+ SessionEventType,
17
+ SessionSeqCursor,
18
+ SurfaceEvent,
19
+ SurfaceOp,
20
+ } from './types.ts'
21
+
22
+ /** Readonly history immediately before a message-projection event. */
23
+ export interface SessionMessageProjectionContext {
24
+ /** Current message-producing event sequences in model-visible order. */
25
+ nodes: readonly SessionSeq[]
26
+ /** Contiguous event window; entries at or beyond the candidate seq are not committed inputs. */
27
+ events: readonly SessionEvent[]
28
+ /** Absolute sequence of the window's first event. */
29
+ baseSeq: SessionLogOffset
30
+ /** Previously projected messages keyed by their original event sequences. */
31
+ messages: ReadonlyMap<SessionSeq, Message>
32
+ }
33
+
34
+ /** Pure interpretation of one plugin-owned event that changes existing message content. */
35
+ export interface SessionMessageProjection<T extends SessionEventType = SessionEventType> {
36
+ /** Event interpreted by this definition; declare it with `@messageProjection` in SessionEventMap. */
37
+ type: T
38
+ /**
39
+ * Validate the complete durable decision before returning any updates. Preserve
40
+ * message identities and publish immutable copies without mutating the input.
41
+ * @param event - candidate event, not yet applied to the supplied history.
42
+ * @param context - history preceding this decision.
43
+ * @returns changed current messages keyed by their original sequences.
44
+ * @throws when the durable decision cannot be applied to this history.
45
+ */
46
+ project(event: SessionEvent<T>, context: SessionMessageProjectionContext): ReadonlyMap<SessionSeq, Message>
47
+ }
48
+
49
+ /** Runtime counterpart of the message-producing event union. */
50
+ const SURFACE_EVENT_TYPES = new Set<string>([
51
+ 'system/message',
52
+ 'developer/message',
53
+ 'user/message',
54
+ 'assistant/message',
55
+ 'tool/result',
56
+ ])
57
+
58
+ /**
59
+ * Whether an event type can join the model-visible surface.
60
+ * @param type - event type to test.
61
+ * @returns true for one of the message-producing event types.
62
+ */
63
+ export function isSurfaceEligibleType(type: string): boolean {
64
+ return SURFACE_EVENT_TYPES.has(type)
65
+ }
66
+
67
+ /**
68
+ * Narrow an event to a surface-eligible event carrying its required marker.
69
+ * @param event - event to test.
70
+ * @returns true when both the type and marker identify a surface event.
71
+ */
72
+ export function isSurfaceEvent(event: SessionEvent): event is SurfaceEvent {
73
+ if (!SURFACE_EVENT_TYPES.has(event.type)) return false
74
+ const candidate: { surfaceOp?: unknown } = event
75
+ return candidate.surfaceOp !== undefined
76
+ }
77
+
78
+ /**
79
+ * Narrow an event to an append-origin surface event: one that entered the
80
+ * surface at its own log position and was never itself a replacement copy.
81
+ *
82
+ * The model-visible surface deliberately shadows replaced ranges, so it is the
83
+ * wrong source for a human transcript — a landed replacement would erase
84
+ * conversation the user already saw. Append-origin events are that transcript's
85
+ * durable source material; replacement copies stay model-only.
86
+ * @param event - event to test.
87
+ * @returns true when the event appended to the surface tail.
88
+ */
89
+ export function isAppendSurfaceEvent(
90
+ event: SessionEvent,
91
+ ): event is SurfaceEvent & { surfaceOp: 'append' } {
92
+ return isSurfaceEvent(event) && event.surfaceOp === 'append'
93
+ }
94
+
95
+ /**
96
+ * Narrow an event to a surface replacement: a node that shadowed an existing
97
+ * surface range instead of appending to the tail. The counterpart of
98
+ * {@link isAppendSurfaceEvent} over the two {@link SurfaceOp} variants.
99
+ * @param event - event to test.
100
+ * @returns true when the event replaced a surface range.
101
+ */
102
+ export function isReplacementSurfaceEvent(
103
+ event: SessionEvent,
104
+ ): event is SurfaceEvent & { surfaceOp: Extract<SurfaceOp, { op: 'replace' }> } {
105
+ return isSurfaceEvent(event) && event.surfaceOp !== 'append'
106
+ }
107
+
108
+ /**
109
+ * Project a single event into the LLM message it derives to, or null when it
110
+ * produces none — a non-surface event (attempt, boundary, log-only record) or an
111
+ * empty-content system, developer, or assistant message. A caller
112
+ * reconstructing model input supplies the same prefix's `projectedMessages`
113
+ * from {@link foldSurface}; without that map this function reads original
114
+ * event content. Session instance methods apply the live projection. Messages
115
+ * are immutable and unchanged content retains its durable identity.
116
+ * @param event - the event to project.
117
+ * @param projectedMessages - message projections from the same log prefix's surface fold.
118
+ * @returns the derived message, or null when the event produces none.
119
+ */
120
+ export function deriveEventMessage(
121
+ event: SessionEvent,
122
+ projectedMessages?: ReadonlyMap<SessionSeq, Message>,
123
+ ): Message | null {
124
+ const projected = projectedMessages?.get(event.seq)
125
+ if (projected !== undefined) return projected
126
+ // Intentionally non-exhaustive: only message-producing events derive
127
+ // history; turn/step boundaries, failed attempts, and errors are trace/replay
128
+ // data.
129
+ switch (event.type) {
130
+ // Ordinary prompts and injected context retain producer-owned framing.
131
+ // See Derived history in packages/core/session/README.md.
132
+ case 'user/message': {
133
+ return event.data
134
+ }
135
+ // Empty system and developer nodes retain their surface positions without
136
+ // adding wire messages. An empty assistant event hosts a max-tokens step's
137
+ // usage and must not inject a content-less turn into the provider transcript.
138
+ case 'system/message':
139
+ case 'developer/message':
140
+ case 'assistant/message': {
141
+ if (event.data.message.content.length === 0) return null
142
+ return event.data.message
143
+ }
144
+ case 'tool/result': {
145
+ return event.data.message
146
+ }
147
+ default:
148
+ // A non-surface event (boundary, attempt, log-only record) projects to
149
+ // no message. Merge-extensible union: no assertNever here.
150
+ return null
151
+ }
152
+ }
153
+
154
+ /** Whether a payload field is a JSON object rather than an array or scalar. */
155
+ function isRecord(value: unknown): value is Record<string, unknown> {
156
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
157
+ }
158
+
159
+ /**
160
+ * Reject noncanonical request-header fields, developer roles/content, and contradictory tool failure metadata.
161
+ * This does not validate complete event payloads or embedded provider streams.
162
+ * @param event - event whose locally related payload fields are inspected.
163
+ * @param subject - event location to include in validation errors.
164
+ * @throws when request-header fields, developer roles/content, or tool failure metadata are invalid.
165
+ */
166
+ export function validateSessionEventData(
167
+ event: Pick<SessionEvent, 'type' | 'data'>,
168
+ subject: string,
169
+ ): void {
170
+ const data: unknown = event.data
171
+ if (SURFACE_EVENT_TYPES.has(event.type) && isRecord(data)) {
172
+ const message = event.type === 'user/message' ? data : data['message']
173
+ if (isRecord(message)) {
174
+ if ((event.type === 'developer/message') !== (message['role'] === 'developer')) {
175
+ throw new Error(`${subject} developer/message and developer role must occur together`)
176
+ }
177
+ if (message['role'] !== 'developer' && Array.isArray(message['content'])
178
+ && message['content'].some((block: unknown) => isRecord(block)
179
+ && (block['type'] === 'tool-addition' || block['type'] === 'tool-removal'))) {
180
+ throw new Error(`${subject} tool-change blocks require developer role`)
181
+ }
182
+ if (event.type === 'developer/message' && Array.isArray(message['content'])) {
183
+ let hasAdditions = false
184
+ for (const block of message['content']) {
185
+ if (!isRecord(block) || (block['type'] !== 'tool-addition' && block['type'] !== 'tool-removal')) continue
186
+ if (typeof block['toolName'] !== 'string' || block['toolName'].length === 0) {
187
+ throw new Error(`${subject} ${block['type']} requires a nonempty toolName`)
188
+ }
189
+ if (block['type'] === 'tool-addition') {
190
+ hasAdditions = true
191
+ if (Object.hasOwn(block, 'tool')) throw new Error(`${subject} tool-addition must omit inline tool definitions`)
192
+ }
193
+ }
194
+ if (hasAdditions ? !isEventSeq(data['headerSeq']) : Object.hasOwn(data, 'headerSeq')) {
195
+ throw new Error(`${subject} requires headerSeq exactly when tool additions are present`)
196
+ }
197
+ }
198
+ }
199
+ }
200
+ if (event.type === 'request/header') {
201
+ if (!isRecord(data)) throw new Error(`${subject} data must be an object`)
202
+ const header = data['header']
203
+ if (!isRecord(header)) throw new Error(`${subject} header must be an object`)
204
+ if (Object.hasOwn(header, 'system')) throw new Error(`${subject} must omit header.system; use system/message`)
205
+ if (Array.isArray(header['tools']) && header['tools'].length === 0) {
206
+ throw new Error(`${subject} must omit empty tools`)
207
+ }
208
+ const defaults = header['adapterDefaults']
209
+ if (isRecord(defaults) && Object.keys(defaults).length === 0) {
210
+ throw new Error(`${subject} must omit empty adapterDefaults`)
211
+ }
212
+ } else if (event.type === 'tool/result') {
213
+ if (!isRecord(data)) throw new Error(`${subject} data must be an object`)
214
+ if (data['error'] === undefined) return
215
+ const message = data['message']
216
+ if (!isRecord(message) || message['isError'] !== true) {
217
+ throw new Error(`${subject} error requires message.isError === true`)
218
+ }
219
+ }
220
+ }
221
+
222
+ /** One replacement operation observed while folding a session surface. */
223
+ export interface SurfaceFoldReplacement {
224
+ /** Seq of the event that replaced the prior surface range. */
225
+ seq: SessionSeq
226
+ /** Declared inclusive start seq of the replaced surface range. */
227
+ start: SessionSeq
228
+ /** Declared inclusive end seq of the replaced surface range. */
229
+ end: SessionSeq
230
+ /** Actual surface entries removed by the operation, in surface order. */
231
+ shadowedSeqs: SessionSeq[]
232
+ }
233
+
234
+ /** Complete result of replaying the surface operations in a session log. */
235
+ export interface SurfaceFoldResult {
236
+ /** Current surface event sequences in model-visible order. */
237
+ nodes: SessionSeq[]
238
+ /** Replacement operations in event order. */
239
+ replacements: SurfaceFoldReplacement[]
240
+ /** Immutable projected messages, keyed by their original event sequences. */
241
+ projectedMessages: ReadonlyMap<SessionSeq, Message>
242
+ }
243
+
244
+ /** Readonly live projection of the message-producing session events. */
245
+ export interface SessionSurface {
246
+ /** Current surface event sequences in model-visible order. */
247
+ readonly nodes: readonly SessionSeq[]
248
+ /** Monotonic count of committed positional replacements. */
249
+ readonly replaceGeneration: number
250
+ /** Monotonic count of committed replacements and plugin-owned message changes. */
251
+ readonly contentGeneration: number
252
+ }
253
+
254
+ /** Mutable state shared by complete and incremental folds. */
255
+ interface SurfaceFoldState {
256
+ nodes: SessionSeq[]
257
+ replaceGeneration: number
258
+ contentGeneration: number
259
+ projectedMessages: Map<SessionSeq, Message>
260
+ projections: Set<SessionMessageProjection>
261
+ }
262
+
263
+ /** A validated replacement transition that has not mutated fold state yet. */
264
+ interface SurfaceReplacePlan extends SurfaceFoldReplacement {
265
+ kind: 'replace'
266
+ startIdx: number
267
+ endIdx: number
268
+ }
269
+
270
+ /** One validated surface transition that has not mutated fold state yet. */
271
+ type SurfacePlan =
272
+ | { kind: 'append'; seq: SessionSeq }
273
+ | SurfaceReplacePlan
274
+ | { kind: 'project'; projection: SessionMessageProjection; messages: ReadonlyMap<SessionSeq, Message> }
275
+
276
+ /** Create an empty surface fold state. */
277
+ function createFoldState(): SurfaceFoldState {
278
+ return { nodes: [], replaceGeneration: 0, contentGeneration: 0, projectedMessages: new Map(), projections: new Set() }
279
+ }
280
+
281
+ /** Whether a runtime value is a non-negative safe event sequence. */
282
+ function isEventSeq(value: unknown): value is SessionSeq {
283
+ return typeof value === 'number'
284
+ && Number.isSafeInteger(value)
285
+ && value >= 0
286
+ && !Object.is(value, -0)
287
+ }
288
+
289
+ /** Whether a runtime value is the exact positional-replacement shape. */
290
+ function isReplaceOp(value: object): value is Extract<SurfaceOp, { op: 'replace' }> {
291
+ const op = value as Record<string, unknown>
292
+ return Object.keys(op).length === 3
293
+ && Object.hasOwn(op, 'op')
294
+ && Object.hasOwn(op, 'startSeq')
295
+ && Object.hasOwn(op, 'endSeq')
296
+ && op['op'] === 'replace'
297
+ && isEventSeq(op['startSeq'])
298
+ && isEventSeq(op['endSeq'])
299
+ }
300
+
301
+ /** Validate event-local surface eligibility and return its operation. */
302
+ function surfaceOpOf(event: SessionEvent): SurfaceOp | undefined {
303
+ const raw: { surfaceOp?: unknown; sourceEventSeqs?: unknown } = event
304
+ if (!isSurfaceEligibleType(event.type)) {
305
+ // Unknown ignorable records retain opaque metadata without affecting history.
306
+ if (!KNOWN_SESSION_EVENT_TYPES.has(event.type) && event.ignorable === true) return
307
+ if (raw.surfaceOp !== undefined) {
308
+ throw new Error(`session event "${event.type}" is not surface-eligible and cannot carry surfaceOp`)
309
+ }
310
+ if (raw.sourceEventSeqs !== undefined) {
311
+ throw new Error(`session event "${event.type}" is not surface-eligible and cannot carry sourceEventSeqs`)
312
+ }
313
+ return
314
+ }
315
+ const op = raw.surfaceOp
316
+ if (op === undefined) {
317
+ throw new Error(`session event "${event.type}" is surface-eligible and requires a surfaceOp marker`)
318
+ }
319
+ if (op === 'append') return op
320
+ if (op === null || typeof op !== 'object' || Array.isArray(op)) {
321
+ throw new Error(`session event "${event.type}" carries an invalid surfaceOp`)
322
+ }
323
+ if (!isReplaceOp(op)) {
324
+ throw new Error(`session event "${event.type}" carries an invalid replace surfaceOp`)
325
+ }
326
+ return op
327
+ }
328
+
329
+ /** Validate cited source-event seqs against prior log entries and the replacement range. */
330
+ function assertSourceEventReferences(
331
+ event: SessionEvent,
332
+ shadowedSeqs: readonly SessionSeq[],
333
+ ): void {
334
+ const raw: unknown = event.sourceEventSeqs
335
+ if (event.type === 'assistant/message' && raw !== undefined) {
336
+ throw new Error('assistant/message embeds its source stream and cannot carry sourceEventSeqs')
337
+ }
338
+ const sources = new Set<SessionSeq>()
339
+ if (raw !== undefined) {
340
+ if (!Array.isArray(raw)) {
341
+ throw new Error(`sourceEventSeqs on event at seq ${event.seq} must be an array when present`)
342
+ }
343
+ if (raw.length === 0) {
344
+ throw new Error('sourceEventSeqs must not be empty')
345
+ }
346
+ let nonEarlierSource: SessionSeq | undefined
347
+ for (const source of raw) {
348
+ if (!isEventSeq(source)) {
349
+ throw new Error(`session event "${event.type}" sourceEventSeqs must densely contain non-negative safe integers`)
350
+ }
351
+ sources.add(source)
352
+ if (nonEarlierSource === undefined && source >= event.seq) nonEarlierSource = source
353
+ }
354
+ if (sources.size !== raw.length) {
355
+ throw new Error('sourceEventSeqs must not contain duplicates')
356
+ }
357
+ if (nonEarlierSource !== undefined) {
358
+ throw new Error(`sourceEventSeqs must reference earlier events: ${nonEarlierSource} >= current seq ${event.seq}`)
359
+ }
360
+ }
361
+ const missing = shadowedSeqs.filter(seq => !sources.has(seq))
362
+ if (missing.length > 0) {
363
+ throw new Error(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`)
364
+ }
365
+ }
366
+
367
+ /** Resolve tool additions against their immutable historical request header. */
368
+ function assertDeveloperHeader(
369
+ event: SessionEvent,
370
+ read: (seq: number) => SessionEvent | undefined,
371
+ ): void {
372
+ if (event.type !== 'developer/message') return
373
+ validateSessionEventData(event, `developer/message at seq ${event.seq}`)
374
+ if (event.data.headerSeq === undefined) return
375
+ const headerSeq = event.data.headerSeq
376
+ const headerEvent = read(headerSeq)
377
+ if (headerSeq >= event.seq || headerEvent?.type !== 'request/header') {
378
+ throw new Error('developer/message headerSeq must reference an earlier request/header')
379
+ }
380
+ for (const block of event.data.message.content) {
381
+ if (block.type !== 'tool-addition') continue
382
+ const definitions = headerEvent.data.header.tools?.filter(tool => tool.name === block.toolName) ?? []
383
+ if (definitions.length !== 1) {
384
+ throw new Error(`developer/message tool-addition "${block.toolName}" must name exactly one tool in headerSeq ${headerSeq}`)
385
+ }
386
+ const definition = definitions[0] as ToolSchema
387
+ if (typeof definition.description !== 'string' || !isRecord(definition.parameters)) {
388
+ throw new Error(`developer/message tool-addition "${block.toolName}" requires a complete tool definition in headerSeq ${headerSeq}`)
389
+ }
390
+ if (Object.hasOwn(definition, 'deferLoading') && definition.deferLoading !== true) {
391
+ throw new Error('developer/message referenced tool deferLoading must be true when present')
392
+ }
393
+ }
394
+ }
395
+
396
+ /**
397
+ * Validate one event's surface metadata without checking membership in a log or surface.
398
+ * @param event - event whose marker and source sequence values are inspected.
399
+ * Unknown ignorable records retain opaque metadata and never change the surface.
400
+ * @returns the validated operation, or undefined for a log-only or unknown ignorable event.
401
+ * @throws when metadata violates event-local eligibility, marker, or source-sequence rules.
402
+ */
403
+ export function validateSurfaceMetadata(event: SessionEvent): SurfaceOp | undefined {
404
+ const op = surfaceOpOf(event)
405
+ if (op !== undefined && op !== 'append'
406
+ && (op.startSeq >= event.seq || op.endSeq >= event.seq)) {
407
+ throw new Error(`surface replace at seq ${event.seq}: startSeq and endSeq must reference earlier events`)
408
+ }
409
+ if (op !== undefined) assertSourceEventReferences(event, [])
410
+ return op
411
+ }
412
+
413
+ /** Locate one replacement range without mutating the current fold state. */
414
+ function replacementRange(
415
+ state: SurfaceFoldState,
416
+ op: Extract<SurfaceOp, { op: 'replace' }>,
417
+ ): Pick<SurfaceReplacePlan, 'startIdx' | 'endIdx' | 'shadowedSeqs'> {
418
+ const startIdx = state.nodes.indexOf(op.startSeq)
419
+ if (startIdx === -1) {
420
+ throw new Error(`surface replace: start seq ${op.startSeq} not found in surface`)
421
+ }
422
+ const endIdx = state.nodes.indexOf(op.endSeq)
423
+ if (endIdx === -1) {
424
+ throw new Error(`surface replace: end seq ${op.endSeq} not found in surface`)
425
+ }
426
+ if (startIdx > endIdx) {
427
+ throw new Error(`surface replace: start seq ${op.startSeq} (index ${startIdx}) is after end seq ${op.endSeq} (index ${endIdx})`)
428
+ }
429
+ return {
430
+ startIdx,
431
+ endIdx,
432
+ shadowedSeqs: state.nodes.slice(startIdx, endIdx + 1),
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Deep structural equality over the session-event JSON value domain
438
+ * (null/boolean/number/string, arrays, plain objects). Replaces
439
+ * `node:util`'s isDeepStrictEqual to keep this module browser-safe.
440
+ */
441
+ function isDeepEqualJson(a: unknown, b: unknown): boolean {
442
+ if (a === b) return true
443
+ if (Array.isArray(a) || Array.isArray(b)) {
444
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
445
+ return a.every((item, i) => isDeepEqualJson(item, b[i]))
446
+ }
447
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
448
+ const aKeys = Object.keys(a)
449
+ const bRecord = b as Record<string, unknown>
450
+ if (aKeys.length !== Object.keys(b).length) return false
451
+ return aKeys.every(key => Object.hasOwn(b, key) && isDeepEqualJson((a as Record<string, unknown>)[key], bRecord[key]))
452
+ }
453
+
454
+ /** Restrict a tool-result replacement to one current result's content. */
455
+ function assertToolResultRewrite(
456
+ event: SessionEvent,
457
+ shadowedSeqs: readonly SessionSeq[],
458
+ read: (seq: number) => SessionEvent | undefined,
459
+ ): void {
460
+ if (event.type !== 'tool/result') return
461
+ if (shadowedSeqs.length !== 1) {
462
+ throw new Error('tool/result surface replacement must rewrite exactly one current node')
463
+ }
464
+ for (const originalSeq of shadowedSeqs) {
465
+ const original = read(originalSeq)
466
+ if (original?.type !== 'tool/result') {
467
+ throw new Error('tool/result surface replacement must target a current tool/result')
468
+ }
469
+ const originalRest = { ...original.data } as Record<string, unknown>
470
+ const replacementRest = { ...event.data } as Record<string, unknown>
471
+ originalRest['message'] = {
472
+ ...original.data.message,
473
+ content: null,
474
+ }
475
+ replacementRest['message'] = {
476
+ ...event.data.message,
477
+ content: null,
478
+ }
479
+ if (!isDeepEqualJson(originalRest, replacementRest)) {
480
+ throw new Error('tool/result surface replacement may change only content')
481
+ }
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Protect the system prompt at surface node 0. A replacement covering node 0
487
+ * while that node is a `system/message` must itself be a `system/message` over
488
+ * exactly that node; later system nodes carry no protection and a compaction
489
+ * range may shadow them.
490
+ */
491
+ function assertSystemHeadRewrite(
492
+ event: SessionEvent,
493
+ state: SurfaceFoldState,
494
+ startIdx: number,
495
+ shadowedSeqs: readonly SessionSeq[],
496
+ read: (seq: number) => SessionEvent | undefined,
497
+ ): void {
498
+ if (startIdx !== 0) return
499
+ const head = read(state.nodes[0] as number)
500
+ if (head?.type !== 'system/message') return
501
+ if (event.type !== 'system/message' || shadowedSeqs.length !== 1) {
502
+ throw new Error('surface replace: node 0 holds the system prompt and may be rewritten only by a system/message over exactly that node')
503
+ }
504
+ }
505
+
506
+ /** Validate one event at its replay boundary and prepare its atomic fold transition. */
507
+ function planSurfaceEvent(
508
+ state: SurfaceFoldState,
509
+ event: SessionEvent,
510
+ expectedSeq: SessionSeq,
511
+ events: readonly SessionEvent[],
512
+ baseSeq: SessionLogOffset,
513
+ projections: readonly SessionMessageProjection[],
514
+ candidates?: readonly SessionEvent[],
515
+ ): SurfacePlan | undefined {
516
+ if (event.seq !== expectedSeq) {
517
+ throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`)
518
+ }
519
+ const surfaceOp = validateSurfaceMetadata(event)
520
+ const read = (seq: number): SessionEvent | undefined => {
521
+ const index = seq - baseSeq
522
+ return index < events.length ? events[index] : candidates?.[index - events.length]
523
+ }
524
+ assertDeveloperHeader(event, read)
525
+ const projection = projections.find(item => item.type === event.type)
526
+ if (projection !== undefined) {
527
+ let projectionEvents: readonly SessionEvent[] | undefined
528
+ return { kind: 'project', projection, messages: projection.project(event, {
529
+ nodes: state.nodes,
530
+ get events() { return candidates === undefined ? events : (projectionEvents ??= [...events, ...candidates]) },
531
+ baseSeq,
532
+ messages: state.projectedMessages,
533
+ }) }
534
+ }
535
+ if (MESSAGE_PROJECTION_EVENT_TYPES.has(event.type)) {
536
+ throw new Error(`session event "${event.type}" requires a message projection; load its owning plugin or supply its projection definition`)
537
+ }
538
+ if (surfaceOp === undefined) return
539
+ if (surfaceOp === 'append') {
540
+ return { kind: 'append', seq: event.seq }
541
+ }
542
+ const range = replacementRange(state, surfaceOp)
543
+ assertSourceEventReferences(event, range.shadowedSeqs)
544
+ assertToolResultRewrite(event, range.shadowedSeqs, read)
545
+ assertSystemHeadRewrite(event, state, range.startIdx, range.shadowedSeqs, read)
546
+ return {
547
+ kind: 'replace',
548
+ seq: event.seq,
549
+ start: surfaceOp.startSeq,
550
+ end: surfaceOp.endSeq,
551
+ ...range,
552
+ }
553
+ }
554
+
555
+ /** Apply one event and return replacement metadata only when one occurred. */
556
+ function applySurfaceEvent(
557
+ state: SurfaceFoldState,
558
+ event: SessionEvent,
559
+ expectedSeq: SessionSeq,
560
+ events: readonly SessionEvent[],
561
+ baseSeq: SessionLogOffset,
562
+ projections: readonly SessionMessageProjection[],
563
+ ): SurfaceFoldReplacement | undefined {
564
+ const plan = planSurfaceEvent(state, event, expectedSeq, events, baseSeq, projections)
565
+ return applySurfacePlan(state, plan)
566
+ }
567
+
568
+ /** Commit one previously validated surface transition. */
569
+ function applySurfacePlan(
570
+ state: SurfaceFoldState,
571
+ plan: SurfacePlan | undefined,
572
+ ): SurfaceFoldReplacement | undefined {
573
+ if (plan?.kind === 'append') {
574
+ state.nodes.push(plan.seq)
575
+ } else if (plan?.kind === 'replace') {
576
+ state.nodes.splice(plan.startIdx, plan.endIdx - plan.startIdx + 1, plan.seq)
577
+ state.replaceGeneration += 1
578
+ state.contentGeneration += 1
579
+ } else if (plan?.kind === 'project') {
580
+ for (const [seq, message] of plan.messages) state.projectedMessages.set(seq, message)
581
+ state.projections.add(plan.projection)
582
+ state.contentGeneration += 1
583
+ }
584
+ if (plan?.kind !== 'replace') return
585
+ return {
586
+ seq: plan.seq,
587
+ start: plan.start,
588
+ end: plan.end,
589
+ shadowedSeqs: plan.shadowedSeqs,
590
+ }
591
+ }
592
+
593
+ /**
594
+ * Replay a complete session log through the canonical surface fold.
595
+ * @param events - session events in contiguous seq order.
596
+ * @param projections - pure interpreters for plugin-owned message changes; required definitions must be supplied.
597
+ * @returns detached current sequences and replacement history.
598
+ * @throws when an interpreter is missing or an event violates its projection, surface metadata, source attribution, or replacement rules.
599
+ */
600
+ export function foldSurface(events: readonly SessionEvent[], projections: readonly SessionMessageProjection[] = []): SurfaceFoldResult {
601
+ const state = createFoldState()
602
+ const replacements: SurfaceFoldReplacement[] = []
603
+ for (const [index, event] of events.entries()) {
604
+ const replacement = applySurfaceEvent(
605
+ state,
606
+ event,
607
+ SessionSeq(index),
608
+ events,
609
+ SessionLogOffset(0),
610
+ projections,
611
+ )
612
+ if (replacement !== undefined) replacements.push(replacement)
613
+ }
614
+ return { nodes: [...state.nodes], replacements, projectedMessages: new Map(state.projectedMessages) }
615
+ }
616
+
617
+ /** Incremental ordered surface view and append-boundary validator. */
618
+ export class SurfaceManager implements SessionSurface {
619
+ /** Shared transition state; replacement history is not retained. */
620
+ private _state = createFoldState()
621
+ /** Last processed absolute seq. */
622
+ private _lastProcessedSeq: SessionSeqCursor
623
+ /** Candidate already validated by `validateNext`, pending exact log admission. */
624
+ private _pendingPlan: { event: SessionEvent; expectedSeq: SessionSeq; plan: SurfacePlan | undefined } | undefined
625
+
626
+ /**
627
+ * @param log - Contiguous complete log or loaded event window.
628
+ * @param baseSeq - Absolute sequence of the window's first event.
629
+ * @param projections - live borrowed definitions; removing a used definition invalidates further reads.
630
+ */
631
+ constructor(
632
+ private log: readonly SessionEvent[],
633
+ private readonly baseSeq: SessionLogOffset = SessionLogOffset(0),
634
+ private readonly projections: readonly SessionMessageProjection[] = [],
635
+ ) {
636
+ this._lastProcessedSeq = baseSeq === 0 ? -1 : SessionSeq(baseSeq - 1)
637
+ }
638
+
639
+ /**
640
+ * Validate the next candidate without mutating the committed surface.
641
+ * @param event - candidate event that has not entered the log yet.
642
+ */
643
+ validateNext(event: SessionEvent): void {
644
+ this._assertProjections()
645
+ if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
646
+ const expectedSeq = SessionSeq(this.baseSeq + this.log.length)
647
+ this._pendingPlan = {
648
+ event,
649
+ expectedSeq,
650
+ plan: planSurfaceEvent(this._state, event, expectedSeq, this.log, this.baseSeq, this.projections),
651
+ }
652
+ }
653
+
654
+ /**
655
+ * Validate consecutive candidates against an isolated surface fold.
656
+ * @param events - candidates immediately following the committed log.
657
+ * @returns a callback adopting the validated fold after all candidates enter the log.
658
+ */
659
+ prepareBatch(events: readonly SessionEvent[]): () => void {
660
+ this._assertProjections()
661
+ if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
662
+ const lastProcessedSeq = SessionSeq(this.baseSeq + this.log.length + events.length - 1)
663
+ if (events.every(event => (event.surfaceOp === undefined || event.surfaceOp === 'append')
664
+ && !this.projections.some(projection => projection.type === event.type))) {
665
+ const plans = events.map((event, index) => planSurfaceEvent(
666
+ this._state, event, SessionSeq(this.baseSeq + this.log.length + index), this.log, this.baseSeq, this.projections, events,
667
+ ))
668
+ return () => {
669
+ for (const plan of plans) applySurfacePlan(this._state, plan)
670
+ this._lastProcessedSeq = lastProcessedSeq
671
+ this._pendingPlan = undefined
672
+ }
673
+ }
674
+ const state: SurfaceFoldState = {
675
+ ...this._state,
676
+ nodes: [...this._state.nodes],
677
+ projectedMessages: new Map(this._state.projectedMessages),
678
+ projections: new Set(this._state.projections),
679
+ }
680
+ const plans = events.map((event, index) => {
681
+ const plan = planSurfaceEvent(
682
+ state, event, SessionSeq(this.baseSeq + this.log.length + index), this.log, this.baseSeq, this.projections, events,
683
+ )
684
+ applySurfacePlan(state, plan)
685
+ return plan
686
+ })
687
+ return () => {
688
+ for (const plan of plans) applySurfacePlan(this._state, plan)
689
+ this._lastProcessedSeq = lastProcessedSeq
690
+ this._pendingPlan = undefined
691
+ }
692
+ }
693
+
694
+ /** Monotonic count of folded positional replacements. */
695
+ get replaceGeneration(): number {
696
+ this._assertProjections()
697
+ if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
698
+ return this._state.replaceGeneration
699
+ }
700
+
701
+ /** Monotonic count of committed changes to existing model-visible content. */
702
+ get contentGeneration(): number {
703
+ this._assertProjections()
704
+ if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
705
+ return this._state.contentGeneration
706
+ }
707
+
708
+ /**
709
+ * Project one message with every committed message projection applied.
710
+ * @param event - message-producing or log-only event.
711
+ * @returns its immutable projected message, or null when it produces none.
712
+ */
713
+ deriveEventMessage(event: SessionEvent): Message | null {
714
+ this._assertProjections()
715
+ if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
716
+ return deriveEventMessage(event, this._state.projectedMessages)
717
+ }
718
+
719
+ /** Surface event sequences in model-visible order. */
720
+ get nodes(): readonly SessionSeq[] {
721
+ this._assertProjections()
722
+ if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
723
+ return this._state.nodes
724
+ }
725
+
726
+ /** Fold events appended since the previous access. */
727
+ private _processDelta(): void {
728
+ const tailSeq = this.baseSeq + this.log.length - 1
729
+ for (let seq = this._lastProcessedSeq + 1; seq <= tailSeq; seq++) {
730
+ const index = seq - this.baseSeq
731
+ // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition
732
+ const event = this.log[index]!
733
+ const pending = this._pendingPlan
734
+ if (pending?.event === event && pending.expectedSeq === seq) {
735
+ applySurfacePlan(this._state, pending.plan)
736
+ } else {
737
+ applySurfaceEvent(this._state, event, SessionSeq(seq), this.log, this.baseSeq, this.projections)
738
+ }
739
+ if (pending !== undefined && pending.expectedSeq <= seq) this._pendingPlan = undefined
740
+ this._lastProcessedSeq = SessionSeq(seq)
741
+ }
742
+ }
743
+
744
+ /** Cached messages cannot outlive the definitions that interpreted their log. */
745
+ private _assertProjections(): void {
746
+ const candidate = this._pendingPlan
747
+ const pending = candidate !== undefined && this.log[candidate.expectedSeq - this.baseSeq] === candidate.event
748
+ ? candidate.plan : undefined
749
+ const required = pending?.kind === 'project'
750
+ ? [...this._state.projections, pending.projection]
751
+ : this._state.projections
752
+ for (const projection of required) {
753
+ if (!this.projections.includes(projection)) {
754
+ throw new Error(`session message projection "${projection.type}" was removed or replaced; restore the session with its owning plugin`)
755
+ }
756
+ }
757
+ }
758
+ }