返回源码地图

packages/core/session/src/surface.ts

main snapshot · da00f7f5358f · 正文引用章节 02 / 06 / 06 / 06 / 06 / 06;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

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
11import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
12import { SessionLogOffset, SessionSeq } from './types.ts'
13import { KNOWN_SESSION_EVENT_TYPES, MESSAGE_PROJECTION_EVENT_TYPES } from './known-event-types.ts'
14import 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. */
23export 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. */
35export 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. */
50const 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 */
63export 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 */
72export 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 */
89export 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 */
102export 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 */
120export 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. */
155function 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 */
166export 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. */
223export 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. */
235export 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. */
245export 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. */
255interface 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. */
264interface 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. */
271type 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. */
277function 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. */
282function 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. */
290function 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. */
302function 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. */
330function 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. */
368function assertDeveloperHeader(
369 event: SessionEvent,
370 events: readonly SessionEvent[],
371 baseSeq: SessionLogOffset,
372): void {
373 if (event.type !== 'developer/message') return
374 validateSessionEventData(event, `developer/message at seq ${event.seq}`)
375 if (event.data.headerSeq === undefined) return
376 const headerSeq = event.data.headerSeq
377 const headerEvent = events[headerSeq - baseSeq]
378 if (headerSeq >= event.seq || headerEvent?.type !== 'request/header') {
379 throw new Error('developer/message headerSeq must reference an earlier request/header')
380 }
381 for (const block of event.data.message.content) {
382 if (block.type !== 'tool-addition') continue
383 const definitions = headerEvent.data.header.tools?.filter(tool => tool.name === block.toolName) ?? []
384 if (definitions.length !== 1) {
385 throw new Error(`developer/message tool-addition "${block.toolName}" must name exactly one tool in headerSeq ${headerSeq}`)
386 }
387 const definition = definitions[0] as ToolSchema
388 if (typeof definition.description !== 'string' || !isRecord(definition.parameters)) {
389 throw new Error(`developer/message tool-addition "${block.toolName}" requires a complete tool definition in headerSeq ${headerSeq}`)
390 }
391 if (Object.hasOwn(definition, 'deferLoading') && definition.deferLoading !== true) {
392 throw new Error('developer/message referenced tool deferLoading must be true when present')
393 }
394 }
395}
396
397/**
398 * Validate one event's surface metadata without checking membership in a log or surface.
399 * @param event - event whose marker and source sequence values are inspected.
400 * Unknown ignorable records retain opaque metadata and never change the surface.
401 * @returns the validated operation, or undefined for a log-only or unknown ignorable event.
402 * @throws when metadata violates event-local eligibility, marker, or source-sequence rules.
403 */
404export function validateSurfaceMetadata(event: SessionEvent): SurfaceOp | undefined {
405 const op = surfaceOpOf(event)
406 if (op !== undefined && op !== 'append'
407 && (op.startSeq >= event.seq || op.endSeq >= event.seq)) {
408 throw new Error(`surface replace at seq ${event.seq}: startSeq and endSeq must reference earlier events`)
409 }
410 if (op !== undefined) assertSourceEventReferences(event, [])
411 return op
412}
413
414/** Locate one replacement range without mutating the current fold state. */
415function replacementRange(
416 state: SurfaceFoldState,
417 op: Extract<SurfaceOp, { op: 'replace' }>,
418): Pick<SurfaceReplacePlan, 'startIdx' | 'endIdx' | 'shadowedSeqs'> {
419 const startIdx = state.nodes.indexOf(op.startSeq)
420 if (startIdx === -1) {
421 throw new Error(`surface replace: start seq ${op.startSeq} not found in surface`)
422 }
423 const endIdx = state.nodes.indexOf(op.endSeq)
424 if (endIdx === -1) {
425 throw new Error(`surface replace: end seq ${op.endSeq} not found in surface`)
426 }
427 if (startIdx > endIdx) {
428 throw new Error(`surface replace: start seq ${op.startSeq} (index ${startIdx}) is after end seq ${op.endSeq} (index ${endIdx})`)
429 }
430 return {
431 startIdx,
432 endIdx,
433 shadowedSeqs: state.nodes.slice(startIdx, endIdx + 1),
434 }
435}
436
437/**
438 * Deep structural equality over the session-event JSON value domain
439 * (null/boolean/number/string, arrays, plain objects). Replaces
440 * `node:util`'s isDeepStrictEqual to keep this module browser-safe.
441 */
442function isDeepEqualJson(a: unknown, b: unknown): boolean {
443 if (a === b) return true
444 if (Array.isArray(a) || Array.isArray(b)) {
445 if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
446 return a.every((item, i) => isDeepEqualJson(item, b[i]))
447 }
448 if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
449 const aKeys = Object.keys(a)
450 const bRecord = b as Record<string, unknown>
451 if (aKeys.length !== Object.keys(b).length) return false
452 return aKeys.every(key => Object.hasOwn(b, key) && isDeepEqualJson((a as Record<string, unknown>)[key], bRecord[key]))
453}
454
455/** Restrict a tool-result replacement to one current result's content. */
456function assertToolResultRewrite(
457 event: SessionEvent,
458 shadowedSeqs: readonly SessionSeq[],
459 events: readonly SessionEvent[],
460 baseSeq: SessionLogOffset,
461): void {
462 if (event.type !== 'tool/result') return
463 if (shadowedSeqs.length !== 1) {
464 throw new Error('tool/result surface replacement must rewrite exactly one current node')
465 }
466 for (const originalSeq of shadowedSeqs) {
467 const original = events[originalSeq - baseSeq]
468 if (original?.type !== 'tool/result') {
469 throw new Error('tool/result surface replacement must target a current tool/result')
470 }
471 const originalRest = { ...original.data } as Record<string, unknown>
472 const replacementRest = { ...event.data } as Record<string, unknown>
473 originalRest['message'] = {
474 ...original.data.message,
475 content: null,
476 }
477 replacementRest['message'] = {
478 ...event.data.message,
479 content: null,
480 }
481 if (!isDeepEqualJson(originalRest, replacementRest)) {
482 throw new Error('tool/result surface replacement may change only content')
483 }
484 }
485}
486
487/**
488 * Protect the system prompt at surface node 0. A replacement covering node 0
489 * while that node is a `system/message` must itself be a `system/message` over
490 * exactly that node; later system nodes carry no protection and a compaction
491 * range may shadow them.
492 */
493function assertSystemHeadRewrite(
494 event: SessionEvent,
495 state: SurfaceFoldState,
496 startIdx: number,
497 shadowedSeqs: readonly SessionSeq[],
498 events: readonly SessionEvent[],
499 baseSeq: SessionLogOffset,
500): void {
501 if (startIdx !== 0) return
502 const head = events[state.nodes[0] as number - baseSeq]
503 if (head?.type !== 'system/message') return
504 if (event.type !== 'system/message' || shadowedSeqs.length !== 1) {
505 throw new Error('surface replace: node 0 holds the system prompt and may be rewritten only by a system/message over exactly that node')
506 }
507}
508
509/** Validate one event at its replay boundary and prepare its atomic fold transition. */
510function planSurfaceEvent(
511 state: SurfaceFoldState,
512 event: SessionEvent,
513 expectedSeq: SessionSeq,
514 events: readonly SessionEvent[],
515 baseSeq: SessionLogOffset,
516 projections: readonly SessionMessageProjection[],
517): SurfacePlan | undefined {
518 if (event.seq !== expectedSeq) {
519 throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`)
520 }
521 const surfaceOp = validateSurfaceMetadata(event)
522 assertDeveloperHeader(event, events, baseSeq)
523 const projection = projections.find(item => item.type === event.type)
524 if (projection !== undefined) {
525 return { kind: 'project', projection, messages: projection.project(event, {
526 nodes: state.nodes, events, baseSeq, messages: state.projectedMessages,
527 }) }
528 }
529 if (MESSAGE_PROJECTION_EVENT_TYPES.has(event.type)) {
530 throw new Error(`session event "${event.type}" requires a message projection; load its owning plugin or supply its projection definition`)
531 }
532 if (surfaceOp === undefined) return
533 if (surfaceOp === 'append') {
534 return { kind: 'append', seq: event.seq }
535 }
536 const range = replacementRange(state, surfaceOp)
537 assertSourceEventReferences(event, range.shadowedSeqs)
538 assertToolResultRewrite(event, range.shadowedSeqs, events, baseSeq)
539 assertSystemHeadRewrite(event, state, range.startIdx, range.shadowedSeqs, events, baseSeq)
540 return {
541 kind: 'replace',
542 seq: event.seq,
543 start: surfaceOp.startSeq,
544 end: surfaceOp.endSeq,
545 ...range,
546 }
547}
548
549/** Apply one event and return replacement metadata only when one occurred. */
550function applySurfaceEvent(
551 state: SurfaceFoldState,
552 event: SessionEvent,
553 expectedSeq: SessionSeq,
554 events: readonly SessionEvent[],
555 baseSeq: SessionLogOffset,
556 projections: readonly SessionMessageProjection[],
557): SurfaceFoldReplacement | undefined {
558 const plan = planSurfaceEvent(state, event, expectedSeq, events, baseSeq, projections)
559 return applySurfacePlan(state, plan)
560}
561
562/** Commit one previously validated surface transition. */
563function applySurfacePlan(
564 state: SurfaceFoldState,
565 plan: SurfacePlan | undefined,
566): SurfaceFoldReplacement | undefined {
567 if (plan?.kind === 'append') {
568 state.nodes.push(plan.seq)
569 } else if (plan?.kind === 'replace') {
570 state.nodes.splice(plan.startIdx, plan.endIdx - plan.startIdx + 1, plan.seq)
571 state.replaceGeneration += 1
572 state.contentGeneration += 1
573 } else if (plan?.kind === 'project') {
574 for (const [seq, message] of plan.messages) state.projectedMessages.set(seq, message)
575 state.projections.add(plan.projection)
576 state.contentGeneration += 1
577 }
578 if (plan?.kind !== 'replace') return
579 return {
580 seq: plan.seq,
581 start: plan.start,
582 end: plan.end,
583 shadowedSeqs: plan.shadowedSeqs,
584 }
585}
586
587/**
588 * Replay a complete session log through the canonical surface fold.
589 * @param events - session events in contiguous seq order.
590 * @param projections - pure interpreters for plugin-owned message changes; required definitions must be supplied.
591 * @returns detached current sequences and replacement history.
592 * @throws when an interpreter is missing or an event violates its projection, surface metadata, source attribution, or replacement rules.
593 */
594export function foldSurface(events: readonly SessionEvent[], projections: readonly SessionMessageProjection[] = []): SurfaceFoldResult {
595 const state = createFoldState()
596 const replacements: SurfaceFoldReplacement[] = []
597 for (const [index, event] of events.entries()) {
598 const replacement = applySurfaceEvent(
599 state,
600 event,
601 SessionSeq(index),
602 events,
603 SessionLogOffset(0),
604 projections,
605 )
606 if (replacement !== undefined) replacements.push(replacement)
607 }
608 return { nodes: [...state.nodes], replacements, projectedMessages: new Map(state.projectedMessages) }
609}
610
611/** Incremental ordered surface view and append-boundary validator. */
612export class SurfaceManager implements SessionSurface {
613 /** Shared transition state; replacement history is not retained. */
614 private _state = createFoldState()
615 /** Last processed absolute seq. */
616 private _lastProcessedSeq: SessionSeqCursor
617 /** Candidate already validated by `validateNext`, pending exact log admission. */
618 private _pendingPlan: { event: SessionEvent; expectedSeq: SessionSeq; plan: SurfacePlan | undefined } | undefined
619
620 /**
621 * @param log - Contiguous complete log or loaded event window.
622 * @param baseSeq - Absolute sequence of the window's first event.
623 * @param projections - live borrowed definitions; removing a used definition invalidates further reads.
624 */
625 constructor(
626 private log: readonly SessionEvent[],
627 private readonly baseSeq: SessionLogOffset = SessionLogOffset(0),
628 private readonly projections: readonly SessionMessageProjection[] = [],
629 ) {
630 this._lastProcessedSeq = baseSeq === 0 ? -1 : SessionSeq(baseSeq - 1)
631 }
632
633 /**
634 * Validate the next candidate without mutating the committed surface.
635 * @param event - candidate event that has not entered the log yet.
636 */
637 validateNext(event: SessionEvent): void {
638 this._assertProjections()
639 if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
640 const expectedSeq = SessionSeq(this.baseSeq + this.log.length)
641 this._pendingPlan = {
642 event,
643 expectedSeq,
644 plan: planSurfaceEvent(this._state, event, expectedSeq, this.log, this.baseSeq, this.projections),
645 }
646 }
647
648 /** Monotonic count of folded positional replacements. */
649 get replaceGeneration(): number {
650 this._assertProjections()
651 if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
652 return this._state.replaceGeneration
653 }
654
655 /** Monotonic count of committed changes to existing model-visible content. */
656 get contentGeneration(): number {
657 this._assertProjections()
658 if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
659 return this._state.contentGeneration
660 }
661
662 /**
663 * Project one message with every committed message projection applied.
664 * @param event - message-producing or log-only event.
665 * @returns its immutable projected message, or null when it produces none.
666 */
667 deriveEventMessage(event: SessionEvent): Message | null {
668 this._assertProjections()
669 if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
670 return deriveEventMessage(event, this._state.projectedMessages)
671 }
672
673 /** Surface event sequences in model-visible order. */
674 get nodes(): readonly SessionSeq[] {
675 this._assertProjections()
676 if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
677 return this._state.nodes
678 }
679
680 /** Fold events appended since the previous access. */
681 private _processDelta(): void {
682 const tailSeq = this.baseSeq + this.log.length - 1
683 for (let seq = this._lastProcessedSeq + 1; seq <= tailSeq; seq++) {
684 const index = seq - this.baseSeq
685 // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition
686 const event = this.log[index]!
687 const pending = this._pendingPlan
688 if (pending?.event === event && pending.expectedSeq === seq) {
689 applySurfacePlan(this._state, pending.plan)
690 } else {
691 applySurfaceEvent(this._state, event, SessionSeq(seq), this.log, this.baseSeq, this.projections)
692 }
693 if (pending !== undefined && pending.expectedSeq <= seq) this._pendingPlan = undefined
694 this._lastProcessedSeq = SessionSeq(seq)
695 }
696 }
697
698 /** Cached messages cannot outlive the definitions that interpreted their log. */
699 private _assertProjections(): void {
700 const candidate = this._pendingPlan
701 const pending = candidate !== undefined && this.log[candidate.expectedSeq - this.baseSeq] === candidate.event
702 ? candidate.plan : undefined
703 const required = pending?.kind === 'project'
704 ? [...this._state.projections, pending.projection]
705 : this._state.projections
706 for (const projection of required) {
707 if (!this.projections.includes(projection)) {
708 throw new Error(`session message projection "${projection.type}" was removed or replaced; restore the session with its owning plugin`)
709 }
710 }
711 }
712}