@deepseek-ai/dsh-session 0.0.1-rc.1

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.
@@ -0,0 +1,1015 @@
1
+ /**
2
+ * Event-sourced session service: append-only session log, in-memory store, and
3
+ * the derived LLM message history. Persistence is a plugin concern (subscribe
4
+ * to `session/event`, drain on `session/flush`).
5
+ *
6
+ * @module @deepseek-ai/dsh-session
7
+ */
8
+ import { Service } from '@deepseek-ai/cordis';
9
+ import { isAbsolute } from 'node:path';
10
+ import { deepFreeze } from '@deepseek-ai/dsh-llm';
11
+ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope';
12
+ import { SESSION_FORMAT_VERSION, SessionId } from "./types.js";
13
+ import { snapshotJsonValue } from "./json.js";
14
+ import { deriveEventMessage, SurfaceManager } from "./surface.js";
15
+ import { foldRequestHeader } from "./request-header.js";
16
+ export * from "./types.js";
17
+ export { SessionPreparation } from "./preparation.js";
18
+ export { isJsonValue, snapshotJsonValue } from "./json.js";
19
+ export { interruptedTurnClosers, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from "./repair.js";
20
+ export { decodeStorageRecord, packChunkRuns } from "./chunk-rows.js";
21
+ export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from "./surface.js";
22
+ export { canonicalHeader, foldRequestHeader, headerEquals } from "./request-header.js";
23
+ /**
24
+ * Find the latest closed turn that entered at least one model step, ignoring
25
+ * balanced no-step turns produced by rejection, empty input, or cancellation.
26
+ * @param events - session events, or an owned suffix, to inspect.
27
+ * @returns the latest matching turn end, or `undefined`.
28
+ */
29
+ export function findLastMessageTurnEnd(events) {
30
+ const steppedTurns = new Set();
31
+ let latest;
32
+ for (const event of events) {
33
+ if (event.type === 'step/start') {
34
+ steppedTurns.add(event.data.turn);
35
+ continue;
36
+ }
37
+ if (event.type === 'turn/end' && steppedTurns.delete(event.data.turn))
38
+ latest = event;
39
+ }
40
+ return latest;
41
+ }
42
+ /** Validate and freeze one detached creation header in place. */
43
+ function validateSessionHeader(id, input) {
44
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
45
+ throw new Error('session header is not a plain JSON record');
46
+ }
47
+ const record = input;
48
+ if (record.version !== SESSION_FORMAT_VERSION) {
49
+ throw new Error(`session header version must be ${SESSION_FORMAT_VERSION}, got ${String(record.version)}`);
50
+ }
51
+ if (record.id !== id) {
52
+ throw new Error(`session header id "${String(record.id)}" does not match session id "${id}"`);
53
+ }
54
+ if (typeof record.createdAt !== 'number'
55
+ || !Number.isSafeInteger(record.createdAt)
56
+ || record.createdAt < 0) {
57
+ throw new Error('session header createdAt must be a non-negative safe integer');
58
+ }
59
+ if (record.cwd !== undefined) {
60
+ if (typeof record.cwd !== 'string')
61
+ throw new Error('session header cwd must be a string');
62
+ if (!isAbsolute(record.cwd)) {
63
+ throw new Error(`session header cwd must be an absolute path, got "${record.cwd}"`);
64
+ }
65
+ }
66
+ if (record.parentSession !== undefined && typeof record.parentSession !== 'string') {
67
+ throw new Error('session header parentSession must be a string');
68
+ }
69
+ if (record.seedLength !== undefined
70
+ && (typeof record.seedLength !== 'number' || !Number.isSafeInteger(record.seedLength) || record.seedLength < 0)) {
71
+ throw new Error('session header seedLength must be a non-negative safe integer');
72
+ }
73
+ if (record.origin !== undefined && record.origin !== 'subagent') {
74
+ throw new Error('session header origin must be "subagent"');
75
+ }
76
+ if (record.delegationDepth !== undefined
77
+ && (typeof record.delegationDepth !== 'number' || !Number.isSafeInteger(record.delegationDepth) || record.delegationDepth < 0)) {
78
+ throw new Error('session header delegationDepth must be a non-negative safe integer');
79
+ }
80
+ if (record.agentPreset !== undefined && typeof record.agentPreset !== 'string') {
81
+ throw new Error('session header agentPreset must be a string');
82
+ }
83
+ return deepFreeze(record);
84
+ }
85
+ /** Validate and freeze one exclusively owned persistence header in place. */
86
+ function validateRestoredSessionHeader(id, input) {
87
+ if (input !== null && typeof input === 'object' && !Array.isArray(input)) {
88
+ const prototype = Reflect.getPrototypeOf(input);
89
+ if (prototype !== Object.prototype && prototype !== null) {
90
+ throw new Error('session header is not a plain JSON record');
91
+ }
92
+ }
93
+ return validateSessionHeader(id, input);
94
+ }
95
+ /** Detach, validate, and freeze the creation metadata published by a session. */
96
+ function snapshotSessionHeader(id, source) {
97
+ const input = source === undefined
98
+ ? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
99
+ : source;
100
+ const snapshot = snapshotJsonValue(input);
101
+ if (snapshot === undefined)
102
+ throw new Error('session header is not losslessly JSON-serializable');
103
+ return validateSessionHeader(id, snapshot);
104
+ }
105
+ /**
106
+ * Validate an exclusively owned event and deeply freeze its identified message
107
+ * without copying the event. The caller transfers an object graph that no
108
+ * producer retains and that shares no mutable children with another event.
109
+ * Use {@link snapshotSessionEvent} when exclusive ownership is not guaranteed.
110
+ * @param event - exclusively owned event imported across a trusted boundary.
111
+ * @returns the same event object with a validated, deeply frozen message.
112
+ */
113
+ export function adoptSessionEvent(event) {
114
+ assertMessageEventShape(event, `session event at seq ${event.seq}`);
115
+ switch (event.type) {
116
+ case 'user/message':
117
+ deepFreeze(event.data);
118
+ break;
119
+ case 'assistant/message':
120
+ case 'tool/result':
121
+ deepFreeze(event.data.message);
122
+ break;
123
+ default:
124
+ // SessionEventMap is merge-extensible; plugin-owned events carry no core message.
125
+ break;
126
+ }
127
+ return event;
128
+ }
129
+ /**
130
+ * Detach one event while preserving deep immutability for its identified message.
131
+ * @param event - event imported across a query or persistence boundary.
132
+ * @returns a detached event snapshot with a validated, deeply frozen message.
133
+ */
134
+ export function snapshotSessionEvent(event) {
135
+ return adoptSessionEvent(structuredClone(event));
136
+ }
137
+ /** Deep-freeze one acyclic JSON tree without consuming the JavaScript call stack. */
138
+ function freezeRestoredObject(value) {
139
+ const pending = [value];
140
+ while (pending.length > 0) {
141
+ // The non-empty check proves an object remains to visit.
142
+ // oxlint-disable-next-line typescript/no-non-null-assertion
143
+ const current = pending.pop();
144
+ Object.freeze(current);
145
+ for (const key in current) {
146
+ const child = current[key];
147
+ if (child !== null && typeof child === 'object')
148
+ pending.push(child);
149
+ }
150
+ }
151
+ return value;
152
+ }
153
+ /** Validate the fixed event envelope after one-pass JSON materialization. */
154
+ function assertSessionEventEnvelope(value, index) {
155
+ const event = value;
156
+ if (event['type'] === 'request/header-delta') {
157
+ throw new Error(`seed event at index ${index} uses unsupported legacy request/header-delta format`);
158
+ }
159
+ for (const key in event) {
160
+ switch (key) {
161
+ case 'type':
162
+ case 'seq':
163
+ case 'time':
164
+ case 'data':
165
+ case 'surfaceOp':
166
+ case 'sourceEventSeqs':
167
+ break;
168
+ default:
169
+ throw new Error(`seed event at index ${index} has an invalid event envelope`);
170
+ }
171
+ }
172
+ const type = event['type'];
173
+ const seq = event['seq'];
174
+ const time = event['time'];
175
+ if (typeof type !== 'string'
176
+ || typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0
177
+ || typeof time !== 'number' || !Number.isSafeInteger(time)
178
+ || event['data'] === undefined) {
179
+ throw new Error(`seed event at index ${index} has an invalid event envelope`);
180
+ }
181
+ switch (type) {
182
+ case 'request/header':
183
+ case 'user/message':
184
+ case 'assistant/message':
185
+ case 'tool/result':
186
+ assertCurrentLlmShape(event, index);
187
+ break;
188
+ }
189
+ }
190
+ /** Reject obsolete request headers and malformed messages at the seed/load boundary. */
191
+ function assertCurrentLlmShape(event, index) {
192
+ const data = event['data'];
193
+ const record = typeof data === 'object' && data !== null
194
+ ? data
195
+ : undefined;
196
+ if (event['type'] === 'request/header') {
197
+ const header = record?.['header'];
198
+ const headerRecord = typeof header === 'object' && header !== null && !Array.isArray(header)
199
+ ? header
200
+ : undefined;
201
+ const config = headerRecord?.['config'];
202
+ if (!hasProviderModel(config))
203
+ throw new Error(`seed request/header at index ${index} lacks provider/model`);
204
+ const configRecord = config;
205
+ const reasoningEffort = configRecord['reasoningEffort'];
206
+ if (reasoningEffort !== undefined
207
+ && (typeof reasoningEffort !== 'string' || reasoningEffort.length === 0)) {
208
+ throw new Error(`seed request/header at index ${index} has an invalid reasoningEffort`);
209
+ }
210
+ assertAdapterDefaults(headerRecord?.['adapterDefaults'], configRecord, index);
211
+ }
212
+ const type = event['type'];
213
+ if (type !== 'user/message' && type !== 'assistant/message'
214
+ && type !== 'tool/result')
215
+ return;
216
+ assertMessageEventShape(event, `seed ${type} at index ${index}`);
217
+ }
218
+ const allowedAdapterKeys = new Set(['reasoningEffort', 'maxTokens']);
219
+ /** Validate adapter-default markers imported from a durable request header. */
220
+ function assertAdapterDefaults(value, config, index) {
221
+ if (value === undefined)
222
+ return;
223
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
224
+ throw new Error(`seed request/header at index ${index} has invalid adapterDefaults`);
225
+ }
226
+ const defaults = value;
227
+ if (Object.keys(defaults).some(key => !allowedAdapterKeys.has(key))
228
+ || Object.values(defaults).some(marker => marker !== true)
229
+ || defaults['reasoningEffort'] === true && config['reasoningEffort'] === undefined
230
+ || defaults['maxTokens'] === true && config['maxTokens'] === undefined) {
231
+ throw new Error(`seed request/header at index ${index} has invalid adapterDefaults`);
232
+ }
233
+ }
234
+ /** Validate only the event-specific invariants needed to safely replay a message. */
235
+ function assertMessageEventShape(event, subject) {
236
+ const type = event['type'];
237
+ if (type !== 'user/message' && type !== 'assistant/message'
238
+ && type !== 'tool/result')
239
+ return;
240
+ const data = event['data'];
241
+ const record = typeof data === 'object' && data !== null
242
+ ? data
243
+ : undefined;
244
+ const message = type === 'user/message' ? record : record?.['message'];
245
+ if (typeof message !== 'object' || message === null
246
+ || typeof message['id'] !== 'string'
247
+ || message['id'] === '') {
248
+ throw new Error(`${subject} lacks an identified message`);
249
+ }
250
+ const messageRecord = message;
251
+ const expectedRole = type === 'assistant/message' ? 'assistant' : 'user';
252
+ if (messageRecord['role'] !== expectedRole) {
253
+ throw new Error(`${subject} message must have role "${expectedRole}"`);
254
+ }
255
+ const source = messageRecord['source'];
256
+ if (typeof source !== 'object' || source === null
257
+ || typeof source['kind'] !== 'string'
258
+ || source['kind'] === '') {
259
+ throw new Error(`${subject} message has invalid source`);
260
+ }
261
+ if (!Array.isArray(messageRecord['content'])) {
262
+ throw new Error(`${subject} message has invalid content`);
263
+ }
264
+ const sourceRecord = source;
265
+ if (type === 'assistant/message') {
266
+ if (sourceRecord['kind'] !== 'model' || !hasProviderModel(sourceRecord)) {
267
+ throw new Error(`${subject} message must have model source`);
268
+ }
269
+ return;
270
+ }
271
+ if (type !== 'tool/result')
272
+ return;
273
+ if (sourceRecord['kind'] !== 'tool'
274
+ || typeof sourceRecord['callId'] !== 'string'
275
+ || sourceRecord['callId'] === '') {
276
+ throw new Error(`${subject} message must have tool source`);
277
+ }
278
+ const content = messageRecord['content'];
279
+ const block = content[0];
280
+ if (content.length !== 1 || typeof block !== 'object' || block === null
281
+ || block['type'] !== 'tool-result'
282
+ || !Array.isArray(block['content'])) {
283
+ throw new Error(`${subject} message must contain one tool-result block`);
284
+ }
285
+ if (block['toolCallId'] !== sourceRecord['callId']) {
286
+ throw new Error(`${subject} message has mismatched tool call ids`);
287
+ }
288
+ }
289
+ /** Whether an unknown value carries the current provider/model pair. */
290
+ function hasProviderModel(value) {
291
+ if (typeof value !== 'object' || value === null)
292
+ return false;
293
+ const pair = value;
294
+ return typeof pair['provider'] === 'string' && pair['provider'].length > 0
295
+ && typeof pair['model'] === 'string' && pair['model'].length > 0;
296
+ }
297
+ /** Reject request-header vocabulary removed with the legacy delta codec. */
298
+ function assertSupportedRequestHeader(type, data, location) {
299
+ if (type === 'request/header-delta') {
300
+ throw new Error(`${location} uses unsupported legacy request/header-delta format`);
301
+ }
302
+ if (type === 'request/header'
303
+ && data !== null && typeof data === 'object' && !Array.isArray(data)
304
+ && data['reason'] === 'fallback') {
305
+ throw new Error(`${location} uses unsupported legacy request/header reason "fallback"`);
306
+ }
307
+ }
308
+ /** Resolve one listener snapshot, including Cordis's internal dispatch checks. */
309
+ function collectSessionCallbacks(ctx, args) {
310
+ return [...ctx.events.dispatch('emit', args)];
311
+ }
312
+ /** Invoke one resolved observe-only listener snapshot with per-listener containment. */
313
+ function invokeContainedSessionObservers(ctx, name, id, args, callbacks) {
314
+ for (const callback of callbacks) {
315
+ try {
316
+ const returned = callback(...args);
317
+ void Promise.resolve(returned).catch((error) => {
318
+ ctx.logger.warn(`session "${id}": ${name} listener rejected: ${String(error)}`);
319
+ });
320
+ }
321
+ catch (error) {
322
+ ctx.logger.warn(`session "${id}": ${name} listener threw: ${String(error)}`);
323
+ }
324
+ }
325
+ }
326
+ /** Store attachment for the append path; module-private to keep Session store-agnostic publicly. */
327
+ const attachments = new WeakMap();
328
+ /**
329
+ * An event-sourced session: an append-only log of {@link SessionEvent}s.
330
+ *
331
+ * Plain class (not a Service) — create live instances via
332
+ * `ctx.sessions.create()` and detached instances via {@link create}.
333
+ * Seeding with an existing event log replays/forks a session.
334
+ * @typert object
335
+ */
336
+ export class Session {
337
+ log = [];
338
+ /** Single incremental owner of surface acceptance and projection state. */
339
+ surfaceManager = new SurfaceManager(this.log);
340
+ /** The ordered surface over this session's event log. */
341
+ get surface() {
342
+ return this.surfaceManager;
343
+ }
344
+ /**
345
+ * Detached, deep-frozen creation metadata (format version, cwd, lineage,
346
+ * seed boundary). Supplied by the store via `ctx.sessions.create()`. When a
347
+ * `Session` is created without a store-owned header, a minimal header is
348
+ * synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so
349
+ * `session.header` is always present. Kept out of the event log — it is a
350
+ * storage concern, not replayable conversation state.
351
+ */
352
+ header;
353
+ /** The session identity, derived from its durable header's single copy. */
354
+ get id() {
355
+ return this.header.id;
356
+ }
357
+ /**
358
+ * The first seq appended IN THIS PROCESS: the length of the constructor
359
+ * seed (0 without one). Events with smaller seq values entered through
360
+ * construction — replay, fork, or resume — and were never published on the
361
+ * `session/event` firehose (constructor seeds do not emit), so consumers
362
+ * that replay the log as a publication substitute (telemetry adoption)
363
+ * start here. Distinct from `header.seedLength`, the DURABLE fork-lineage
364
+ * boundary: a resumed session's constructor seed is its full stored log,
365
+ * while its header keeps the original fork value — this field is the
366
+ * in-process construction fact.
367
+ *
368
+ * Not persisted itself: a seeded session projects it into the log as the
369
+ * `session/end-seed` event, which is what a consumer reading STORED history
370
+ * reads. Locate the LAST such event, not necessarily one at this seq — a
371
+ * seed already ending in one is not re-marked, so reopening an untouched
372
+ * session leaves that event at a smaller seq than `firstLiveSeq`. Prefer
373
+ * this field in-process: it is exact before the marker reaches storage.
374
+ *
375
+ * When this lifecycle appends the marker, it occupies this seq before the
376
+ * store attaches and therefore does not publish either. Otherwise this seq
377
+ * holds an ordinary published write.
378
+ */
379
+ firstLiveSeq;
380
+ /**
381
+ * Create a detached session by validating and snapshotting borrowed seed
382
+ * events and storage metadata.
383
+ * @param id - session identity.
384
+ * @param seed - optional borrowed replay or fork events.
385
+ * @param header - optional borrowed storage metadata.
386
+ * @returns a detached session.
387
+ */
388
+ static create(id, seed, header) {
389
+ return new Session(id, seed, header);
390
+ }
391
+ /**
392
+ * Restore a detached session by taking ownership of fresh persistence values.
393
+ * The storage format, event envelopes, sequence continuity, surface transitions,
394
+ * and header fields are validated before the restored objects are frozen.
395
+ * @param id - restored session identity.
396
+ * @param seed - fresh detached events whose ownership is transferred.
397
+ * @param header - fresh detached metadata whose ownership is transferred.
398
+ * @returns a restored detached session.
399
+ */
400
+ static fromRestore(id, seed, header) {
401
+ return new Session(id, seed, header, 'restore');
402
+ }
403
+ constructor(id, seed, header, mode = 'snapshot') {
404
+ const restoredHeader = mode === 'restore'
405
+ ? validateRestoredSessionHeader(id, header)
406
+ : undefined;
407
+ if (seed !== undefined) {
408
+ // Validate the seed to the SAME invariants `append` enforces, so a
409
+ // replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
410
+ // live log that no persistence backend could store: each event's `data`
411
+ // must be JSON-serializable, and `seq` must be contiguous from 0 (the
412
+ // `seq = log.length` contract the whole system relies on). Without this,
413
+ // a bad seed would surface only later as a backend rejection or a silent
414
+ // divergence between the live log and disk.
415
+ for (const [index, source] of seed.entries()) {
416
+ // The seed is a persistence/replay boundary: validate and detach the
417
+ // complete event in one lossless-JSON pass.
418
+ const snapshot = mode === 'restore' ? source : snapshotJsonValue(source);
419
+ if (snapshot === undefined) {
420
+ throw new Error(`seed event at index ${index} is not losslessly JSON-serializable`);
421
+ }
422
+ assertSessionEventEnvelope(snapshot, index);
423
+ assertSupportedRequestHeader(snapshot.type, snapshot.data, `seed event at index ${index}`);
424
+ if (snapshot.seq !== index) {
425
+ throw new Error(`seed event at index ${index} has seq ${snapshot.seq} (expected ${index}); seed must be contiguous from 0`);
426
+ }
427
+ // A seed is accepted incrementally through the same transition as a
428
+ // live append and a full-log fold. The candidate is planned before it
429
+ // enters `log`, so a failure cannot partially mutate the surface.
430
+ try {
431
+ this.surfaceManager.validateNext(snapshot);
432
+ }
433
+ catch (error) {
434
+ throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`);
435
+ }
436
+ this.log.push(mode === 'restore' ? freezeRestoredObject(snapshot) : deepFreeze(snapshot));
437
+ }
438
+ }
439
+ this.firstLiveSeq = this.log.length;
440
+ this.header = restoredHeader ?? snapshotSessionHeader(id, header);
441
+ // Appended here so the marker is already in `events` when a backend
442
+ // captures the creation seed: no load-time write. Re-marking is skipped
443
+ // because a cold session is resumed on first touch, so repeatedly opening
444
+ // one must not grow its log per open.
445
+ if (seed !== undefined && this.log.at(-1)?.type !== 'session/end-seed') {
446
+ this.append('session/end-seed', {});
447
+ }
448
+ }
449
+ /** Cached immutable public snapshot of the private append-only log. */
450
+ eventsSnapshot;
451
+ /**
452
+ * An immutable snapshot of the append-only event log. The snapshot is reused
453
+ * until the next append; a previously returned array does not grow later.
454
+ * Events and their nested data are deep-frozen at acceptance, so neither a
455
+ * cast nor ordinary JavaScript can rewrite durable history.
456
+ */
457
+ get events() {
458
+ this.eventsSnapshot ??= Object.freeze([...this.log]);
459
+ return this.eventsSnapshot;
460
+ }
461
+ /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */
462
+ get seq() {
463
+ return this.log.length;
464
+ }
465
+ /**
466
+ * Append one typed event to the log and synchronously notify observers via
467
+ * the store-owned, module-private publication hooks. The hot path never blocks
468
+ * on I/O — persistence plugins buffer asynchronously. Once the event enters
469
+ * the log, the append is committed: observer failures are logged and
470
+ * contained per listener, so they do not change the return value or prevent
471
+ * later listeners from observing the same accepted event.
472
+ *
473
+ * @param type - The event type (key of {@link SessionEventMap}).
474
+ * @param data - The event payload; must be JSON-serializable.
475
+ * @param opts - Surface metadata: `surfaceOp` controls how the event enters
476
+ * the ordered surface; `sourceEventSeqs` lists the seq numbers of earlier
477
+ * events this one derives from. REQUIRED for
478
+ * {@link SurfaceEventType} events (every message-producing event must
479
+ * declare how it joins the surface, the sole source of derived model
480
+ * history) and
481
+ * rejected by the compiler for non-surface types like `turn/start` or
482
+ * `assistant/chunk`.
483
+ * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
484
+ * `data` that entered the log, so reading `event.data` back sees the logged
485
+ * value, never the caller's still-mutable input.
486
+ * @throws if `data` or surface metadata is not losslessly JSON-serializable
487
+ * (BigInt, function, symbol, undefined, negative zero, non-finite number,
488
+ * circular reference, sparse array, or an exotic object such as
489
+ * Map/Set/Date/class instance), or when the candidate violates the
490
+ * canonical surface contract (marker shape and eligibility, unique
491
+ * earlier source-event references, positional replacement validity, and complete
492
+ * shadowed-node coverage). One recursive pass reads, validates, and
493
+ * copies each nested value once, so a stateful getter cannot supply one value
494
+ * to validation and another to storage. The event log is the durable source
495
+ * of truth, so a bad event fails at the append site rather than later during
496
+ * a backend flush. A synchronous internal dispatch validation failure or an
497
+ * append reentered while this acceptance/publication boundary is open also
498
+ * rejects before the log changes.
499
+ */
500
+ append(type, data, ...opts) {
501
+ const surfaceOpts = opts[0];
502
+ const surfaceMetadata = {
503
+ ...surfaceOpts?.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: surfaceOpts.sourceEventSeqs },
504
+ ...surfaceOpts?.surfaceOp === undefined ? {} : { surfaceOp: surfaceOpts.surfaceOp },
505
+ };
506
+ const dataSnapshot = snapshotJsonValue(data);
507
+ if (dataSnapshot === undefined) {
508
+ throw new Error(`session event "${type}" carries non-JSON-serializable data`);
509
+ }
510
+ assertSupportedRequestHeader(type, dataSnapshot, `session event "${type}"`);
511
+ const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata);
512
+ if (surfaceMetadataSnapshot === undefined) {
513
+ throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`);
514
+ }
515
+ const entry = attachments.get(this);
516
+ if (entry?.appending) {
517
+ throw new Error('session append cannot reenter while another append is being published');
518
+ }
519
+ const event = deepFreeze({
520
+ type,
521
+ seq: this.log.length,
522
+ time: Date.now(),
523
+ data: dataSnapshot,
524
+ ...surfaceMetadataSnapshot,
525
+ });
526
+ this.surfaceManager.validateNext(event);
527
+ if (entry !== undefined)
528
+ entry.appending = true;
529
+ try {
530
+ let callbacks;
531
+ const callbackArgs = [this, event];
532
+ if (entry !== undefined) {
533
+ callbacks = collectSessionCallbacks(entry.emitCtx, [entry.carrier, 'session/event', ...callbackArgs]);
534
+ }
535
+ this.log.push(event);
536
+ this.eventsSnapshot = undefined;
537
+ if (callbacks !== undefined && entry !== undefined) {
538
+ invokeContainedSessionObservers(entry.emitCtx, 'session/event', entry.id, callbackArgs, callbacks);
539
+ }
540
+ return event;
541
+ }
542
+ finally {
543
+ if (entry !== undefined) {
544
+ entry.appending = false;
545
+ if (entry.detachRequested && !entry.announcing)
546
+ entry.detach();
547
+ }
548
+ }
549
+ }
550
+ /** Cached fold of the request-header events — see {@link requestHeader}. */
551
+ headerFold;
552
+ /** Log position (events consumed) the header fold has reached. */
553
+ headerFoldSeq = 0;
554
+ /**
555
+ * The {@link EpochHeader} in force after the log's last header event — the
556
+ * header the NEXT request will be compared against — or undefined before
557
+ * the first `request/header` snapshot. The live, incrementally-maintained
558
+ * form of `foldRequestHeader(session.events)`: each header event is folded
559
+ * once, when first seen, so a per-step read costs O(new events).
560
+ * @returns the folded header, or undefined when no header event exists yet.
561
+ */
562
+ requestHeader() {
563
+ if (this.headerFoldSeq < this.log.length) {
564
+ // Frozen on update: the fold is session state exposed by reference — a
565
+ // consumer mutating it in place (instead of building a replacement)
566
+ // would desync every later comparison against the log, so mutation
567
+ // throws instead.
568
+ this.headerFold = deepFreeze(foldRequestHeader(this.log.slice(this.headerFoldSeq), this.headerFold));
569
+ this.headerFoldSeq = this.log.length;
570
+ }
571
+ return this.headerFold;
572
+ }
573
+ /** Cached fold of `request/context` events. */
574
+ contextFold;
575
+ contextFoldSeq = 0;
576
+ /**
577
+ * Return the latest resolved route metadata, or `undefined` before the first
578
+ * `request/context` event. Each event is folded once.
579
+ * @returns the latest immutable route metadata.
580
+ */
581
+ requestContext() {
582
+ if (this.contextFoldSeq < this.log.length) {
583
+ for (const event of this.log.slice(this.contextFoldSeq)) {
584
+ if (event.type === 'request/context')
585
+ this.contextFold = deepFreeze({ ...event.data });
586
+ }
587
+ this.contextFoldSeq = this.log.length;
588
+ }
589
+ return this.contextFold;
590
+ }
591
+ /** The derived-message cache: frozen projections, extended per unseen node. */
592
+ derived = [];
593
+ /** Surface position (nodes projected) the cache has reached. */
594
+ derivedNodes = 0;
595
+ /** {@link SurfaceManager.replaceGeneration} the cache was built under. */
596
+ derivedGeneration = 0;
597
+ /**
598
+ * Derive the LLM message history by walking the ordered sequences of
599
+ * message-producing events maintained by `surfaceOp` markers. The
600
+ * surface is the single source of derived history: every message-producing
601
+ * append records its `surfaceOp`, so a raw event with no marker (a chunk, a
602
+ * turn boundary) is correctly absent, and a compaction `replace` deletes the
603
+ * shadowed nodes from the derivation. The projection rules are
604
+ * {@link deriveEventMessage}, folded per node.
605
+ *
606
+ * CACHED: each surface node is projected exactly once, when first seen — a
607
+ * call costs O(new nodes), and a surface rewrite (a `replace`;
608
+ * {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is
609
+ * a fresh snapshot per call (later appends never grow an array a caller
610
+ * already holds); the `Message` objects in it are SHARED and **deep-frozen**.
611
+ * Their content reuses the already frozen durable event data, so the cache
612
+ * needs no second deep clone and consumers still cannot mutate the log.
613
+ * @returns a fresh array of the shared, frozen derived history.
614
+ */
615
+ deriveMessages() {
616
+ const surface = this.surface;
617
+ const nodes = surface.nodes;
618
+ const generation = surface.replaceGeneration;
619
+ if (generation !== this.derivedGeneration) {
620
+ this.derived = [];
621
+ this.derivedNodes = 0;
622
+ this.derivedGeneration = generation;
623
+ }
624
+ for (const seq of nodes.slice(this.derivedNodes)) {
625
+ // Surface sequences are built from this.log — seq is always a valid
626
+ // index by construction. The non-null assertion expresses that invariant.
627
+ // oxlint-disable-next-line typescript/no-non-null-assertion
628
+ const msg = this.deriveEventMessage(this.log[seq]);
629
+ // A surface node is one of the five message-producing types, but an
630
+ // empty-content assistant/message (a max-tokens step that hosts only
631
+ // usage) derives to null and must not enter the transcript.
632
+ if (msg)
633
+ this.derived.push(msg);
634
+ }
635
+ this.derivedNodes = nodes.length;
636
+ return [...this.derived];
637
+ }
638
+ /**
639
+ * Instance face of the pure per-node `deriveEventMessage` export from
640
+ * `surface.ts`.
641
+ * @param event - the event to project.
642
+ * @returns the derived message, or null when the event produces none.
643
+ */
644
+ deriveEventMessage(event) {
645
+ return deriveEventMessage(event);
646
+ }
647
+ }
648
+ /** Typed error for session fork rejections. */
649
+ export class SessionForkError extends Error {
650
+ code;
651
+ constructor(message, code) {
652
+ super(message);
653
+ this.code = code;
654
+ this.name = 'SessionForkError';
655
+ }
656
+ }
657
+ /**
658
+ * In-memory session store (`ctx.sessions`).
659
+ *
660
+ * Persistence is intentionally not implemented here — persistence plugins
661
+ * subscribe to `session/event` and flush on `session/flush` / dispose.
662
+ */
663
+ export class SessionStore extends Service {
664
+ store = new Map();
665
+ counter = 0;
666
+ constructor(ctx) {
667
+ super(ctx, 'sessions');
668
+ ctx.inject(['typert'], (typeCtx) => {
669
+ typeCtx.typert.lookups.register('session', {
670
+ parameter: 'session',
671
+ wire: 'sessionId',
672
+ hostTypeSymbol: '@deepseek-ai/dsh-session#Session',
673
+ wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
674
+ resolve: sessionId => this.get(sessionId),
675
+ });
676
+ });
677
+ }
678
+ /**
679
+ * Create a session owned by the calling fiber: disposing that fiber stops
680
+ * event notification and removes the session from the store. `options.seed`
681
+ * populates the session with a copy of those events (replay/fork);
682
+ * `options.meta` attaches creation metadata (validated absolute `cwd`, seed
683
+ * and parent lineage, and delegation depth) as the immutable
684
+ * {@link SessionHeader} (the store fills `version`/`id`/`createdAt`).
685
+ *
686
+ * For an agent whose session must be torn down IN ORDER with its loop (so the
687
+ * loop's final events are published before the store attachment ends), do NOT use this
688
+ * — fold the session lifecycle into the agent's own effect via
689
+ * {@link prepare} + {@link enter} + {@link announce} (see
690
+ * `dsh-agent-loop`'s creation transaction).
691
+ *
692
+ * @param id - the session id; omitted, the store mints `session-<n>`.
693
+ * @param options - seed events and/or creation metadata for the header.
694
+ * @returns the live session, already entered and announced.
695
+ * @throws if a session with `id` already exists, metadata is not a plain
696
+ * lossless-JSON record with valid scalar fields, or `meta.cwd` is a
697
+ * non-absolute path (storage backends key directories off it).
698
+ */
699
+ create(id, options) {
700
+ const session = this.prepare(id, options);
701
+ // Single effect owned by the calling fiber. Yield the detach BEFORE
702
+ // announcing so a throwing `session/created` listener rolls the attach back
703
+ // (the generator effect disposes already-yielded disposers on a throw)
704
+ // instead of leaking the store entry and its publication hooks.
705
+ this.ctx.effect(function* () {
706
+ yield this.enter(session);
707
+ this.announce(session);
708
+ }.bind(this), 'sessions.create()');
709
+ return session;
710
+ }
711
+ /**
712
+ * Build a session WITHOUT entering it into the store — validate the id/cwd and
713
+ * construct the {@link Session} (with its immutable {@link SessionHeader}).
714
+ * Pairs with {@link enter} + {@link announce}: a caller that owns a composite
715
+ * `ctx.effect` (the agent factory) folds the session lifecycle into that ONE
716
+ * effect so a fiber unload tears the session + agent down as a single ORDERED
717
+ * chain rather than as racing sibling effects — which would remove the publication hooks
718
+ * before the driver's closing events commit, dropping them.
719
+ *
720
+ * @param id - the session id; omitted, the store mints `session-<n>`.
721
+ * @param options - seed events and/or creation metadata for the header. With
722
+ * `seedSource: 'persistence'`, metadata and events must be fresh detached
723
+ * graphs whose ownership transfers to this call: they are validated and
724
+ * frozen in place through {@link Session.fromRestore}, so the caller must
725
+ * retain no mutable aliases.
726
+ * @returns the constructed session, NOT yet in the store.
727
+ * @throws if a session with `id` already exists, metadata is not a plain
728
+ * lossless-JSON record with valid scalar fields, or `meta.cwd` is a
729
+ * non-absolute path.
730
+ */
731
+ prepare(id, options) {
732
+ let sessionId;
733
+ if (id === undefined) {
734
+ do
735
+ sessionId = SessionId(`session-${++this.counter}`);
736
+ while (this.store.has(sessionId));
737
+ }
738
+ else {
739
+ sessionId = SessionId(id);
740
+ }
741
+ if (this.store.has(sessionId))
742
+ throw new Error(`session "${sessionId}" already exists`);
743
+ if (options?.seedSource === 'persistence') {
744
+ return Session.fromRestore(sessionId, options.seed, options.meta);
745
+ }
746
+ const seed = options?.seed;
747
+ const meta = options?.meta;
748
+ const header = {
749
+ version: SESSION_FORMAT_VERSION,
750
+ id: sessionId,
751
+ createdAt: meta?.createdAt ?? Date.now(),
752
+ ...meta?.cwd === undefined ? {} : { cwd: meta.cwd },
753
+ ...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession },
754
+ ...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength },
755
+ ...meta?.origin === undefined ? {} : { origin: meta.origin },
756
+ ...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth },
757
+ ...meta?.agentPreset === undefined ? {} : { agentPreset: meta.agentPreset },
758
+ };
759
+ return Session.create(sessionId, seed, header);
760
+ }
761
+ /**
762
+ * Enter a {@link prepare}d session into the store: install the module-private
763
+ * append publication hooks and add it to the store. Returns the DETACH
764
+ * disposer (hooks + store removal). Does NOT emit `session/created` —
765
+ * the caller yields this disposer inside its effect and THEN calls
766
+ * {@link announce}, so a throwing `session/created` listener rolls the attach
767
+ * back instead of leaking it.
768
+ *
769
+ * Re-checks the id for a duplicate: `prepare` and `enter` are public
770
+ * cross-package primitives and a caller may interleave arbitrary work (or
771
+ * another create) between them, so a stale prepared session must NOT overwrite
772
+ * a live store entry of the same id — its detach disposer would later delete
773
+ * the REAL session. The {@link create} convenience and the agent factory call
774
+ * the two back-to-back so they never trip this, but the public API cannot
775
+ * assume that.
776
+ *
777
+ * @param session - a {@link prepare}d session not yet in the store.
778
+ * @returns the detach disposer (publication hooks + store removal). When called from
779
+ * a synchronous `session/created` listener, removal and disposal wait until
780
+ * that creation dispatch unwinds.
781
+ * @throws if a session with this id is already in the store.
782
+ */
783
+ enter(session) {
784
+ const id = session.id;
785
+ const carrier = scopeTarget(session, scopeOf(this.ctx));
786
+ // This is the authoritative collision boundary after arbitrary unpublished
787
+ // preparation. Only one exact same-id transaction can publish.
788
+ if (this.store.has(id))
789
+ throw new Error(`session "${id}" already exists`);
790
+ if (attachments.has(session))
791
+ throw new Error(`session "${id}" is already attached to a store`);
792
+ const entry = {
793
+ id,
794
+ session,
795
+ carrier,
796
+ emitCtx: this.ctx,
797
+ announced: false,
798
+ announcing: false,
799
+ appending: false,
800
+ detachRequested: false,
801
+ detach: () => { this.detachEntered(entry); },
802
+ };
803
+ this.store.set(id, entry);
804
+ attachments.set(session, entry);
805
+ let entered = true;
806
+ const detach = () => {
807
+ if (!entered)
808
+ return;
809
+ entered = false;
810
+ // A lifecycle listener may own the advanced detach capability. Keep the
811
+ // entry and its publication hooks live until synchronous creation or append
812
+ // publication unwinds, then publish the paired disposal edge.
813
+ if (entry.announcing || entry.appending) {
814
+ entry.detachRequested = true;
815
+ return;
816
+ }
817
+ entry.detach();
818
+ };
819
+ return detach;
820
+ }
821
+ /** Remove one exact entered session and emit its paired disposal when announced. */
822
+ detachEntered(entry) {
823
+ entry.detachRequested = false;
824
+ // A stale capability cannot remove observers or storage belonging to a
825
+ // later same-id lifecycle.
826
+ /* v8 ignore next -- enter() rejects replacement while this single-shot detach capability is live. */
827
+ if (this.store.get(entry.id) !== entry)
828
+ return;
829
+ this.store.delete(entry.id);
830
+ attachments.delete(entry.session);
831
+ if (entry.announced)
832
+ this.emitDisposed(entry);
833
+ }
834
+ /** Emit `session/created` exactly once for an {@link enter}ed session (with
835
+ * the carrier {@link enter} captured). Separate from {@link enter} so the
836
+ * caller can yield the detach disposer first (rollback safety — see
837
+ * {@link enter}).
838
+ * @param session - the entered session to announce to listeners.
839
+ * @throws if the session is not live or its announcement already began,
840
+ * including a reentrant call from a creation listener. */
841
+ announce(session) {
842
+ const entry = this.liveEntryFor(session);
843
+ if (entry.announced || entry.announcing) {
844
+ throw new Error(`session "${entry.id}" was already announced`);
845
+ }
846
+ // Mark before emit: Cordis emit may deliver to earlier listeners and then
847
+ // throw. Rollback must still pair that partial creation with disposal, and
848
+ // a listener cannot recursively create a second lifecycle edge.
849
+ entry.announced = true;
850
+ const callbackArgs = [session];
851
+ entry.announcing = true;
852
+ try {
853
+ const callbacks = collectSessionCallbacks(this.ctx, [entry.carrier, 'session/created', session]);
854
+ for (const callback of callbacks) {
855
+ // Synchronous throws intentionally propagate and veto publication; the
856
+ // yielded detach then emits the paired disposal edge. An async function
857
+ // is nevertheless assignable to a void listener, so observe its returned
858
+ // promise: rejection is too late to roll back and must be logged instead
859
+ // of becoming unhandled.
860
+ const returned = callback(...callbackArgs);
861
+ void Promise.resolve(returned).catch((error) => {
862
+ this.ctx.logger.warn(`session "${entry.id}": session/created listener rejected: ${String(error)}`);
863
+ });
864
+ }
865
+ }
866
+ finally {
867
+ entry.announcing = false;
868
+ if (entry.detachRequested && !entry.appending)
869
+ entry.detach();
870
+ }
871
+ }
872
+ /** Emit the paired teardown notification with per-listener containment. */
873
+ emitDisposed(entry) {
874
+ const callbackArgs = [entry.session];
875
+ try {
876
+ const callbacks = collectSessionCallbacks(this.ctx, [entry.carrier, 'session/disposed', entry.session]);
877
+ invokeContainedSessionObservers(this.ctx, 'session/disposed', entry.id, callbackArgs, callbacks);
878
+ }
879
+ catch (error) {
880
+ this.ctx.logger.warn(`session "${entry.id}": session/disposed dispatch threw: ${String(error)}`);
881
+ }
882
+ }
883
+ /**
884
+ * Dispatch the awaited `session/flush` durability checkpoint for `session`,
885
+ * with the carrier captured at {@link enter}. THE flush entry point: the
886
+ * store owns the carrier, so callers (the checkpoint policy's per-request
887
+ * barrier, goal-session's idle checkpoint, teardown drains, and consumers
888
+ * that flush themselves before reading storage) must come through here
889
+ * rather than dispatch a raw `ctx.parallel('session/flush', …)` — one owner,
890
+ * one spelling, and the scoped-dispatch invariant can pin it.
891
+ * @param session - the session whose buffered events must reach durable storage.
892
+ * @returns whether at least one durability listener participated, after every
893
+ * listener has settled successfully.
894
+ * @throws the first registered listener failure after every listener settles.
895
+ */
896
+ async flush(session) {
897
+ const { carrier } = this.liveEntryFor(session);
898
+ const callbackArgs = [session];
899
+ const callbacks = collectSessionCallbacks(this.ctx, [carrier, 'session/flush', session]);
900
+ const results = await Promise.allSettled(callbacks.map((callback) => {
901
+ try {
902
+ return callback(...callbackArgs);
903
+ }
904
+ catch (error) {
905
+ // Preserve the listener's exact rejection value; flush is a caller-owned
906
+ // failure boundary, and Cordis listeners may throw arbitrary values.
907
+ // oxlint-disable-next-line typescript/prefer-promise-reject-errors
908
+ return Promise.reject(error);
909
+ }
910
+ }));
911
+ const failure = results.find((result) => result.status === 'rejected');
912
+ if (failure !== undefined)
913
+ throw failure.reason;
914
+ return callbacks.length > 0;
915
+ }
916
+ /** Return the exact live entry; detached/prepared objects reject. */
917
+ liveEntryFor(session) {
918
+ const entry = attachments.get(session);
919
+ if (entry === undefined || this.store.get(entry.id) !== entry) {
920
+ throw new Error(`session "${session.id}" is not live in this store`);
921
+ }
922
+ return entry;
923
+ }
924
+ /**
925
+ * Look up a live session.
926
+ * @param id - the session id to look up.
927
+ * @returns the session, or undefined when no live session has that id.
928
+ */
929
+ get(id) {
930
+ return this.store.get(id)?.session;
931
+ }
932
+ /**
933
+ * All live sessions, in creation order.
934
+ * @returns a fresh array; mutating it does not affect the store.
935
+ */
936
+ list() {
937
+ return [...this.store.values()].map(entry => entry.session);
938
+ }
939
+ /**
940
+ * Create a live child session from a stable prefix of a live source.
941
+ * `boundary` is an inclusive source event seq; omitted means the source's
942
+ * current last event. The selected slice may end with a between-turn event
943
+ * but must not end inside an open turn.
944
+ *
945
+ * @param source - Live source session object or id.
946
+ * @param boundary - Inclusive source event seq to fork through; omitted means
947
+ * the source's current last event, and omitted on an empty source forks an
948
+ * empty child.
949
+ * @param childSessionId - Optional child session id; omitted delegates to
950
+ * `SessionStore`'s id policy.
951
+ * @returns The created live child session.
952
+ */
953
+ fork(source, boundary, childSessionId) {
954
+ if (childSessionId !== undefined && this.get(childSessionId) !== undefined) {
955
+ throw new SessionForkError(`session "${childSessionId}" already exists`, 'SESSION_ALREADY_EXISTS');
956
+ }
957
+ const liveSource = this._resolveForkSource(source);
958
+ const seed = this._forkSeed(liveSource, boundary);
959
+ return this.create(childSessionId, {
960
+ seed,
961
+ meta: {
962
+ ...liveSource.header.cwd !== undefined ? { cwd: liveSource.header.cwd } : {},
963
+ parentSession: liveSource.id,
964
+ seedLength: seed.length,
965
+ },
966
+ });
967
+ }
968
+ _forkSeed(session, requestedBoundary) {
969
+ const events = session.events;
970
+ const lastEvent = events.at(-1);
971
+ let boundary;
972
+ if (requestedBoundary !== undefined) {
973
+ boundary = requestedBoundary;
974
+ }
975
+ else {
976
+ if (lastEvent === undefined)
977
+ return [];
978
+ boundary = lastEvent.seq;
979
+ }
980
+ if (!Number.isSafeInteger(boundary) || boundary < 0) {
981
+ throw new SessionForkError(`fork boundary for session "${session.id}" must be a non-negative safe integer, got ${String(boundary)}`, 'INVALID_BOUNDARY');
982
+ }
983
+ if (boundary >= events.length) {
984
+ const lastSeq = events.at(-1)?.seq;
985
+ throw new SessionForkError(`fork boundary ${boundary} does not exist in session "${session.id}" (last seq: ${lastSeq ?? 'none'})`, 'INVALID_BOUNDARY');
986
+ }
987
+ const boundaryEvent = events[boundary];
988
+ if (boundaryEvent === undefined || boundaryEvent.seq !== boundary) {
989
+ throw new SessionForkError(`fork boundary ${boundary} does not match a contiguous event seq in session "${session.id}"`, 'INVALID_BOUNDARY');
990
+ }
991
+ const lastTurnBoundary = events.slice(0, boundary + 1)
992
+ .findLast(event => event.type === 'turn/start' || event.type === 'turn/end');
993
+ if (lastTurnBoundary?.type === 'turn/start') {
994
+ throw new SessionForkError(`fork boundary ${boundary} in session "${session.id}" ends inside open turn ${lastTurnBoundary.data.turn}`, 'OPEN_TURN');
995
+ }
996
+ return events.slice(0, boundary + 1);
997
+ }
998
+ _resolveForkSource(source) {
999
+ if (typeof source === 'string') {
1000
+ const session = this.get(source);
1001
+ if (session === undefined)
1002
+ throw new SessionForkError(`session "${source}" not found`, 'SESSION_NOT_FOUND');
1003
+ return session;
1004
+ }
1005
+ const live = this.get(source.id);
1006
+ if (live === undefined) {
1007
+ throw new SessionForkError(`session "${source.id}" not found`, 'SESSION_NOT_FOUND');
1008
+ }
1009
+ if (live !== source)
1010
+ throw new SessionForkError(`session "${source.id}" is not the live store instance`, 'SESSION_NOT_LIVE');
1011
+ return source;
1012
+ }
1013
+ }
1014
+ export default SessionStore;
1015
+ //# sourceMappingURL=index.js.map