1
/**2
* Surface layer on top of the session event log: an ordered view of events3
* 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 free6
* of `node:` imports (they break the vite bundle).7
*8
* @module @deepseek-ai/dsh-session/surface9
*/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'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: SessionLogOffset30
/** Previously projected messages keyed by their original event sequences. */31
messages: ReadonlyMap<SessionSeq, Message>32
}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: T38
/**39
* Validate the complete durable decision before returning any updates. Preserve40
* 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
}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
])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
}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 false74
const candidate: { surfaceOp?: unknown } = event75
return candidate.surfaceOp !== undefined76
}78
/**79
* Narrow an event to an append-origin surface event: one that entered the80
* 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 the83
* wrong source for a human transcript — a landed replacement would erase84
* conversation the user already saw. Append-origin events are that transcript's85
* 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
}95
/**96
* Narrow an event to a surface replacement: a node that shadowed an existing97
* surface range instead of appending to the tail. The counterpart of98
* {@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
}108
/**109
* Project a single event into the LLM message it derives to, or null when it110
* produces none — a non-surface event (attempt, boundary, log-only record) or an111
* empty-content system, developer, or assistant message. A caller112
* reconstructing model input supplies the same prefix's `projectedMessages`113
* from {@link foldSurface}; without that map this function reads original114
* event content. Session instance methods apply the live projection. Messages115
* 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 projected126
// Intentionally non-exhaustive: only message-producing events derive127
// history; turn/step boundaries, failed attempts, and errors are trace/replay128
// 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.data134
}135
// Empty system and developer nodes retain their surface positions without136
// adding wire messages. An empty assistant event hosts a max-tokens step's137
// 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 null142
return event.data.message143
}144
case 'tool/result': {145
return event.data.message146
}147
default:148
// A non-surface event (boundary, attempt, log-only record) projects to149
// no message. Merge-extensible union: no assertNever here.150
return null151
}152
}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
}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.data171
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 = false184
for (const block of message['content']) {185
if (!isRecord(block) || (block['type'] !== 'tool-addition' && block['type'] !== 'tool-removal')) continue186
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 = true191
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) return215
const message = data['message']216
if (!isRecord(message) || message['isError'] !== true) {217
throw new Error(`${subject} error requires message.isError === true`)218
}219
}220
}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: SessionSeq226
/** Declared inclusive start seq of the replaced surface range. */227
start: SessionSeq228
/** Declared inclusive end seq of the replaced surface range. */229
end: SessionSeq230
/** Actual surface entries removed by the operation, in surface order. */231
shadowedSeqs: SessionSeq[]232
}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
}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: number250
/** Monotonic count of committed replacements and plugin-owned message changes. */251
readonly contentGeneration: number252
}254
/** Mutable state shared by complete and incremental folds. */255
interface SurfaceFoldState {256
nodes: SessionSeq[]257
replaceGeneration: number258
contentGeneration: number259
projectedMessages: Map<SessionSeq, Message>260
projections: Set<SessionMessageProjection>261
}263
/** A validated replacement transition that has not mutated fold state yet. */264
interface SurfaceReplacePlan extends SurfaceFoldReplacement {265
kind: 'replace'266
startIdx: number267
endIdx: number268
}270
/** One validated surface transition that has not mutated fold state yet. */271
type SurfacePlan =272
| { kind: 'append'; seq: SessionSeq }273
| SurfaceReplacePlan274
| { kind: 'project'; projection: SessionMessageProjection; messages: ReadonlyMap<SessionSeq, Message> }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
}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 >= 0286
&& !Object.is(value, -0)287
}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 === 3293
&& 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
}301
/** Validate event-local surface eligibility and return its operation. */302
function surfaceOpOf(event: SessionEvent): SurfaceOp | undefined {303
const raw: { surfaceOp?: unknown; sourceEventSeqs?: unknown } = event304
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) return307
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
return314
}315
const op = raw.surfaceOp316
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 op320
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 op327
}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.sourceEventSeqs335
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 | undefined347
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 = source353
}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
}367
/** Resolve tool additions against their immutable historical request header. */368
function assertDeveloperHeader(369
event: SessionEvent,370
events: readonly SessionEvent[],371
baseSeq: SessionLogOffset,372
): void {373
if (event.type !== 'developer/message') return374
validateSessionEventData(event, `developer/message at seq ${event.seq}`)375
if (event.data.headerSeq === undefined) return376
const headerSeq = event.data.headerSeq377
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') continue383
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 ToolSchema388
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
}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
*/404
export 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 op412
}414
/** Locate one replacement range without mutating the current fold state. */415
function 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
}437
/**438
* Deep structural equality over the session-event JSON value domain439
* (null/boolean/number/string, arrays, plain objects). Replaces440
* `node:util`'s isDeepStrictEqual to keep this module browser-safe.441
*/442
function isDeepEqualJson(a: unknown, b: unknown): boolean {443
if (a === b) return true444
if (Array.isArray(a) || Array.isArray(b)) {445
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false446
return a.every((item, i) => isDeepEqualJson(item, b[i]))447
}448
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false449
const aKeys = Object.keys(a)450
const bRecord = b as Record<string, unknown>451
if (aKeys.length !== Object.keys(b).length) return false452
return aKeys.every(key => Object.hasOwn(b, key) && isDeepEqualJson((a as Record<string, unknown>)[key], bRecord[key]))453
}455
/** Restrict a tool-result replacement to one current result's content. */456
function assertToolResultRewrite(457
event: SessionEvent,458
shadowedSeqs: readonly SessionSeq[],459
events: readonly SessionEvent[],460
baseSeq: SessionLogOffset,461
): void {462
if (event.type !== 'tool/result') return463
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
}487
/**488
* Protect the system prompt at surface node 0. A replacement covering node 0489
* while that node is a `system/message` must itself be a `system/message` over490
* exactly that node; later system nodes carry no protection and a compaction491
* range may shadow them.492
*/493
function 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) return502
const head = events[state.nodes[0] as number - baseSeq]503
if (head?.type !== 'system/message') return504
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
}509
/** Validate one event at its replay boundary and prepare its atomic fold transition. */510
function 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) return533
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
}549
/** Apply one event and return replacement metadata only when one occurred. */550
function 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
}562
/** Commit one previously validated surface transition. */563
function 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 += 1572
state.contentGeneration += 1573
} 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 += 1577
}578
if (plan?.kind !== 'replace') return579
return {580
seq: plan.seq,581
start: plan.start,582
end: plan.end,583
shadowedSeqs: plan.shadowedSeqs,584
}585
}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
*/594
export 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
}611
/** Incremental ordered surface view and append-boundary validator. */612
export 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: SessionSeqCursor617
/** Candidate already validated by `validateNext`, pending exact log admission. */618
private _pendingPlan: { event: SessionEvent; expectedSeq: SessionSeq; plan: SurfacePlan | undefined } | undefined620
/**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
}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
}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.replaceGeneration653
}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.contentGeneration660
}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
}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.nodes678
}680
/** Fold events appended since the previous access. */681
private _processDelta(): void {682
const tailSeq = this.baseSeq + this.log.length - 1683
for (let seq = this._lastProcessedSeq + 1; seq <= tailSeq; seq++) {684
const index = seq - this.baseSeq685
// oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition686
const event = this.log[index]!687
const pending = this._pendingPlan688
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 = undefined694
this._lastProcessedSeq = SessionSeq(seq)695
}696
}698
/** Cached messages cannot outlive the definitions that interpreted their log. */699
private _assertProjections(): void {700
const candidate = this._pendingPlan701
const pending = candidate !== undefined && this.log[candidate.expectedSeq - this.baseSeq] === candidate.event702
? candidate.plan : undefined703
const required = pending?.kind === 'project'704
? [...this._state.projections, pending.projection]705
: this._state.projections706
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
}