@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.
- package/LICENSE +28 -0
- package/README.i18n.yaml +6 -0
- package/README.md +143 -0
- package/README.zh.md +143 -0
- package/lib/index.js +1841 -0
- package/lib/invariant.js +168 -0
- package/lib/types/chunk-rows.d.ts +92 -0
- package/lib/types/chunk-rows.js +301 -0
- package/lib/types/index.d.ts +424 -0
- package/lib/types/index.js +1015 -0
- package/lib/types/invariant.d.ts +18 -0
- package/lib/types/invariant.js +199 -0
- package/lib/types/json.d.ts +36 -0
- package/lib/types/json.js +174 -0
- package/lib/types/preparation.d.ts +33 -0
- package/lib/types/preparation.js +37 -0
- package/lib/types/repair.d.ts +38 -0
- package/lib/types/repair.js +144 -0
- package/lib/types/request-header.d.ts +35 -0
- package/lib/types/request-header.js +65 -0
- package/lib/types/surface.d.ts +123 -0
- package/lib/types/surface.js +377 -0
- package/lib/types/types.d.ts +426 -0
- package/lib/types/types.js +18 -0
- package/package.json +60 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1841 @@
|
|
|
1
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
2
|
+
import { isAbsolute } from "node:path";
|
|
3
|
+
import { CallId, MessageId, assertNever, callConfigEquals, deepFreeze, freezeMessage } from "@deepseek-ai/dsh-llm";
|
|
4
|
+
import { scopeOf, scopeTarget } from "@deepseek-ai/dsh-scope";
|
|
5
|
+
//#region lib/types/types.js
|
|
6
|
+
/**
|
|
7
|
+
* Brand a string as a {@link SessionId}.
|
|
8
|
+
* @param id - the raw session id string.
|
|
9
|
+
* @returns the same string, branded (a compile-time cast — no runtime cost).
|
|
10
|
+
*/
|
|
11
|
+
function SessionId(id) {
|
|
12
|
+
return id;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The on-disk session format version, stamped into every newly-written {@link SessionHeader}
|
|
16
|
+
* and enforced by every persistence backend on load. The single source of truth for the
|
|
17
|
+
* version — write sites and the load-time check all read it.
|
|
18
|
+
* While the harness is unreleased it is pinned at `0`: no compatibility is
|
|
19
|
+
* implied, incompatible logs are rejected, and no migration is provided. A
|
|
20
|
+
* monotonic version policy starts with the first tagged release.
|
|
21
|
+
*/
|
|
22
|
+
const SESSION_FORMAT_VERSION = 0;
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region lib/types/json.js
|
|
25
|
+
/** Lossless-JSON validation and detached snapshots for durable session data. @module @deepseek-ai/dsh-session/json */
|
|
26
|
+
/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */
|
|
27
|
+
function hasIntrinsicConstructor(prototype, name) {
|
|
28
|
+
const constructor = Object.getOwnPropertyDescriptor(prototype, "constructor")?.value;
|
|
29
|
+
if (typeof constructor !== "function") return false;
|
|
30
|
+
try {
|
|
31
|
+
return constructor.name === name && constructor.prototype === prototype && Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Whether a candidate is one realm's intrinsic `Object.prototype`. */
|
|
37
|
+
function isIntrinsicObjectPrototype(value) {
|
|
38
|
+
return Object.getPrototypeOf(value) === null && hasIntrinsicConstructor(value, "Object");
|
|
39
|
+
}
|
|
40
|
+
/** Whether an array uses one realm's intrinsic `Array.prototype`, not a subclass or forged prototype. */
|
|
41
|
+
function hasPlainArrayPrototype(value) {
|
|
42
|
+
const prototype = Object.getPrototypeOf(value);
|
|
43
|
+
if (!Array.isArray(prototype) || !hasIntrinsicConstructor(prototype, "Array")) return false;
|
|
44
|
+
const objectPrototype = Object.getPrototypeOf(prototype);
|
|
45
|
+
return typeof objectPrototype === "object" && objectPrototype !== null && isIntrinsicObjectPrototype(objectPrototype);
|
|
46
|
+
}
|
|
47
|
+
/** Whether an object is a plain or null-prototype record from any JavaScript realm. */
|
|
48
|
+
function hasPlainObjectPrototype(value) {
|
|
49
|
+
const prototype = Object.getPrototypeOf(value);
|
|
50
|
+
return prototype === null || typeof prototype === "object" && isIntrinsicObjectPrototype(prototype);
|
|
51
|
+
}
|
|
52
|
+
/** Return every JSON-visible object key, or reject own data JSON would discard. */
|
|
53
|
+
function enumerableStringKeys(value) {
|
|
54
|
+
const keys = Reflect.ownKeys(value);
|
|
55
|
+
if (keys.some((key) => typeof key !== "string" || !Object.prototype.propertyIsEnumerable.call(value, key))) return void 0;
|
|
56
|
+
return keys;
|
|
57
|
+
}
|
|
58
|
+
/** Validate lossless JSON iteratively, optionally materializing a detached snapshot. */
|
|
59
|
+
function walkJsonValue(value, detach) {
|
|
60
|
+
const ancestors = /* @__PURE__ */ new Set();
|
|
61
|
+
let root;
|
|
62
|
+
const assign = (destination, item) => {
|
|
63
|
+
if (destination === void 0) return;
|
|
64
|
+
if (destination.kind === "root") root = item;
|
|
65
|
+
else if (destination.kind === "array") destination.target[destination.index] = item;
|
|
66
|
+
else Object.defineProperty(destination.target, destination.key, {
|
|
67
|
+
value: item,
|
|
68
|
+
enumerable: true,
|
|
69
|
+
configurable: true,
|
|
70
|
+
writable: true
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
const tasks = [{
|
|
74
|
+
kind: "visit",
|
|
75
|
+
value,
|
|
76
|
+
...detach ? { destination: { kind: "root" } } : {}
|
|
77
|
+
}];
|
|
78
|
+
for (let task = tasks.pop(); task !== void 0; task = tasks.pop()) {
|
|
79
|
+
if (task.kind === "leave") {
|
|
80
|
+
ancestors.delete(task.source);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (task.kind === "array-item") {
|
|
84
|
+
if (!Object.prototype.hasOwnProperty.call(task.source, task.index)) return void 0;
|
|
85
|
+
tasks.push({
|
|
86
|
+
kind: "visit",
|
|
87
|
+
value: task.source[task.index],
|
|
88
|
+
...task.target === void 0 ? {} : { destination: {
|
|
89
|
+
kind: "array",
|
|
90
|
+
target: task.target,
|
|
91
|
+
index: task.index
|
|
92
|
+
} }
|
|
93
|
+
});
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (task.kind === "object-property") {
|
|
97
|
+
tasks.push({
|
|
98
|
+
kind: "visit",
|
|
99
|
+
value: task.source[task.key],
|
|
100
|
+
...task.target === void 0 ? {} : { destination: {
|
|
101
|
+
kind: "object",
|
|
102
|
+
target: task.target,
|
|
103
|
+
key: task.key
|
|
104
|
+
} }
|
|
105
|
+
});
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const current = task.value;
|
|
109
|
+
if (current === null) {
|
|
110
|
+
assign(task.destination, null);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (typeof current === "boolean" || typeof current === "string") {
|
|
114
|
+
assign(task.destination, current);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (typeof current === "number") {
|
|
118
|
+
if (!Number.isFinite(current) || Object.is(current, -0)) return void 0;
|
|
119
|
+
assign(task.destination, current);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (typeof current !== "object") return void 0;
|
|
123
|
+
if (ancestors.has(current)) return void 0;
|
|
124
|
+
if (Array.isArray(current)) {
|
|
125
|
+
if (!hasPlainArrayPrototype(current)) return void 0;
|
|
126
|
+
const length = current.length;
|
|
127
|
+
if (Reflect.ownKeys(current).length !== length + 1) return void 0;
|
|
128
|
+
const target = detach ? [] : void 0;
|
|
129
|
+
if (target !== void 0) assign(task.destination, target);
|
|
130
|
+
ancestors.add(current);
|
|
131
|
+
tasks.push({
|
|
132
|
+
kind: "leave",
|
|
133
|
+
source: current
|
|
134
|
+
});
|
|
135
|
+
for (let index = length - 1; index >= 0; index--) tasks.push({
|
|
136
|
+
kind: "array-item",
|
|
137
|
+
source: current,
|
|
138
|
+
index,
|
|
139
|
+
...target === void 0 ? {} : { target }
|
|
140
|
+
});
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (!hasPlainObjectPrototype(current)) return void 0;
|
|
144
|
+
const keys = enumerableStringKeys(current);
|
|
145
|
+
if (keys === void 0) return void 0;
|
|
146
|
+
const target = detach ? {} : void 0;
|
|
147
|
+
if (target !== void 0) assign(task.destination, target);
|
|
148
|
+
ancestors.add(current);
|
|
149
|
+
tasks.push({
|
|
150
|
+
kind: "leave",
|
|
151
|
+
source: current
|
|
152
|
+
});
|
|
153
|
+
for (let index = keys.length - 1; index >= 0; index--) {
|
|
154
|
+
const key = keys[index];
|
|
155
|
+
/* v8 ignore next -- the loop is bounded by the captured key count. */
|
|
156
|
+
if (key === void 0) return void 0;
|
|
157
|
+
tasks.push({
|
|
158
|
+
kind: "object-property",
|
|
159
|
+
source: current,
|
|
160
|
+
key,
|
|
161
|
+
...target === void 0 ? {} : { target }
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return detach ? root : true;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Validate and detach lossless JSON in one read per property, so a stateful
|
|
169
|
+
* getter cannot change between validation and copying. Traversal is iterative,
|
|
170
|
+
* so valid nesting is bounded by available memory rather than the JavaScript
|
|
171
|
+
* call stack. Accepts ordinary arrays, plain or null-prototype objects, and JSON
|
|
172
|
+
* scalars; rejects sparse, cyclic, exotic, negative-zero, and non-finite values.
|
|
173
|
+
* Getter throws propagate.
|
|
174
|
+
*
|
|
175
|
+
* @param value - the candidate value to validate and detach.
|
|
176
|
+
* @returns the detached snapshot, or `undefined` when the value is not
|
|
177
|
+
* losslessly JSON-serializable.
|
|
178
|
+
*/
|
|
179
|
+
function snapshotJsonValue(value) {
|
|
180
|
+
return walkJsonValue(value, true);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Test the same lossless JSON boundary as {@link snapshotJsonValue} without
|
|
184
|
+
* detaching it. Only own enumerable string properties participate; `toJSON`
|
|
185
|
+
* is ignored and getters run, so persistence boundaries use the snapshotter.
|
|
186
|
+
* @param value - the candidate event data to test.
|
|
187
|
+
* @returns whether `value` survives JSON round-trip losslessly.
|
|
188
|
+
*/
|
|
189
|
+
function isJsonValue(value) {
|
|
190
|
+
return walkJsonValue(value, false) === true;
|
|
191
|
+
}
|
|
192
|
+
//#endregion
|
|
193
|
+
//#region lib/types/surface.js
|
|
194
|
+
/**
|
|
195
|
+
* Surface layer on top of the session event log: an ordered view of events
|
|
196
|
+
* that produce LLM messages. The append-only log remains the source of truth.
|
|
197
|
+
*
|
|
198
|
+
* Browser-safe: web clients consume this subpath export, so it must stay free
|
|
199
|
+
* of `node:` imports (they break the vite bundle).
|
|
200
|
+
*
|
|
201
|
+
* @module @deepseek-ai/dsh-session/surface
|
|
202
|
+
*/
|
|
203
|
+
/** Runtime counterpart of the message-producing event union. */
|
|
204
|
+
const SURFACE_EVENT_TYPES = new Set([
|
|
205
|
+
"user/message",
|
|
206
|
+
"assistant/message",
|
|
207
|
+
"tool/result"
|
|
208
|
+
]);
|
|
209
|
+
/**
|
|
210
|
+
* Whether an event type can join the model-visible surface.
|
|
211
|
+
* @param type - event type to test.
|
|
212
|
+
* @returns true for one of the three message-producing event types.
|
|
213
|
+
*/
|
|
214
|
+
function isSurfaceEligibleType(type) {
|
|
215
|
+
return SURFACE_EVENT_TYPES.has(type);
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Narrow an event to a surface-eligible event carrying its required marker.
|
|
219
|
+
* @param event - event to test.
|
|
220
|
+
* @returns true when both the type and marker identify a surface event.
|
|
221
|
+
*/
|
|
222
|
+
function isSurfaceEvent(event) {
|
|
223
|
+
if (!SURFACE_EVENT_TYPES.has(event.type)) return false;
|
|
224
|
+
return event.surfaceOp !== void 0;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Narrow an event to an append-origin surface event: one that entered the
|
|
228
|
+
* surface at its own log position and was never itself a replacement copy.
|
|
229
|
+
*
|
|
230
|
+
* The model-visible surface deliberately shadows replaced ranges, so it is the
|
|
231
|
+
* wrong source for a human transcript — a landed replacement would erase
|
|
232
|
+
* conversation the user already saw. Append-origin events are that transcript's
|
|
233
|
+
* durable source material; replacement copies stay model-only.
|
|
234
|
+
* @param event - event to test.
|
|
235
|
+
* @returns true when the event appended to the surface tail.
|
|
236
|
+
*/
|
|
237
|
+
function isAppendSurfaceEvent(event) {
|
|
238
|
+
return isSurfaceEvent(event) && event.surfaceOp === "append";
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Narrow an event to a surface replacement: a node that shadowed an existing
|
|
242
|
+
* surface range instead of appending to the tail. The counterpart of
|
|
243
|
+
* {@link isAppendSurfaceEvent} over the two {@link SurfaceOp} variants.
|
|
244
|
+
* @param event - event to test.
|
|
245
|
+
* @returns true when the event replaced a surface range.
|
|
246
|
+
*/
|
|
247
|
+
function isReplacementSurfaceEvent(event) {
|
|
248
|
+
return isSurfaceEvent(event) && event.surfaceOp !== "append";
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Project a single event into the LLM message it derives to, or null when it
|
|
252
|
+
* produces none — a non-surface event (chunk, boundary, log-only record) or an
|
|
253
|
+
* empty-content assistant/message (which exists only to host usage). This is
|
|
254
|
+
* THE per-node projection rule: `Session.deriveMessages` folds it over the
|
|
255
|
+
* live surface, external reconstructors and pure projections fold the same
|
|
256
|
+
* function over a log prefix's surface to rebuild the exact messages any
|
|
257
|
+
* request was built from. The returned message is the already frozen message
|
|
258
|
+
* nested in the event wrapper and shared by delivery, durable history, and
|
|
259
|
+
* model requests.
|
|
260
|
+
* @param event - the event to project.
|
|
261
|
+
* @returns the derived message, or null when the event produces none.
|
|
262
|
+
*/
|
|
263
|
+
function deriveEventMessage(event) {
|
|
264
|
+
switch (event.type) {
|
|
265
|
+
case "user/message": return event.data;
|
|
266
|
+
case "assistant/message":
|
|
267
|
+
if (event.data.message.content.length === 0) return null;
|
|
268
|
+
return event.data.message;
|
|
269
|
+
case "tool/result": return event.data.message;
|
|
270
|
+
default: return null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/** Create an empty surface fold state. */
|
|
274
|
+
function createFoldState() {
|
|
275
|
+
return {
|
|
276
|
+
nodes: [],
|
|
277
|
+
replaceGeneration: 0
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
/** Whether a runtime value is a non-negative safe event sequence. */
|
|
281
|
+
function isEventSeq(value) {
|
|
282
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
283
|
+
}
|
|
284
|
+
/** Whether a runtime value is the exact positional-replacement shape. */
|
|
285
|
+
function isReplaceOp(value) {
|
|
286
|
+
const op = value;
|
|
287
|
+
return Object.keys(op).length === 3 && Object.hasOwn(op, "op") && Object.hasOwn(op, "start") && Object.hasOwn(op, "end") && op["op"] === "replace" && isEventSeq(op["start"]) && isEventSeq(op["end"]);
|
|
288
|
+
}
|
|
289
|
+
/** Validate event-local surface eligibility and return its operation. */
|
|
290
|
+
function surfaceOpOf(event) {
|
|
291
|
+
const raw = event;
|
|
292
|
+
if (!isSurfaceEligibleType(event.type)) {
|
|
293
|
+
if (raw.surfaceOp !== void 0) throw new Error(`session event "${event.type}" is not surface-eligible and cannot carry surfaceOp`);
|
|
294
|
+
if (raw.sourceEventSeqs !== void 0) throw new Error(`session event "${event.type}" is not surface-eligible and cannot carry sourceEventSeqs`);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const op = raw.surfaceOp;
|
|
298
|
+
if (op === void 0) throw new Error(`session event "${event.type}" is surface-eligible and requires a surfaceOp marker`);
|
|
299
|
+
if (op === "append") return op;
|
|
300
|
+
if (op === null || typeof op !== "object" || Array.isArray(op)) throw new Error(`session event "${event.type}" carries an invalid surfaceOp`);
|
|
301
|
+
if (!isReplaceOp(op)) throw new Error(`session event "${event.type}" carries an invalid replace surfaceOp`);
|
|
302
|
+
return op;
|
|
303
|
+
}
|
|
304
|
+
/** Validate cited source-event seqs against prior log entries and the replacement range. */
|
|
305
|
+
function assertProvenance(event, shadowedSeqs) {
|
|
306
|
+
const raw = event.sourceEventSeqs;
|
|
307
|
+
const sources = /* @__PURE__ */ new Set();
|
|
308
|
+
if (raw !== void 0) {
|
|
309
|
+
if (!Array.isArray(raw)) throw new Error(`sourceEventSeqs on event at seq ${event.seq} must be an array when present`);
|
|
310
|
+
if (raw.length === 0 && event.type !== "assistant/message") throw new Error("sourceEventSeqs must not be empty except on assistant/message");
|
|
311
|
+
let nonEarlierSource;
|
|
312
|
+
for (const source of raw) {
|
|
313
|
+
if (!isEventSeq(source)) throw new Error(`session event "${event.type}" sourceEventSeqs must densely contain non-negative safe integers`);
|
|
314
|
+
sources.add(source);
|
|
315
|
+
if (nonEarlierSource === void 0 && source >= event.seq) nonEarlierSource = source;
|
|
316
|
+
}
|
|
317
|
+
if (sources.size !== raw.length) throw new Error("sourceEventSeqs must not contain duplicates");
|
|
318
|
+
if (nonEarlierSource !== void 0) throw new Error(`sourceEventSeqs must reference earlier events: ${nonEarlierSource} >= current seq ${event.seq}`);
|
|
319
|
+
}
|
|
320
|
+
const missing = shadowedSeqs.filter((seq) => !sources.has(seq));
|
|
321
|
+
if (missing.length > 0) throw new Error(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(", ")}`);
|
|
322
|
+
}
|
|
323
|
+
/** Locate one replacement range without mutating the current fold state. */
|
|
324
|
+
function replacementRange(state, op) {
|
|
325
|
+
const startIdx = state.nodes.indexOf(op.start);
|
|
326
|
+
if (startIdx === -1) throw new Error(`surface replace: start seq ${op.start} not found in surface`);
|
|
327
|
+
const endIdx = state.nodes.indexOf(op.end);
|
|
328
|
+
if (endIdx === -1) throw new Error(`surface replace: end seq ${op.end} not found in surface`);
|
|
329
|
+
if (startIdx > endIdx) throw new Error(`surface replace: start seq ${op.start} (index ${startIdx}) is after end seq ${op.end} (index ${endIdx})`);
|
|
330
|
+
return {
|
|
331
|
+
startIdx,
|
|
332
|
+
endIdx,
|
|
333
|
+
shadowedSeqs: state.nodes.slice(startIdx, endIdx + 1)
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Deep structural equality over the session-event JSON value domain
|
|
338
|
+
* (null/boolean/number/string, arrays, plain objects). Replaces
|
|
339
|
+
* `node:util`'s isDeepStrictEqual to keep this module browser-safe.
|
|
340
|
+
*/
|
|
341
|
+
function isDeepEqualJson(a, b) {
|
|
342
|
+
if (a === b) return true;
|
|
343
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
344
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
345
|
+
return a.every((item, i) => isDeepEqualJson(item, b[i]));
|
|
346
|
+
}
|
|
347
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
348
|
+
const aKeys = Object.keys(a);
|
|
349
|
+
const bRecord = b;
|
|
350
|
+
if (aKeys.length !== Object.keys(b).length) return false;
|
|
351
|
+
return aKeys.every((key) => Object.hasOwn(b, key) && isDeepEqualJson(a[key], bRecord[key]));
|
|
352
|
+
}
|
|
353
|
+
/** Restrict a tool-result replacement to one current result's content. */
|
|
354
|
+
function assertToolResultRewrite(event, shadowedSeqs, events, baseSeq) {
|
|
355
|
+
if (event.type !== "tool/result") return;
|
|
356
|
+
if (shadowedSeqs.length !== 1) throw new Error("tool/result surface replacement must rewrite exactly one current node");
|
|
357
|
+
for (const originalSeq of shadowedSeqs) {
|
|
358
|
+
const original = events[originalSeq - baseSeq];
|
|
359
|
+
if (original?.type !== "tool/result") throw new Error("tool/result surface replacement must target a current tool/result");
|
|
360
|
+
const originalRest = { ...original.data };
|
|
361
|
+
const replacementRest = { ...event.data };
|
|
362
|
+
const originalResult = original.data.message.content[0];
|
|
363
|
+
const replacementResult = event.data.message.content[0];
|
|
364
|
+
originalRest["message"] = {
|
|
365
|
+
...original.data.message,
|
|
366
|
+
content: [{
|
|
367
|
+
...originalResult,
|
|
368
|
+
content: null
|
|
369
|
+
}]
|
|
370
|
+
};
|
|
371
|
+
replacementRest["message"] = {
|
|
372
|
+
...event.data.message,
|
|
373
|
+
content: [{
|
|
374
|
+
...replacementResult,
|
|
375
|
+
content: null
|
|
376
|
+
}]
|
|
377
|
+
};
|
|
378
|
+
if (!isDeepEqualJson(originalRest, replacementRest)) throw new Error("tool/result surface replacement may change only content");
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/** Validate one event at its replay boundary and prepare its atomic fold transition. */
|
|
382
|
+
function planSurfaceEvent(state, event, expectedSeq, events, baseSeq) {
|
|
383
|
+
if (event.seq !== expectedSeq) throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`);
|
|
384
|
+
const surfaceOp = surfaceOpOf(event);
|
|
385
|
+
if (surfaceOp === void 0) return;
|
|
386
|
+
if (surfaceOp === "append") {
|
|
387
|
+
assertProvenance(event, []);
|
|
388
|
+
return {
|
|
389
|
+
kind: "append",
|
|
390
|
+
seq: event.seq
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
const range = replacementRange(state, surfaceOp);
|
|
394
|
+
assertProvenance(event, range.shadowedSeqs);
|
|
395
|
+
assertToolResultRewrite(event, range.shadowedSeqs, events, baseSeq);
|
|
396
|
+
return {
|
|
397
|
+
kind: "replace",
|
|
398
|
+
seq: event.seq,
|
|
399
|
+
start: surfaceOp.start,
|
|
400
|
+
end: surfaceOp.end,
|
|
401
|
+
...range
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
/** Apply one event and return replacement metadata only when one occurred. */
|
|
405
|
+
function applySurfaceEvent(state, event, expectedSeq, events, baseSeq) {
|
|
406
|
+
return applySurfacePlan(state, planSurfaceEvent(state, event, expectedSeq, events, baseSeq));
|
|
407
|
+
}
|
|
408
|
+
/** Commit one previously validated surface transition. */
|
|
409
|
+
function applySurfacePlan(state, plan) {
|
|
410
|
+
if (plan?.kind === "append") state.nodes.push(plan.seq);
|
|
411
|
+
else if (plan?.kind === "replace") {
|
|
412
|
+
state.nodes.splice(plan.startIdx, plan.endIdx - plan.startIdx + 1, plan.seq);
|
|
413
|
+
state.replaceGeneration += 1;
|
|
414
|
+
}
|
|
415
|
+
if (plan?.kind !== "replace") return;
|
|
416
|
+
return {
|
|
417
|
+
seq: plan.seq,
|
|
418
|
+
start: plan.start,
|
|
419
|
+
end: plan.end,
|
|
420
|
+
shadowedSeqs: plan.shadowedSeqs
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Replay a complete session log through the canonical surface fold.
|
|
425
|
+
* @param events - session events in contiguous seq order.
|
|
426
|
+
* @returns detached current sequences and replacement history.
|
|
427
|
+
* @throws when an event violates surface metadata, source-event references, range, or tool-result rewrite rules.
|
|
428
|
+
*/
|
|
429
|
+
function foldSurface(events) {
|
|
430
|
+
const state = createFoldState();
|
|
431
|
+
const replacements = [];
|
|
432
|
+
for (const [index, event] of events.entries()) {
|
|
433
|
+
const replacement = applySurfaceEvent(state, event, index, events, 0);
|
|
434
|
+
if (replacement !== void 0) replacements.push(replacement);
|
|
435
|
+
}
|
|
436
|
+
return {
|
|
437
|
+
nodes: [...state.nodes],
|
|
438
|
+
replacements
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
/** Incremental ordered surface view and append-boundary validator. */
|
|
442
|
+
var SurfaceManager = class {
|
|
443
|
+
log;
|
|
444
|
+
baseSeq;
|
|
445
|
+
/** Shared transition state; replacement history is not retained. */
|
|
446
|
+
_state = createFoldState();
|
|
447
|
+
/** Last processed absolute seq. */
|
|
448
|
+
_lastProcessedSeq;
|
|
449
|
+
/** Candidate already validated by `validateNext`, pending exact log admission. */
|
|
450
|
+
_pendingPlan;
|
|
451
|
+
/**
|
|
452
|
+
* @param log - Contiguous complete log or loaded event window.
|
|
453
|
+
* @param baseSeq - Absolute sequence of the window's first event.
|
|
454
|
+
*/
|
|
455
|
+
constructor(log, baseSeq = 0) {
|
|
456
|
+
this.log = log;
|
|
457
|
+
this.baseSeq = baseSeq;
|
|
458
|
+
this._lastProcessedSeq = baseSeq - 1;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Validate the next candidate without mutating the committed surface.
|
|
462
|
+
* @param event - candidate event that has not entered the log yet.
|
|
463
|
+
*/
|
|
464
|
+
validateNext(event) {
|
|
465
|
+
if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta();
|
|
466
|
+
const expectedSeq = this.baseSeq + this.log.length;
|
|
467
|
+
this._pendingPlan = {
|
|
468
|
+
event,
|
|
469
|
+
expectedSeq,
|
|
470
|
+
plan: planSurfaceEvent(this._state, event, expectedSeq, this.log, this.baseSeq)
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
/** Monotonic count of folded positional replacements. */
|
|
474
|
+
get replaceGeneration() {
|
|
475
|
+
if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta();
|
|
476
|
+
return this._state.replaceGeneration;
|
|
477
|
+
}
|
|
478
|
+
/** Surface event sequences in model-visible order. */
|
|
479
|
+
get nodes() {
|
|
480
|
+
if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta();
|
|
481
|
+
return this._state.nodes;
|
|
482
|
+
}
|
|
483
|
+
/** Fold events appended since the previous access. */
|
|
484
|
+
_processDelta() {
|
|
485
|
+
const tailSeq = this.baseSeq + this.log.length - 1;
|
|
486
|
+
for (let seq = this._lastProcessedSeq + 1; seq <= tailSeq; seq++) {
|
|
487
|
+
const index = seq - this.baseSeq;
|
|
488
|
+
const event = this.log[index];
|
|
489
|
+
const pending = this._pendingPlan;
|
|
490
|
+
if (pending?.event === event && pending.expectedSeq === seq) applySurfacePlan(this._state, pending.plan);
|
|
491
|
+
else applySurfaceEvent(this._state, event, seq, this.log, this.baseSeq);
|
|
492
|
+
if (pending !== void 0 && pending.expectedSeq <= seq) this._pendingPlan = void 0;
|
|
493
|
+
this._lastProcessedSeq = seq;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
//#endregion
|
|
498
|
+
//#region lib/types/request-header.js
|
|
499
|
+
/**
|
|
500
|
+
* Request-header reconstruction utilities over full `request/header` session
|
|
501
|
+
* events. Anyone holding a session log reconstructs the {@link EpochHeader}
|
|
502
|
+
* any request was built under by taking the latest canonical snapshot; the
|
|
503
|
+
* loop uses the same equality helper to avoid logging unchanged headers.
|
|
504
|
+
*
|
|
505
|
+
* @module dsh-session/request-header
|
|
506
|
+
*/
|
|
507
|
+
/**
|
|
508
|
+
* Normalize a header to canonical form: an empty system prompt and empty tool
|
|
509
|
+
* list become absent fields, matching how requests are built. Logging, folding,
|
|
510
|
+
* and comparison use this one representation.
|
|
511
|
+
* @param header - the header to normalize (not mutated).
|
|
512
|
+
* @returns the canonical header.
|
|
513
|
+
*/
|
|
514
|
+
function canonicalHeader(header) {
|
|
515
|
+
const adapterDefaults = header.adapterDefaults;
|
|
516
|
+
return {
|
|
517
|
+
config: header.config,
|
|
518
|
+
...adapterDefaults?.reasoningEffort === true || adapterDefaults?.maxTokens === true ? { adapterDefaults } : {},
|
|
519
|
+
...header.system !== void 0 && header.system.length > 0 ? { system: header.system } : {},
|
|
520
|
+
...header.tools !== void 0 && header.tools.length > 0 ? { tools: header.tools } : {}
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
/** Canonical JSON equality for tool schemas assembled through the same path. */
|
|
524
|
+
function sameSchema(a, b) {
|
|
525
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Field-wise equality over canonical headers. Tool schemas compare in order.
|
|
529
|
+
* @param a - one canonical header.
|
|
530
|
+
* @param b - the other.
|
|
531
|
+
* @returns whether config, system, and tools all match.
|
|
532
|
+
*/
|
|
533
|
+
function headerEquals(a, b) {
|
|
534
|
+
if (!callConfigEquals(a.config, b.config) || a.adapterDefaults?.reasoningEffort !== b.adapterDefaults?.reasoningEffort || a.adapterDefaults?.maxTokens !== b.adapterDefaults?.maxTokens || a.system !== b.system) return false;
|
|
535
|
+
const at = a.tools ?? [];
|
|
536
|
+
const bt = b.tools ?? [];
|
|
537
|
+
return at.length === bt.length && at.every((tool, i) => sameSchema(tool, bt[i]));
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Fold the header events of a log (or any prefix) into the
|
|
541
|
+
* {@link EpochHeader} in force after the last snapshot. Non-header events are
|
|
542
|
+
* skipped. This is the pure offline reconstruction path; the live session
|
|
543
|
+
* tracks the same fold incrementally.
|
|
544
|
+
* @param events - session events in log order.
|
|
545
|
+
* @param from - a previously folded state to continue from.
|
|
546
|
+
* @returns the latest canonical header, or undefined when none exists yet.
|
|
547
|
+
*/
|
|
548
|
+
function foldRequestHeader(events, from) {
|
|
549
|
+
let state = from;
|
|
550
|
+
for (const event of events) if (event.type === "request/header") state = canonicalHeader(event.data.header);
|
|
551
|
+
return state;
|
|
552
|
+
}
|
|
553
|
+
//#endregion
|
|
554
|
+
//#region lib/types/preparation.js
|
|
555
|
+
/**
|
|
556
|
+
* Ownership of one unpublished Session before registry publication.
|
|
557
|
+
* @module @deepseek-ai/dsh-session/preparation
|
|
558
|
+
*/
|
|
559
|
+
/**
|
|
560
|
+
* One exact unpublished Session and the provider state that keeps it usable.
|
|
561
|
+
* Disposal is synchronous and idempotent. Providers decide whether release
|
|
562
|
+
* returns the Session to a cache or discards it; publication may consume that
|
|
563
|
+
* state before disposal, making the callback a no-op.
|
|
564
|
+
*/
|
|
565
|
+
var SessionPreparation = class SessionPreparation {
|
|
566
|
+
options;
|
|
567
|
+
released = false;
|
|
568
|
+
/** The exact Session to use for setup and publication. */
|
|
569
|
+
session;
|
|
570
|
+
constructor(session, options) {
|
|
571
|
+
this.options = options;
|
|
572
|
+
this.session = session;
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Wrap an unpublished Session in one preparation lifetime.
|
|
576
|
+
* @param session - exact unpublished Session.
|
|
577
|
+
* @param options - optional provider release behavior.
|
|
578
|
+
* @returns a preparation disposed after publication or rollback.
|
|
579
|
+
*/
|
|
580
|
+
static create(session, options) {
|
|
581
|
+
return new SessionPreparation(session, options ?? {});
|
|
582
|
+
}
|
|
583
|
+
/** Release provider state once when this preparation leaves its caller. */
|
|
584
|
+
[Symbol.dispose]() {
|
|
585
|
+
if (this.released) return;
|
|
586
|
+
this.released = true;
|
|
587
|
+
this.options.release?.();
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
//#endregion
|
|
591
|
+
//#region lib/types/repair.js
|
|
592
|
+
/**
|
|
593
|
+
* Crash-recovery repair for an interrupted session log. It preserves a fully
|
|
594
|
+
* written final turn and supplies the missing tool, step, and turn boundaries
|
|
595
|
+
* needed to resume with a provider-valid transcript, plus the activity-time
|
|
596
|
+
* read that must skip the end-seed boundary — which this module does
|
|
597
|
+
* not write (`Session`'s constructor does) but whose synthetic closers can
|
|
598
|
+
* inherit that boundary's timestamp, the one real coupling between the two.
|
|
599
|
+
* @module @deepseek-ai/dsh-session/repair
|
|
600
|
+
*/
|
|
601
|
+
/**
|
|
602
|
+
* The `time` of the log's last event representing actual work, skipping the
|
|
603
|
+
* `session/end-seed` boundary — picking a session up is not activity, so
|
|
604
|
+
* activity ordering must exclude it.
|
|
605
|
+
*
|
|
606
|
+
* Excluded by type, so a pickup time still leaks when a boundary is the last
|
|
607
|
+
* event of an open turn: {@link interruptedTurnClosers} copies it onto the
|
|
608
|
+
* synthetic `turn/end`, which this counts as work. Reachable only by seeding an
|
|
609
|
+
* unbalanced log directly — `load()` balances first.
|
|
610
|
+
* @param events - the log to scan, in seq order.
|
|
611
|
+
* @returns the latest non-boundary event's `time`, or undefined when there is none.
|
|
612
|
+
*/
|
|
613
|
+
function lastActivityTime(events) {
|
|
614
|
+
return events.findLast((event) => event.type !== "session/end-seed")?.time;
|
|
615
|
+
}
|
|
616
|
+
/** Recovery code for an assistant tool request that never reached a recorded call start. */
|
|
617
|
+
const TOOL_NOT_STARTED = "TOOL_NOT_STARTED";
|
|
618
|
+
/** Recovery code for a recorded tool call whose completed outcome was not durably recorded. */
|
|
619
|
+
const TOOL_OUTCOME_UNKNOWN = "TOOL_OUTCOME_UNKNOWN";
|
|
620
|
+
/**
|
|
621
|
+
* Return deterministic synthetic events that close an open tail turn. Unmatched
|
|
622
|
+
* calls receive error results first, followed by an open `step/end` and an
|
|
623
|
+
* interrupted `turn/end`; sequences continue the log and timestamps reuse the
|
|
624
|
+
* last real event. A balanced or empty log returns no events.
|
|
625
|
+
*
|
|
626
|
+
* @param events - the loaded durable log to scan (a valid committed prefix, possibly with a crash tail).
|
|
627
|
+
* @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced.
|
|
628
|
+
*/
|
|
629
|
+
function interruptedTurnClosers(events) {
|
|
630
|
+
let openTurn = null;
|
|
631
|
+
let openStep = null;
|
|
632
|
+
const pendingCalls = /* @__PURE__ */ new Map();
|
|
633
|
+
for (const event of events) switch (event.type) {
|
|
634
|
+
case "turn/start":
|
|
635
|
+
openTurn = event.data.turn;
|
|
636
|
+
openStep = null;
|
|
637
|
+
pendingCalls.clear();
|
|
638
|
+
break;
|
|
639
|
+
case "turn/end":
|
|
640
|
+
openTurn = null;
|
|
641
|
+
openStep = null;
|
|
642
|
+
pendingCalls.clear();
|
|
643
|
+
break;
|
|
644
|
+
case "step/start":
|
|
645
|
+
openStep = event.data.step;
|
|
646
|
+
break;
|
|
647
|
+
case "step/end":
|
|
648
|
+
pendingCalls.clear();
|
|
649
|
+
openStep = null;
|
|
650
|
+
break;
|
|
651
|
+
case "assistant/message":
|
|
652
|
+
for (const block of event.data.message.content) if (block.type === "tool-call") pendingCalls.set(block.id, { step: event.data.step });
|
|
653
|
+
break;
|
|
654
|
+
case "tool/call":
|
|
655
|
+
{
|
|
656
|
+
const entry = pendingCalls.get(event.data.callId);
|
|
657
|
+
if (entry) entry.callSeq = event.seq;
|
|
658
|
+
}
|
|
659
|
+
break;
|
|
660
|
+
case "tool/result":
|
|
661
|
+
pendingCalls.delete(event.data.message.source.callId);
|
|
662
|
+
break;
|
|
663
|
+
default: break;
|
|
664
|
+
}
|
|
665
|
+
const last = events.at(-1);
|
|
666
|
+
if (openTurn === null || last === void 0) return [];
|
|
667
|
+
let seq = last.seq + 1;
|
|
668
|
+
const time = last.time;
|
|
669
|
+
const closers = [];
|
|
670
|
+
for (const [callId, { step, callSeq }] of pendingCalls) {
|
|
671
|
+
const started = callSeq !== void 0;
|
|
672
|
+
const message = freezeMessage({
|
|
673
|
+
id: MessageId(`interrupted-tool-result-${callId}-${seq}`),
|
|
674
|
+
role: "user",
|
|
675
|
+
source: {
|
|
676
|
+
kind: "tool",
|
|
677
|
+
callId
|
|
678
|
+
},
|
|
679
|
+
content: [{
|
|
680
|
+
type: "tool-result",
|
|
681
|
+
toolCallId: callId,
|
|
682
|
+
isError: true,
|
|
683
|
+
content: [{
|
|
684
|
+
type: "text",
|
|
685
|
+
text: started ? "The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly." : "The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed."
|
|
686
|
+
}]
|
|
687
|
+
}]
|
|
688
|
+
});
|
|
689
|
+
closers.push({
|
|
690
|
+
type: "tool/result",
|
|
691
|
+
seq: seq++,
|
|
692
|
+
time,
|
|
693
|
+
data: {
|
|
694
|
+
turn: openTurn,
|
|
695
|
+
step,
|
|
696
|
+
message,
|
|
697
|
+
error: started ? {
|
|
698
|
+
name: "ToolOutcomeUnknownError",
|
|
699
|
+
code: TOOL_OUTCOME_UNKNOWN
|
|
700
|
+
} : {
|
|
701
|
+
name: "ToolNotStartedError",
|
|
702
|
+
code: TOOL_NOT_STARTED
|
|
703
|
+
}
|
|
704
|
+
},
|
|
705
|
+
surfaceOp: "append",
|
|
706
|
+
...started ? { sourceEventSeqs: [callSeq] } : {}
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
if (openStep !== null) closers.push({
|
|
710
|
+
type: "step/end",
|
|
711
|
+
seq: seq++,
|
|
712
|
+
time,
|
|
713
|
+
data: {
|
|
714
|
+
turn: openTurn,
|
|
715
|
+
step: openStep
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
closers.push({
|
|
719
|
+
type: "turn/end",
|
|
720
|
+
seq: seq++,
|
|
721
|
+
time,
|
|
722
|
+
data: {
|
|
723
|
+
turn: openTurn,
|
|
724
|
+
reason: { kind: "interrupted" }
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
return closers;
|
|
728
|
+
}
|
|
729
|
+
//#endregion
|
|
730
|
+
//#region lib/types/chunk-rows.js
|
|
731
|
+
/**
|
|
732
|
+
* Lossless storage packing for `assistant/chunk` delta runs. Providers stream
|
|
733
|
+
* token-sized deltas, so a log stores hundreds of near-identical event lines
|
|
734
|
+
* whose JSON envelopes dwarf their payloads (~56× measured on a real DeepSeek
|
|
735
|
+
* session). This module packs each run of consecutive same-block delta chunks
|
|
736
|
+
* into ONE storage row — `text-chunks`, `reasoning-chunks`, or
|
|
737
|
+
* `tool-call-chunks` — and expands rows back to the exact original events.
|
|
738
|
+
*
|
|
739
|
+
* Storage rows are a durable-encoding vocabulary, NOT session events: they
|
|
740
|
+
* never enter `Session.events`, have no `SessionEventMap` entry, and use bare
|
|
741
|
+
* (slash-less) type tags so a reader cannot confuse them with the event
|
|
742
|
+
* taxonomy (precedent: the JSONL header line's `session` tag). The encoder
|
|
743
|
+
* whitelists exact shapes — anything it does not fully recognize is stored
|
|
744
|
+
* verbatim, so unknown fields or future chunk variants lose compression, never
|
|
745
|
+
* data. The decoder validates before expanding and fails loud on a malformed
|
|
746
|
+
* row-tagged value instead of silently dropping a whole run.
|
|
747
|
+
*
|
|
748
|
+
* @module @deepseek-ai/dsh-session/chunk-rows
|
|
749
|
+
*/
|
|
750
|
+
/**
|
|
751
|
+
* Minimum members before a run packs. Below it a row's envelope rivals the
|
|
752
|
+
* event lines it replaces. A format constant, not a tunable: both layouts
|
|
753
|
+
* decode identically, so changing it never invalidates stored logs.
|
|
754
|
+
*/
|
|
755
|
+
const MIN_RUN = 3;
|
|
756
|
+
function isRecord(value) {
|
|
757
|
+
return typeof value === "object" && value !== null;
|
|
758
|
+
}
|
|
759
|
+
/** Exact-key check: `value` has every key in `keys` and nothing else. */
|
|
760
|
+
function hasExactKeys(value, keys) {
|
|
761
|
+
return Object.keys(value).length === keys.length && keys.every((k) => Object.hasOwn(value, k));
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Classify an event for packing: its delta kind when the ENTIRE shape
|
|
765
|
+
* (envelope, data, chunk — exact keys, primitive types, integer seq/time) is
|
|
766
|
+
* whitelisted, else `undefined` (store verbatim). Inputs come from live typed
|
|
767
|
+
* appends AND parsed fixture files, so the checks are structural, not
|
|
768
|
+
* type-trusted. Integer times keep gap encoding exact: a fractional time would
|
|
769
|
+
* reconstruct through float subtraction/addition, which need not round-trip.
|
|
770
|
+
*/
|
|
771
|
+
function classify(event) {
|
|
772
|
+
if (event.type !== "assistant/chunk") return void 0;
|
|
773
|
+
if (!hasExactKeys(event, [
|
|
774
|
+
"type",
|
|
775
|
+
"seq",
|
|
776
|
+
"time",
|
|
777
|
+
"data"
|
|
778
|
+
])) return void 0;
|
|
779
|
+
if (!Number.isSafeInteger(event.seq) || event.seq < 0 || !Number.isSafeInteger(event.time)) return void 0;
|
|
780
|
+
const data = event.data;
|
|
781
|
+
if (!isRecord(data) || !hasExactKeys(data, [
|
|
782
|
+
"turn",
|
|
783
|
+
"step",
|
|
784
|
+
"chunk"
|
|
785
|
+
])) return void 0;
|
|
786
|
+
if (typeof data.turn !== "number" || typeof data.step !== "number") return void 0;
|
|
787
|
+
const chunk = data.chunk;
|
|
788
|
+
if (!isRecord(chunk) || typeof chunk.index !== "number") return void 0;
|
|
789
|
+
switch (chunk.type) {
|
|
790
|
+
case "text-delta":
|
|
791
|
+
case "reasoning-delta": return hasExactKeys(chunk, [
|
|
792
|
+
"type",
|
|
793
|
+
"index",
|
|
794
|
+
"text"
|
|
795
|
+
]) && typeof chunk.text === "string" ? chunk.type : void 0;
|
|
796
|
+
case "tool-call-delta": return (hasExactKeys(chunk, [
|
|
797
|
+
"type",
|
|
798
|
+
"index",
|
|
799
|
+
"id",
|
|
800
|
+
"argumentsDelta"
|
|
801
|
+
]) || hasExactKeys(chunk, [
|
|
802
|
+
"type",
|
|
803
|
+
"index",
|
|
804
|
+
"id",
|
|
805
|
+
"name",
|
|
806
|
+
"argumentsDelta"
|
|
807
|
+
]) && typeof chunk.name === "string") && typeof chunk.id === "string" && typeof chunk.argumentsDelta === "string" ? chunk.type : void 0;
|
|
808
|
+
default: return;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
/** The tool-call fields of a whitelisted delta chunk (only after {@link classify} returned `'tool-call-delta'`). */
|
|
812
|
+
function toolCallOf(event) {
|
|
813
|
+
return event.data.chunk;
|
|
814
|
+
}
|
|
815
|
+
/** The block index of a whitelisted delta chunk (not every {@link StreamChunk} variant carries one). */
|
|
816
|
+
function indexOf(event) {
|
|
817
|
+
return event.data.chunk.index;
|
|
818
|
+
}
|
|
819
|
+
/** Whether `next` extends a run ending in `prev` (same kind already checked by the caller). */
|
|
820
|
+
function continues(prev, next, kind) {
|
|
821
|
+
if (next.seq !== prev.seq + 1) return false;
|
|
822
|
+
if (!Number.isSafeInteger(next.time - prev.time)) return false;
|
|
823
|
+
if (next.data.turn !== prev.data.turn || next.data.step !== prev.data.step) return false;
|
|
824
|
+
if (indexOf(next) !== indexOf(prev)) return false;
|
|
825
|
+
if (kind !== "tool-call-delta") return true;
|
|
826
|
+
const a = toolCallOf(prev);
|
|
827
|
+
const b = toolCallOf(next);
|
|
828
|
+
return a.id === b.id && Object.hasOwn(a, "name") === Object.hasOwn(b, "name") && a.name === b.name;
|
|
829
|
+
}
|
|
830
|
+
/** Build the row for a completed run (`run.length >= MIN_RUN`, uniform per {@link continues}). */
|
|
831
|
+
function buildRow(kind, run) {
|
|
832
|
+
const first = run[0];
|
|
833
|
+
const base = {
|
|
834
|
+
turn: first.data.turn,
|
|
835
|
+
step: first.data.step,
|
|
836
|
+
index: indexOf(first),
|
|
837
|
+
dt: run.slice(1).map((event, i) => event.time - run[i].time)
|
|
838
|
+
};
|
|
839
|
+
const envelope = {
|
|
840
|
+
seq0: first.seq,
|
|
841
|
+
time0: first.time
|
|
842
|
+
};
|
|
843
|
+
if (kind === "tool-call-delta") {
|
|
844
|
+
const call = toolCallOf(first);
|
|
845
|
+
return {
|
|
846
|
+
type: "tool-call-chunks",
|
|
847
|
+
...envelope,
|
|
848
|
+
data: {
|
|
849
|
+
...base,
|
|
850
|
+
id: CallId(call.id),
|
|
851
|
+
...Object.hasOwn(call, "name") ? { name: call.name } : {},
|
|
852
|
+
args: run.map((event) => event.data.chunk.argumentsDelta)
|
|
853
|
+
}
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
const data = {
|
|
857
|
+
...base,
|
|
858
|
+
texts: run.map((event) => event.data.chunk.text)
|
|
859
|
+
};
|
|
860
|
+
return kind === "text-delta" ? {
|
|
861
|
+
type: "text-chunks",
|
|
862
|
+
...envelope,
|
|
863
|
+
data
|
|
864
|
+
} : {
|
|
865
|
+
type: "reasoning-chunks",
|
|
866
|
+
...envelope,
|
|
867
|
+
data
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Pack an event batch for storage: each run of at least {@link MIN_RUN}
|
|
872
|
+
* consecutive whitelisted same-kind, same-block delta chunk events becomes one
|
|
873
|
+
* {@link ChunkRow}; every other event passes through verbatim, in order.
|
|
874
|
+
* Pure and stateless — safe over any array, including a batch whose runs were
|
|
875
|
+
* split by flush boundaries (the split runs simply pack per batch).
|
|
876
|
+
*
|
|
877
|
+
* @param events - the batch to encode, in log order.
|
|
878
|
+
* @returns the storage records to write, one JSONL line each.
|
|
879
|
+
*/
|
|
880
|
+
function packChunkRuns(events) {
|
|
881
|
+
const out = [];
|
|
882
|
+
let kind;
|
|
883
|
+
let run = [];
|
|
884
|
+
const flush = () => {
|
|
885
|
+
if (kind !== void 0 && run.length >= MIN_RUN) out.push(buildRow(kind, run));
|
|
886
|
+
else out.push(...run);
|
|
887
|
+
kind = void 0;
|
|
888
|
+
run = [];
|
|
889
|
+
};
|
|
890
|
+
for (const event of events) {
|
|
891
|
+
const k = classify(event);
|
|
892
|
+
if (k === void 0) {
|
|
893
|
+
flush();
|
|
894
|
+
out.push(event);
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
const delta = event;
|
|
898
|
+
const last = run[run.length - 1];
|
|
899
|
+
if (k === kind && last !== void 0 && continues(last, delta, k)) {
|
|
900
|
+
run.push(delta);
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
flush();
|
|
904
|
+
kind = k;
|
|
905
|
+
run = [delta];
|
|
906
|
+
}
|
|
907
|
+
flush();
|
|
908
|
+
return out;
|
|
909
|
+
}
|
|
910
|
+
/** Throw the uniform malformed-row diagnostic. */
|
|
911
|
+
function malformed(tag, why) {
|
|
912
|
+
throw new Error(`malformed ${tag} storage row: ${why}`);
|
|
913
|
+
}
|
|
914
|
+
/** Validate the shared run-data fields and the payload/dt arity; returns the member payload. */
|
|
915
|
+
function validateRunData(tag, data, payloadKey) {
|
|
916
|
+
if (typeof data.turn !== "number" || typeof data.step !== "number" || typeof data.index !== "number") malformed(tag, "turn/step/index must be numbers");
|
|
917
|
+
const payload = data[payloadKey];
|
|
918
|
+
if (!Array.isArray(payload) || payload.length === 0 || payload.some((entry) => typeof entry !== "string")) malformed(tag, `${payloadKey} must be a non-empty string array`);
|
|
919
|
+
const dt = data.dt;
|
|
920
|
+
if (!Array.isArray(dt) || dt.some((gap) => !Number.isSafeInteger(gap))) malformed(tag, "dt must be an array of safe integers");
|
|
921
|
+
if (dt.length !== payload.length - 1) malformed(tag, `dt length ${dt.length} does not match ${payload.length} members`);
|
|
922
|
+
return payload;
|
|
923
|
+
}
|
|
924
|
+
/** Validate a row-tagged parsed value's envelope and data, throwing on any malformation. */
|
|
925
|
+
function validateRow(value, tag) {
|
|
926
|
+
if (!hasExactKeys(value, [
|
|
927
|
+
"type",
|
|
928
|
+
"seq0",
|
|
929
|
+
"time0",
|
|
930
|
+
"data"
|
|
931
|
+
])) malformed(tag, "envelope must be exactly {type, seq0, time0, data}");
|
|
932
|
+
if (!Number.isSafeInteger(value.seq0) || value.seq0 < 0) malformed(tag, "seq0 must be a non-negative safe integer");
|
|
933
|
+
if (!Number.isSafeInteger(value.time0)) malformed(tag, "time0 must be a safe integer");
|
|
934
|
+
const data = value.data;
|
|
935
|
+
if (!isRecord(data)) malformed(tag, "data must be an object");
|
|
936
|
+
let payload;
|
|
937
|
+
if (tag === "tool-call-chunks") {
|
|
938
|
+
const withName = hasExactKeys(data, [
|
|
939
|
+
"turn",
|
|
940
|
+
"step",
|
|
941
|
+
"index",
|
|
942
|
+
"id",
|
|
943
|
+
"name",
|
|
944
|
+
"dt",
|
|
945
|
+
"args"
|
|
946
|
+
]);
|
|
947
|
+
if (!withName && !hasExactKeys(data, [
|
|
948
|
+
"turn",
|
|
949
|
+
"step",
|
|
950
|
+
"index",
|
|
951
|
+
"id",
|
|
952
|
+
"dt",
|
|
953
|
+
"args"
|
|
954
|
+
])) malformed(tag, "data must be exactly {turn, step, index, id, name?, dt, args}");
|
|
955
|
+
if (typeof data.id !== "string" || withName && typeof data.name !== "string") malformed(tag, "id (and name when present) must be strings");
|
|
956
|
+
payload = validateRunData(tag, data, "args");
|
|
957
|
+
} else {
|
|
958
|
+
if (!hasExactKeys(data, [
|
|
959
|
+
"turn",
|
|
960
|
+
"step",
|
|
961
|
+
"index",
|
|
962
|
+
"dt",
|
|
963
|
+
"texts"
|
|
964
|
+
])) malformed(tag, "data must be exactly {turn, step, index, dt, texts}");
|
|
965
|
+
payload = validateRunData(tag, data, "texts");
|
|
966
|
+
}
|
|
967
|
+
if (!Number.isSafeInteger(value.seq0 + payload.length - 1)) malformed(tag, "member seqs must stay safe integers");
|
|
968
|
+
let time = value.time0;
|
|
969
|
+
for (const gap of data.dt) {
|
|
970
|
+
time += gap;
|
|
971
|
+
if (!Number.isSafeInteger(time)) malformed(tag, "member times must stay safe integers");
|
|
972
|
+
}
|
|
973
|
+
return value;
|
|
974
|
+
}
|
|
975
|
+
/** Expand a validated row back into its exact original events, in order. */
|
|
976
|
+
function expandRow(row) {
|
|
977
|
+
const members = row.type === "tool-call-chunks" ? row.data.args : row.data.texts;
|
|
978
|
+
const events = [];
|
|
979
|
+
let time = row.time0;
|
|
980
|
+
for (let k = 0; k < members.length; k++) {
|
|
981
|
+
if (k > 0) time += row.data.dt[k - 1];
|
|
982
|
+
let chunk;
|
|
983
|
+
switch (row.type) {
|
|
984
|
+
case "text-chunks":
|
|
985
|
+
chunk = {
|
|
986
|
+
type: "text-delta",
|
|
987
|
+
index: row.data.index,
|
|
988
|
+
text: members[k]
|
|
989
|
+
};
|
|
990
|
+
break;
|
|
991
|
+
case "reasoning-chunks":
|
|
992
|
+
chunk = {
|
|
993
|
+
type: "reasoning-delta",
|
|
994
|
+
index: row.data.index,
|
|
995
|
+
text: members[k]
|
|
996
|
+
};
|
|
997
|
+
break;
|
|
998
|
+
case "tool-call-chunks":
|
|
999
|
+
chunk = {
|
|
1000
|
+
type: "tool-call-delta",
|
|
1001
|
+
index: row.data.index,
|
|
1002
|
+
id: row.data.id,
|
|
1003
|
+
...Object.hasOwn(row.data, "name") ? { name: row.data.name } : {},
|
|
1004
|
+
argumentsDelta: members[k]
|
|
1005
|
+
};
|
|
1006
|
+
break;
|
|
1007
|
+
/* v8 ignore next 2 -- validateRow only returns the three row tags */
|
|
1008
|
+
default: return assertNever(row, "chunk-rows expandRow");
|
|
1009
|
+
}
|
|
1010
|
+
events.push({
|
|
1011
|
+
type: "assistant/chunk",
|
|
1012
|
+
seq: row.seq0 + k,
|
|
1013
|
+
time,
|
|
1014
|
+
data: {
|
|
1015
|
+
turn: row.data.turn,
|
|
1016
|
+
step: row.data.step,
|
|
1017
|
+
chunk
|
|
1018
|
+
}
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
return events;
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Decode one parsed JSONL line value into the session event(s) it stores.
|
|
1025
|
+
* Chunk-row-tagged values validate and expand (a malformed row throws — it is
|
|
1026
|
+
* corrupt storage, and treating it as an event would silently drop a whole
|
|
1027
|
+
* run); every other value passes through as a single event, unvalidated.
|
|
1028
|
+
*
|
|
1029
|
+
* @param value - one line's `JSON.parse` result.
|
|
1030
|
+
* @returns the stored events, in log order.
|
|
1031
|
+
*/
|
|
1032
|
+
function decodeStorageRecord(value) {
|
|
1033
|
+
if (!isRecord(value)) return [value];
|
|
1034
|
+
const tag = value.type;
|
|
1035
|
+
if (tag !== "text-chunks" && tag !== "reasoning-chunks" && tag !== "tool-call-chunks") return [value];
|
|
1036
|
+
return expandRow(validateRow(value, tag));
|
|
1037
|
+
}
|
|
1038
|
+
//#endregion
|
|
1039
|
+
//#region lib/types/index.js
|
|
1040
|
+
/**
|
|
1041
|
+
* Event-sourced session service: append-only session log, in-memory store, and
|
|
1042
|
+
* the derived LLM message history. Persistence is a plugin concern (subscribe
|
|
1043
|
+
* to `session/event`, drain on `session/flush`).
|
|
1044
|
+
*
|
|
1045
|
+
* @module @deepseek-ai/dsh-session
|
|
1046
|
+
*/
|
|
1047
|
+
/**
|
|
1048
|
+
* Find the latest closed turn that entered at least one model step, ignoring
|
|
1049
|
+
* balanced no-step turns produced by rejection, empty input, or cancellation.
|
|
1050
|
+
* @param events - session events, or an owned suffix, to inspect.
|
|
1051
|
+
* @returns the latest matching turn end, or `undefined`.
|
|
1052
|
+
*/
|
|
1053
|
+
function findLastMessageTurnEnd(events) {
|
|
1054
|
+
const steppedTurns = /* @__PURE__ */ new Set();
|
|
1055
|
+
let latest;
|
|
1056
|
+
for (const event of events) {
|
|
1057
|
+
if (event.type === "step/start") {
|
|
1058
|
+
steppedTurns.add(event.data.turn);
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
if (event.type === "turn/end" && steppedTurns.delete(event.data.turn)) latest = event;
|
|
1062
|
+
}
|
|
1063
|
+
return latest;
|
|
1064
|
+
}
|
|
1065
|
+
/** Validate and freeze one detached creation header in place. */
|
|
1066
|
+
function validateSessionHeader(id, input) {
|
|
1067
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) throw new Error("session header is not a plain JSON record");
|
|
1068
|
+
const record = input;
|
|
1069
|
+
if (record.version !== 0) throw new Error(`session header version must be 0, got ${String(record.version)}`);
|
|
1070
|
+
if (record.id !== id) throw new Error(`session header id "${String(record.id)}" does not match session id "${id}"`);
|
|
1071
|
+
if (typeof record.createdAt !== "number" || !Number.isSafeInteger(record.createdAt) || record.createdAt < 0) throw new Error("session header createdAt must be a non-negative safe integer");
|
|
1072
|
+
if (record.cwd !== void 0) {
|
|
1073
|
+
if (typeof record.cwd !== "string") throw new Error("session header cwd must be a string");
|
|
1074
|
+
if (!isAbsolute(record.cwd)) throw new Error(`session header cwd must be an absolute path, got "${record.cwd}"`);
|
|
1075
|
+
}
|
|
1076
|
+
if (record.parentSession !== void 0 && typeof record.parentSession !== "string") throw new Error("session header parentSession must be a string");
|
|
1077
|
+
if (record.seedLength !== void 0 && (typeof record.seedLength !== "number" || !Number.isSafeInteger(record.seedLength) || record.seedLength < 0)) throw new Error("session header seedLength must be a non-negative safe integer");
|
|
1078
|
+
if (record.origin !== void 0 && record.origin !== "subagent") throw new Error("session header origin must be \"subagent\"");
|
|
1079
|
+
if (record.delegationDepth !== void 0 && (typeof record.delegationDepth !== "number" || !Number.isSafeInteger(record.delegationDepth) || record.delegationDepth < 0)) throw new Error("session header delegationDepth must be a non-negative safe integer");
|
|
1080
|
+
if (record.agentPreset !== void 0 && typeof record.agentPreset !== "string") throw new Error("session header agentPreset must be a string");
|
|
1081
|
+
return deepFreeze(record);
|
|
1082
|
+
}
|
|
1083
|
+
/** Validate and freeze one exclusively owned persistence header in place. */
|
|
1084
|
+
function validateRestoredSessionHeader(id, input) {
|
|
1085
|
+
if (input !== null && typeof input === "object" && !Array.isArray(input)) {
|
|
1086
|
+
const prototype = Reflect.getPrototypeOf(input);
|
|
1087
|
+
if (prototype !== Object.prototype && prototype !== null) throw new Error("session header is not a plain JSON record");
|
|
1088
|
+
}
|
|
1089
|
+
return validateSessionHeader(id, input);
|
|
1090
|
+
}
|
|
1091
|
+
/** Detach, validate, and freeze the creation metadata published by a session. */
|
|
1092
|
+
function snapshotSessionHeader(id, source) {
|
|
1093
|
+
const snapshot = snapshotJsonValue(source === void 0 ? {
|
|
1094
|
+
version: 0,
|
|
1095
|
+
id,
|
|
1096
|
+
createdAt: Date.now()
|
|
1097
|
+
} : source);
|
|
1098
|
+
if (snapshot === void 0) throw new Error("session header is not losslessly JSON-serializable");
|
|
1099
|
+
return validateSessionHeader(id, snapshot);
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* Validate an exclusively owned event and deeply freeze its identified message
|
|
1103
|
+
* without copying the event. The caller transfers an object graph that no
|
|
1104
|
+
* producer retains and that shares no mutable children with another event.
|
|
1105
|
+
* Use {@link snapshotSessionEvent} when exclusive ownership is not guaranteed.
|
|
1106
|
+
* @param event - exclusively owned event imported across a trusted boundary.
|
|
1107
|
+
* @returns the same event object with a validated, deeply frozen message.
|
|
1108
|
+
*/
|
|
1109
|
+
function adoptSessionEvent(event) {
|
|
1110
|
+
assertMessageEventShape(event, `session event at seq ${event.seq}`);
|
|
1111
|
+
switch (event.type) {
|
|
1112
|
+
case "user/message":
|
|
1113
|
+
deepFreeze(event.data);
|
|
1114
|
+
break;
|
|
1115
|
+
case "assistant/message":
|
|
1116
|
+
case "tool/result":
|
|
1117
|
+
deepFreeze(event.data.message);
|
|
1118
|
+
break;
|
|
1119
|
+
default: break;
|
|
1120
|
+
}
|
|
1121
|
+
return event;
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Detach one event while preserving deep immutability for its identified message.
|
|
1125
|
+
* @param event - event imported across a query or persistence boundary.
|
|
1126
|
+
* @returns a detached event snapshot with a validated, deeply frozen message.
|
|
1127
|
+
*/
|
|
1128
|
+
function snapshotSessionEvent(event) {
|
|
1129
|
+
return adoptSessionEvent(structuredClone(event));
|
|
1130
|
+
}
|
|
1131
|
+
/** Deep-freeze one acyclic JSON tree without consuming the JavaScript call stack. */
|
|
1132
|
+
function freezeRestoredObject(value) {
|
|
1133
|
+
const pending = [value];
|
|
1134
|
+
while (pending.length > 0) {
|
|
1135
|
+
const current = pending.pop();
|
|
1136
|
+
Object.freeze(current);
|
|
1137
|
+
for (const key in current) {
|
|
1138
|
+
const child = current[key];
|
|
1139
|
+
if (child !== null && typeof child === "object") pending.push(child);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
return value;
|
|
1143
|
+
}
|
|
1144
|
+
/** Validate the fixed event envelope after one-pass JSON materialization. */
|
|
1145
|
+
function assertSessionEventEnvelope(value, index) {
|
|
1146
|
+
const event = value;
|
|
1147
|
+
if (event["type"] === "request/header-delta") throw new Error(`seed event at index ${index} uses unsupported legacy request/header-delta format`);
|
|
1148
|
+
for (const key in event) switch (key) {
|
|
1149
|
+
case "type":
|
|
1150
|
+
case "seq":
|
|
1151
|
+
case "time":
|
|
1152
|
+
case "data":
|
|
1153
|
+
case "surfaceOp":
|
|
1154
|
+
case "sourceEventSeqs": break;
|
|
1155
|
+
default: throw new Error(`seed event at index ${index} has an invalid event envelope`);
|
|
1156
|
+
}
|
|
1157
|
+
const type = event["type"];
|
|
1158
|
+
const seq = event["seq"];
|
|
1159
|
+
const time = event["time"];
|
|
1160
|
+
if (typeof type !== "string" || typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0 || typeof time !== "number" || !Number.isSafeInteger(time) || event["data"] === void 0) throw new Error(`seed event at index ${index} has an invalid event envelope`);
|
|
1161
|
+
switch (type) {
|
|
1162
|
+
case "request/header":
|
|
1163
|
+
case "user/message":
|
|
1164
|
+
case "assistant/message":
|
|
1165
|
+
case "tool/result":
|
|
1166
|
+
assertCurrentLlmShape(event, index);
|
|
1167
|
+
break;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
/** Reject obsolete request headers and malformed messages at the seed/load boundary. */
|
|
1171
|
+
function assertCurrentLlmShape(event, index) {
|
|
1172
|
+
const data = event["data"];
|
|
1173
|
+
const record = typeof data === "object" && data !== null ? data : void 0;
|
|
1174
|
+
if (event["type"] === "request/header") {
|
|
1175
|
+
const header = record?.["header"];
|
|
1176
|
+
const headerRecord = typeof header === "object" && header !== null && !Array.isArray(header) ? header : void 0;
|
|
1177
|
+
const config = headerRecord?.["config"];
|
|
1178
|
+
if (!hasProviderModel(config)) throw new Error(`seed request/header at index ${index} lacks provider/model`);
|
|
1179
|
+
const configRecord = config;
|
|
1180
|
+
const reasoningEffort = configRecord["reasoningEffort"];
|
|
1181
|
+
if (reasoningEffort !== void 0 && (typeof reasoningEffort !== "string" || reasoningEffort.length === 0)) throw new Error(`seed request/header at index ${index} has an invalid reasoningEffort`);
|
|
1182
|
+
assertAdapterDefaults(headerRecord?.["adapterDefaults"], configRecord, index);
|
|
1183
|
+
}
|
|
1184
|
+
const type = event["type"];
|
|
1185
|
+
if (type !== "user/message" && type !== "assistant/message" && type !== "tool/result") return;
|
|
1186
|
+
assertMessageEventShape(event, `seed ${type} at index ${index}`);
|
|
1187
|
+
}
|
|
1188
|
+
const allowedAdapterKeys = new Set(["reasoningEffort", "maxTokens"]);
|
|
1189
|
+
/** Validate adapter-default markers imported from a durable request header. */
|
|
1190
|
+
function assertAdapterDefaults(value, config, index) {
|
|
1191
|
+
if (value === void 0) return;
|
|
1192
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`seed request/header at index ${index} has invalid adapterDefaults`);
|
|
1193
|
+
const defaults = value;
|
|
1194
|
+
if (Object.keys(defaults).some((key) => !allowedAdapterKeys.has(key)) || Object.values(defaults).some((marker) => marker !== true) || defaults["reasoningEffort"] === true && config["reasoningEffort"] === void 0 || defaults["maxTokens"] === true && config["maxTokens"] === void 0) throw new Error(`seed request/header at index ${index} has invalid adapterDefaults`);
|
|
1195
|
+
}
|
|
1196
|
+
/** Validate only the event-specific invariants needed to safely replay a message. */
|
|
1197
|
+
function assertMessageEventShape(event, subject) {
|
|
1198
|
+
const type = event["type"];
|
|
1199
|
+
if (type !== "user/message" && type !== "assistant/message" && type !== "tool/result") return;
|
|
1200
|
+
const data = event["data"];
|
|
1201
|
+
const record = typeof data === "object" && data !== null ? data : void 0;
|
|
1202
|
+
const message = type === "user/message" ? record : record?.["message"];
|
|
1203
|
+
if (typeof message !== "object" || message === null || typeof message["id"] !== "string" || message["id"] === "") throw new Error(`${subject} lacks an identified message`);
|
|
1204
|
+
const messageRecord = message;
|
|
1205
|
+
const expectedRole = type === "assistant/message" ? "assistant" : "user";
|
|
1206
|
+
if (messageRecord["role"] !== expectedRole) throw new Error(`${subject} message must have role "${expectedRole}"`);
|
|
1207
|
+
const source = messageRecord["source"];
|
|
1208
|
+
if (typeof source !== "object" || source === null || typeof source["kind"] !== "string" || source["kind"] === "") throw new Error(`${subject} message has invalid source`);
|
|
1209
|
+
if (!Array.isArray(messageRecord["content"])) throw new Error(`${subject} message has invalid content`);
|
|
1210
|
+
const sourceRecord = source;
|
|
1211
|
+
if (type === "assistant/message") {
|
|
1212
|
+
if (sourceRecord["kind"] !== "model" || !hasProviderModel(sourceRecord)) throw new Error(`${subject} message must have model source`);
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
if (type !== "tool/result") return;
|
|
1216
|
+
if (sourceRecord["kind"] !== "tool" || typeof sourceRecord["callId"] !== "string" || sourceRecord["callId"] === "") throw new Error(`${subject} message must have tool source`);
|
|
1217
|
+
const content = messageRecord["content"];
|
|
1218
|
+
const block = content[0];
|
|
1219
|
+
if (content.length !== 1 || typeof block !== "object" || block === null || block["type"] !== "tool-result" || !Array.isArray(block["content"])) throw new Error(`${subject} message must contain one tool-result block`);
|
|
1220
|
+
if (block["toolCallId"] !== sourceRecord["callId"]) throw new Error(`${subject} message has mismatched tool call ids`);
|
|
1221
|
+
}
|
|
1222
|
+
/** Whether an unknown value carries the current provider/model pair. */
|
|
1223
|
+
function hasProviderModel(value) {
|
|
1224
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1225
|
+
const pair = value;
|
|
1226
|
+
return typeof pair["provider"] === "string" && pair["provider"].length > 0 && typeof pair["model"] === "string" && pair["model"].length > 0;
|
|
1227
|
+
}
|
|
1228
|
+
/** Reject request-header vocabulary removed with the legacy delta codec. */
|
|
1229
|
+
function assertSupportedRequestHeader(type, data, location) {
|
|
1230
|
+
if (type === "request/header-delta") throw new Error(`${location} uses unsupported legacy request/header-delta format`);
|
|
1231
|
+
if (type === "request/header" && data !== null && typeof data === "object" && !Array.isArray(data) && data["reason"] === "fallback") throw new Error(`${location} uses unsupported legacy request/header reason "fallback"`);
|
|
1232
|
+
}
|
|
1233
|
+
/** Resolve one listener snapshot, including Cordis's internal dispatch checks. */
|
|
1234
|
+
function collectSessionCallbacks(ctx, args) {
|
|
1235
|
+
return [...ctx.events.dispatch("emit", args)];
|
|
1236
|
+
}
|
|
1237
|
+
/** Invoke one resolved observe-only listener snapshot with per-listener containment. */
|
|
1238
|
+
function invokeContainedSessionObservers(ctx, name, id, args, callbacks) {
|
|
1239
|
+
for (const callback of callbacks) try {
|
|
1240
|
+
const returned = callback(...args);
|
|
1241
|
+
Promise.resolve(returned).catch((error) => {
|
|
1242
|
+
ctx.logger.warn(`session "${id}": ${name} listener rejected: ${String(error)}`);
|
|
1243
|
+
});
|
|
1244
|
+
} catch (error) {
|
|
1245
|
+
ctx.logger.warn(`session "${id}": ${name} listener threw: ${String(error)}`);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
/** Store attachment for the append path; module-private to keep Session store-agnostic publicly. */
|
|
1249
|
+
const attachments = /* @__PURE__ */ new WeakMap();
|
|
1250
|
+
/**
|
|
1251
|
+
* An event-sourced session: an append-only log of {@link SessionEvent}s.
|
|
1252
|
+
*
|
|
1253
|
+
* Plain class (not a Service) — create live instances via
|
|
1254
|
+
* `ctx.sessions.create()` and detached instances via {@link create}.
|
|
1255
|
+
* Seeding with an existing event log replays/forks a session.
|
|
1256
|
+
* @typert object
|
|
1257
|
+
*/
|
|
1258
|
+
var Session = class Session {
|
|
1259
|
+
log = [];
|
|
1260
|
+
/** Single incremental owner of surface acceptance and projection state. */
|
|
1261
|
+
surfaceManager = new SurfaceManager(this.log);
|
|
1262
|
+
/** The ordered surface over this session's event log. */
|
|
1263
|
+
get surface() {
|
|
1264
|
+
return this.surfaceManager;
|
|
1265
|
+
}
|
|
1266
|
+
/**
|
|
1267
|
+
* Detached, deep-frozen creation metadata (format version, cwd, lineage,
|
|
1268
|
+
* seed boundary). Supplied by the store via `ctx.sessions.create()`. When a
|
|
1269
|
+
* `Session` is created without a store-owned header, a minimal header is
|
|
1270
|
+
* synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so
|
|
1271
|
+
* `session.header` is always present. Kept out of the event log — it is a
|
|
1272
|
+
* storage concern, not replayable conversation state.
|
|
1273
|
+
*/
|
|
1274
|
+
header;
|
|
1275
|
+
/** The session identity, derived from its durable header's single copy. */
|
|
1276
|
+
get id() {
|
|
1277
|
+
return this.header.id;
|
|
1278
|
+
}
|
|
1279
|
+
/**
|
|
1280
|
+
* The first seq appended IN THIS PROCESS: the length of the constructor
|
|
1281
|
+
* seed (0 without one). Events with smaller seq values entered through
|
|
1282
|
+
* construction — replay, fork, or resume — and were never published on the
|
|
1283
|
+
* `session/event` firehose (constructor seeds do not emit), so consumers
|
|
1284
|
+
* that replay the log as a publication substitute (telemetry adoption)
|
|
1285
|
+
* start here. Distinct from `header.seedLength`, the DURABLE fork-lineage
|
|
1286
|
+
* boundary: a resumed session's constructor seed is its full stored log,
|
|
1287
|
+
* while its header keeps the original fork value — this field is the
|
|
1288
|
+
* in-process construction fact.
|
|
1289
|
+
*
|
|
1290
|
+
* Not persisted itself: a seeded session projects it into the log as the
|
|
1291
|
+
* `session/end-seed` event, which is what a consumer reading STORED history
|
|
1292
|
+
* reads. Locate the LAST such event, not necessarily one at this seq — a
|
|
1293
|
+
* seed already ending in one is not re-marked, so reopening an untouched
|
|
1294
|
+
* session leaves that event at a smaller seq than `firstLiveSeq`. Prefer
|
|
1295
|
+
* this field in-process: it is exact before the marker reaches storage.
|
|
1296
|
+
*
|
|
1297
|
+
* When this lifecycle appends the marker, it occupies this seq before the
|
|
1298
|
+
* store attaches and therefore does not publish either. Otherwise this seq
|
|
1299
|
+
* holds an ordinary published write.
|
|
1300
|
+
*/
|
|
1301
|
+
firstLiveSeq;
|
|
1302
|
+
/**
|
|
1303
|
+
* Create a detached session by validating and snapshotting borrowed seed
|
|
1304
|
+
* events and storage metadata.
|
|
1305
|
+
* @param id - session identity.
|
|
1306
|
+
* @param seed - optional borrowed replay or fork events.
|
|
1307
|
+
* @param header - optional borrowed storage metadata.
|
|
1308
|
+
* @returns a detached session.
|
|
1309
|
+
*/
|
|
1310
|
+
static create(id, seed, header) {
|
|
1311
|
+
return new Session(id, seed, header);
|
|
1312
|
+
}
|
|
1313
|
+
/**
|
|
1314
|
+
* Restore a detached session by taking ownership of fresh persistence values.
|
|
1315
|
+
* The storage format, event envelopes, sequence continuity, surface transitions,
|
|
1316
|
+
* and header fields are validated before the restored objects are frozen.
|
|
1317
|
+
* @param id - restored session identity.
|
|
1318
|
+
* @param seed - fresh detached events whose ownership is transferred.
|
|
1319
|
+
* @param header - fresh detached metadata whose ownership is transferred.
|
|
1320
|
+
* @returns a restored detached session.
|
|
1321
|
+
*/
|
|
1322
|
+
static fromRestore(id, seed, header) {
|
|
1323
|
+
return new Session(id, seed, header, "restore");
|
|
1324
|
+
}
|
|
1325
|
+
constructor(id, seed, header, mode = "snapshot") {
|
|
1326
|
+
const restoredHeader = mode === "restore" ? validateRestoredSessionHeader(id, header) : void 0;
|
|
1327
|
+
if (seed !== void 0) for (const [index, source] of seed.entries()) {
|
|
1328
|
+
const snapshot = mode === "restore" ? source : snapshotJsonValue(source);
|
|
1329
|
+
if (snapshot === void 0) throw new Error(`seed event at index ${index} is not losslessly JSON-serializable`);
|
|
1330
|
+
assertSessionEventEnvelope(snapshot, index);
|
|
1331
|
+
assertSupportedRequestHeader(snapshot.type, snapshot.data, `seed event at index ${index}`);
|
|
1332
|
+
if (snapshot.seq !== index) throw new Error(`seed event at index ${index} has seq ${snapshot.seq} (expected ${index}); seed must be contiguous from 0`);
|
|
1333
|
+
try {
|
|
1334
|
+
this.surfaceManager.validateNext(snapshot);
|
|
1335
|
+
} catch (error) {
|
|
1336
|
+
throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : "invalid surface metadata"}`);
|
|
1337
|
+
}
|
|
1338
|
+
this.log.push(mode === "restore" ? freezeRestoredObject(snapshot) : deepFreeze(snapshot));
|
|
1339
|
+
}
|
|
1340
|
+
this.firstLiveSeq = this.log.length;
|
|
1341
|
+
this.header = restoredHeader ?? snapshotSessionHeader(id, header);
|
|
1342
|
+
if (seed !== void 0 && this.log.at(-1)?.type !== "session/end-seed") this.append("session/end-seed", {});
|
|
1343
|
+
}
|
|
1344
|
+
/** Cached immutable public snapshot of the private append-only log. */
|
|
1345
|
+
eventsSnapshot;
|
|
1346
|
+
/**
|
|
1347
|
+
* An immutable snapshot of the append-only event log. The snapshot is reused
|
|
1348
|
+
* until the next append; a previously returned array does not grow later.
|
|
1349
|
+
* Events and their nested data are deep-frozen at acceptance, so neither a
|
|
1350
|
+
* cast nor ordinary JavaScript can rewrite durable history.
|
|
1351
|
+
*/
|
|
1352
|
+
get events() {
|
|
1353
|
+
this.eventsSnapshot ??= Object.freeze([...this.log]);
|
|
1354
|
+
return this.eventsSnapshot;
|
|
1355
|
+
}
|
|
1356
|
+
/** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */
|
|
1357
|
+
get seq() {
|
|
1358
|
+
return this.log.length;
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1361
|
+
* Append one typed event to the log and synchronously notify observers via
|
|
1362
|
+
* the store-owned, module-private publication hooks. The hot path never blocks
|
|
1363
|
+
* on I/O — persistence plugins buffer asynchronously. Once the event enters
|
|
1364
|
+
* the log, the append is committed: observer failures are logged and
|
|
1365
|
+
* contained per listener, so they do not change the return value or prevent
|
|
1366
|
+
* later listeners from observing the same accepted event.
|
|
1367
|
+
*
|
|
1368
|
+
* @param type - The event type (key of {@link SessionEventMap}).
|
|
1369
|
+
* @param data - The event payload; must be JSON-serializable.
|
|
1370
|
+
* @param opts - Surface metadata: `surfaceOp` controls how the event enters
|
|
1371
|
+
* the ordered surface; `sourceEventSeqs` lists the seq numbers of earlier
|
|
1372
|
+
* events this one derives from. REQUIRED for
|
|
1373
|
+
* {@link SurfaceEventType} events (every message-producing event must
|
|
1374
|
+
* declare how it joins the surface, the sole source of derived model
|
|
1375
|
+
* history) and
|
|
1376
|
+
* rejected by the compiler for non-surface types like `turn/start` or
|
|
1377
|
+
* `assistant/chunk`.
|
|
1378
|
+
* @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
|
|
1379
|
+
* `data` that entered the log, so reading `event.data` back sees the logged
|
|
1380
|
+
* value, never the caller's still-mutable input.
|
|
1381
|
+
* @throws if `data` or surface metadata is not losslessly JSON-serializable
|
|
1382
|
+
* (BigInt, function, symbol, undefined, negative zero, non-finite number,
|
|
1383
|
+
* circular reference, sparse array, or an exotic object such as
|
|
1384
|
+
* Map/Set/Date/class instance), or when the candidate violates the
|
|
1385
|
+
* canonical surface contract (marker shape and eligibility, unique
|
|
1386
|
+
* earlier source-event references, positional replacement validity, and complete
|
|
1387
|
+
* shadowed-node coverage). One recursive pass reads, validates, and
|
|
1388
|
+
* copies each nested value once, so a stateful getter cannot supply one value
|
|
1389
|
+
* to validation and another to storage. The event log is the durable source
|
|
1390
|
+
* of truth, so a bad event fails at the append site rather than later during
|
|
1391
|
+
* a backend flush. A synchronous internal dispatch validation failure or an
|
|
1392
|
+
* append reentered while this acceptance/publication boundary is open also
|
|
1393
|
+
* rejects before the log changes.
|
|
1394
|
+
*/
|
|
1395
|
+
append(type, data, ...opts) {
|
|
1396
|
+
const surfaceOpts = opts[0];
|
|
1397
|
+
const surfaceMetadata = {
|
|
1398
|
+
...surfaceOpts?.sourceEventSeqs === void 0 ? {} : { sourceEventSeqs: surfaceOpts.sourceEventSeqs },
|
|
1399
|
+
...surfaceOpts?.surfaceOp === void 0 ? {} : { surfaceOp: surfaceOpts.surfaceOp }
|
|
1400
|
+
};
|
|
1401
|
+
const dataSnapshot = snapshotJsonValue(data);
|
|
1402
|
+
if (dataSnapshot === void 0) throw new Error(`session event "${type}" carries non-JSON-serializable data`);
|
|
1403
|
+
assertSupportedRequestHeader(type, dataSnapshot, `session event "${type}"`);
|
|
1404
|
+
const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata);
|
|
1405
|
+
if (surfaceMetadataSnapshot === void 0) throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`);
|
|
1406
|
+
const entry = attachments.get(this);
|
|
1407
|
+
if (entry?.appending) throw new Error("session append cannot reenter while another append is being published");
|
|
1408
|
+
const event = deepFreeze({
|
|
1409
|
+
type,
|
|
1410
|
+
seq: this.log.length,
|
|
1411
|
+
time: Date.now(),
|
|
1412
|
+
data: dataSnapshot,
|
|
1413
|
+
...surfaceMetadataSnapshot
|
|
1414
|
+
});
|
|
1415
|
+
this.surfaceManager.validateNext(event);
|
|
1416
|
+
if (entry !== void 0) entry.appending = true;
|
|
1417
|
+
try {
|
|
1418
|
+
let callbacks;
|
|
1419
|
+
const callbackArgs = [this, event];
|
|
1420
|
+
if (entry !== void 0) callbacks = collectSessionCallbacks(entry.emitCtx, [
|
|
1421
|
+
entry.carrier,
|
|
1422
|
+
"session/event",
|
|
1423
|
+
...callbackArgs
|
|
1424
|
+
]);
|
|
1425
|
+
this.log.push(event);
|
|
1426
|
+
this.eventsSnapshot = void 0;
|
|
1427
|
+
if (callbacks !== void 0 && entry !== void 0) invokeContainedSessionObservers(entry.emitCtx, "session/event", entry.id, callbackArgs, callbacks);
|
|
1428
|
+
return event;
|
|
1429
|
+
} finally {
|
|
1430
|
+
if (entry !== void 0) {
|
|
1431
|
+
entry.appending = false;
|
|
1432
|
+
if (entry.detachRequested && !entry.announcing) entry.detach();
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
/** Cached fold of the request-header events — see {@link requestHeader}. */
|
|
1437
|
+
headerFold;
|
|
1438
|
+
/** Log position (events consumed) the header fold has reached. */
|
|
1439
|
+
headerFoldSeq = 0;
|
|
1440
|
+
/**
|
|
1441
|
+
* The {@link EpochHeader} in force after the log's last header event — the
|
|
1442
|
+
* header the NEXT request will be compared against — or undefined before
|
|
1443
|
+
* the first `request/header` snapshot. The live, incrementally-maintained
|
|
1444
|
+
* form of `foldRequestHeader(session.events)`: each header event is folded
|
|
1445
|
+
* once, when first seen, so a per-step read costs O(new events).
|
|
1446
|
+
* @returns the folded header, or undefined when no header event exists yet.
|
|
1447
|
+
*/
|
|
1448
|
+
requestHeader() {
|
|
1449
|
+
if (this.headerFoldSeq < this.log.length) {
|
|
1450
|
+
this.headerFold = deepFreeze(foldRequestHeader(this.log.slice(this.headerFoldSeq), this.headerFold));
|
|
1451
|
+
this.headerFoldSeq = this.log.length;
|
|
1452
|
+
}
|
|
1453
|
+
return this.headerFold;
|
|
1454
|
+
}
|
|
1455
|
+
/** Cached fold of `request/context` events. */
|
|
1456
|
+
contextFold;
|
|
1457
|
+
contextFoldSeq = 0;
|
|
1458
|
+
/**
|
|
1459
|
+
* Return the latest resolved route metadata, or `undefined` before the first
|
|
1460
|
+
* `request/context` event. Each event is folded once.
|
|
1461
|
+
* @returns the latest immutable route metadata.
|
|
1462
|
+
*/
|
|
1463
|
+
requestContext() {
|
|
1464
|
+
if (this.contextFoldSeq < this.log.length) {
|
|
1465
|
+
for (const event of this.log.slice(this.contextFoldSeq)) if (event.type === "request/context") this.contextFold = deepFreeze({ ...event.data });
|
|
1466
|
+
this.contextFoldSeq = this.log.length;
|
|
1467
|
+
}
|
|
1468
|
+
return this.contextFold;
|
|
1469
|
+
}
|
|
1470
|
+
/** The derived-message cache: frozen projections, extended per unseen node. */
|
|
1471
|
+
derived = [];
|
|
1472
|
+
/** Surface position (nodes projected) the cache has reached. */
|
|
1473
|
+
derivedNodes = 0;
|
|
1474
|
+
/** {@link SurfaceManager.replaceGeneration} the cache was built under. */
|
|
1475
|
+
derivedGeneration = 0;
|
|
1476
|
+
/**
|
|
1477
|
+
* Derive the LLM message history by walking the ordered sequences of
|
|
1478
|
+
* message-producing events maintained by `surfaceOp` markers. The
|
|
1479
|
+
* surface is the single source of derived history: every message-producing
|
|
1480
|
+
* append records its `surfaceOp`, so a raw event with no marker (a chunk, a
|
|
1481
|
+
* turn boundary) is correctly absent, and a compaction `replace` deletes the
|
|
1482
|
+
* shadowed nodes from the derivation. The projection rules are
|
|
1483
|
+
* {@link deriveEventMessage}, folded per node.
|
|
1484
|
+
*
|
|
1485
|
+
* CACHED: each surface node is projected exactly once, when first seen — a
|
|
1486
|
+
* call costs O(new nodes), and a surface rewrite (a `replace`;
|
|
1487
|
+
* {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is
|
|
1488
|
+
* a fresh snapshot per call (later appends never grow an array a caller
|
|
1489
|
+
* already holds); the `Message` objects in it are SHARED and **deep-frozen**.
|
|
1490
|
+
* Their content reuses the already frozen durable event data, so the cache
|
|
1491
|
+
* needs no second deep clone and consumers still cannot mutate the log.
|
|
1492
|
+
* @returns a fresh array of the shared, frozen derived history.
|
|
1493
|
+
*/
|
|
1494
|
+
deriveMessages() {
|
|
1495
|
+
const surface = this.surface;
|
|
1496
|
+
const nodes = surface.nodes;
|
|
1497
|
+
const generation = surface.replaceGeneration;
|
|
1498
|
+
if (generation !== this.derivedGeneration) {
|
|
1499
|
+
this.derived = [];
|
|
1500
|
+
this.derivedNodes = 0;
|
|
1501
|
+
this.derivedGeneration = generation;
|
|
1502
|
+
}
|
|
1503
|
+
for (const seq of nodes.slice(this.derivedNodes)) {
|
|
1504
|
+
const msg = this.deriveEventMessage(this.log[seq]);
|
|
1505
|
+
if (msg) this.derived.push(msg);
|
|
1506
|
+
}
|
|
1507
|
+
this.derivedNodes = nodes.length;
|
|
1508
|
+
return [...this.derived];
|
|
1509
|
+
}
|
|
1510
|
+
/**
|
|
1511
|
+
* Instance face of the pure per-node `deriveEventMessage` export from
|
|
1512
|
+
* `surface.ts`.
|
|
1513
|
+
* @param event - the event to project.
|
|
1514
|
+
* @returns the derived message, or null when the event produces none.
|
|
1515
|
+
*/
|
|
1516
|
+
deriveEventMessage(event) {
|
|
1517
|
+
return deriveEventMessage(event);
|
|
1518
|
+
}
|
|
1519
|
+
};
|
|
1520
|
+
/** Typed error for session fork rejections. */
|
|
1521
|
+
var SessionForkError = class extends Error {
|
|
1522
|
+
code;
|
|
1523
|
+
constructor(message, code) {
|
|
1524
|
+
super(message);
|
|
1525
|
+
this.code = code;
|
|
1526
|
+
this.name = "SessionForkError";
|
|
1527
|
+
}
|
|
1528
|
+
};
|
|
1529
|
+
/**
|
|
1530
|
+
* In-memory session store (`ctx.sessions`).
|
|
1531
|
+
*
|
|
1532
|
+
* Persistence is intentionally not implemented here — persistence plugins
|
|
1533
|
+
* subscribe to `session/event` and flush on `session/flush` / dispose.
|
|
1534
|
+
*/
|
|
1535
|
+
var SessionStore = class extends Service {
|
|
1536
|
+
store = /* @__PURE__ */ new Map();
|
|
1537
|
+
counter = 0;
|
|
1538
|
+
constructor(ctx) {
|
|
1539
|
+
super(ctx, "sessions");
|
|
1540
|
+
ctx.inject(["typert"], (typeCtx) => {
|
|
1541
|
+
typeCtx.typert.lookups.register("session", {
|
|
1542
|
+
parameter: "session",
|
|
1543
|
+
wire: "sessionId",
|
|
1544
|
+
hostTypeSymbol: "@deepseek-ai/dsh-session#Session",
|
|
1545
|
+
wireTypeSymbol: "@deepseek-ai/dsh-session/types#SessionId",
|
|
1546
|
+
resolve: (sessionId) => this.get(sessionId)
|
|
1547
|
+
});
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
/**
|
|
1551
|
+
* Create a session owned by the calling fiber: disposing that fiber stops
|
|
1552
|
+
* event notification and removes the session from the store. `options.seed`
|
|
1553
|
+
* populates the session with a copy of those events (replay/fork);
|
|
1554
|
+
* `options.meta` attaches creation metadata (validated absolute `cwd`, seed
|
|
1555
|
+
* and parent lineage, and delegation depth) as the immutable
|
|
1556
|
+
* {@link SessionHeader} (the store fills `version`/`id`/`createdAt`).
|
|
1557
|
+
*
|
|
1558
|
+
* For an agent whose session must be torn down IN ORDER with its loop (so the
|
|
1559
|
+
* loop's final events are published before the store attachment ends), do NOT use this
|
|
1560
|
+
* — fold the session lifecycle into the agent's own effect via
|
|
1561
|
+
* {@link prepare} + {@link enter} + {@link announce} (see
|
|
1562
|
+
* `dsh-agent-loop`'s creation transaction).
|
|
1563
|
+
*
|
|
1564
|
+
* @param id - the session id; omitted, the store mints `session-<n>`.
|
|
1565
|
+
* @param options - seed events and/or creation metadata for the header.
|
|
1566
|
+
* @returns the live session, already entered and announced.
|
|
1567
|
+
* @throws if a session with `id` already exists, metadata is not a plain
|
|
1568
|
+
* lossless-JSON record with valid scalar fields, or `meta.cwd` is a
|
|
1569
|
+
* non-absolute path (storage backends key directories off it).
|
|
1570
|
+
*/
|
|
1571
|
+
create(id, options) {
|
|
1572
|
+
const session = this.prepare(id, options);
|
|
1573
|
+
this.ctx.effect(function* () {
|
|
1574
|
+
yield this.enter(session);
|
|
1575
|
+
this.announce(session);
|
|
1576
|
+
}.bind(this), "sessions.create()");
|
|
1577
|
+
return session;
|
|
1578
|
+
}
|
|
1579
|
+
/**
|
|
1580
|
+
* Build a session WITHOUT entering it into the store — validate the id/cwd and
|
|
1581
|
+
* construct the {@link Session} (with its immutable {@link SessionHeader}).
|
|
1582
|
+
* Pairs with {@link enter} + {@link announce}: a caller that owns a composite
|
|
1583
|
+
* `ctx.effect` (the agent factory) folds the session lifecycle into that ONE
|
|
1584
|
+
* effect so a fiber unload tears the session + agent down as a single ORDERED
|
|
1585
|
+
* chain rather than as racing sibling effects — which would remove the publication hooks
|
|
1586
|
+
* before the driver's closing events commit, dropping them.
|
|
1587
|
+
*
|
|
1588
|
+
* @param id - the session id; omitted, the store mints `session-<n>`.
|
|
1589
|
+
* @param options - seed events and/or creation metadata for the header. With
|
|
1590
|
+
* `seedSource: 'persistence'`, metadata and events must be fresh detached
|
|
1591
|
+
* graphs whose ownership transfers to this call: they are validated and
|
|
1592
|
+
* frozen in place through {@link Session.fromRestore}, so the caller must
|
|
1593
|
+
* retain no mutable aliases.
|
|
1594
|
+
* @returns the constructed session, NOT yet in the store.
|
|
1595
|
+
* @throws if a session with `id` already exists, metadata is not a plain
|
|
1596
|
+
* lossless-JSON record with valid scalar fields, or `meta.cwd` is a
|
|
1597
|
+
* non-absolute path.
|
|
1598
|
+
*/
|
|
1599
|
+
prepare(id, options) {
|
|
1600
|
+
let sessionId;
|
|
1601
|
+
if (id === void 0) do
|
|
1602
|
+
sessionId = SessionId(`session-${++this.counter}`);
|
|
1603
|
+
while (this.store.has(sessionId));
|
|
1604
|
+
else sessionId = SessionId(id);
|
|
1605
|
+
if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`);
|
|
1606
|
+
if (options?.seedSource === "persistence") return Session.fromRestore(sessionId, options.seed, options.meta);
|
|
1607
|
+
const seed = options?.seed;
|
|
1608
|
+
const meta = options?.meta;
|
|
1609
|
+
const header = {
|
|
1610
|
+
version: 0,
|
|
1611
|
+
id: sessionId,
|
|
1612
|
+
createdAt: meta?.createdAt ?? Date.now(),
|
|
1613
|
+
...meta?.cwd === void 0 ? {} : { cwd: meta.cwd },
|
|
1614
|
+
...meta?.parentSession === void 0 ? {} : { parentSession: meta.parentSession },
|
|
1615
|
+
...meta?.seedLength === void 0 ? {} : { seedLength: meta.seedLength },
|
|
1616
|
+
...meta?.origin === void 0 ? {} : { origin: meta.origin },
|
|
1617
|
+
...meta?.delegationDepth === void 0 ? {} : { delegationDepth: meta.delegationDepth },
|
|
1618
|
+
...meta?.agentPreset === void 0 ? {} : { agentPreset: meta.agentPreset }
|
|
1619
|
+
};
|
|
1620
|
+
return Session.create(sessionId, seed, header);
|
|
1621
|
+
}
|
|
1622
|
+
/**
|
|
1623
|
+
* Enter a {@link prepare}d session into the store: install the module-private
|
|
1624
|
+
* append publication hooks and add it to the store. Returns the DETACH
|
|
1625
|
+
* disposer (hooks + store removal). Does NOT emit `session/created` —
|
|
1626
|
+
* the caller yields this disposer inside its effect and THEN calls
|
|
1627
|
+
* {@link announce}, so a throwing `session/created` listener rolls the attach
|
|
1628
|
+
* back instead of leaking it.
|
|
1629
|
+
*
|
|
1630
|
+
* Re-checks the id for a duplicate: `prepare` and `enter` are public
|
|
1631
|
+
* cross-package primitives and a caller may interleave arbitrary work (or
|
|
1632
|
+
* another create) between them, so a stale prepared session must NOT overwrite
|
|
1633
|
+
* a live store entry of the same id — its detach disposer would later delete
|
|
1634
|
+
* the REAL session. The {@link create} convenience and the agent factory call
|
|
1635
|
+
* the two back-to-back so they never trip this, but the public API cannot
|
|
1636
|
+
* assume that.
|
|
1637
|
+
*
|
|
1638
|
+
* @param session - a {@link prepare}d session not yet in the store.
|
|
1639
|
+
* @returns the detach disposer (publication hooks + store removal). When called from
|
|
1640
|
+
* a synchronous `session/created` listener, removal and disposal wait until
|
|
1641
|
+
* that creation dispatch unwinds.
|
|
1642
|
+
* @throws if a session with this id is already in the store.
|
|
1643
|
+
*/
|
|
1644
|
+
enter(session) {
|
|
1645
|
+
const id = session.id;
|
|
1646
|
+
const carrier = scopeTarget(session, scopeOf(this.ctx));
|
|
1647
|
+
if (this.store.has(id)) throw new Error(`session "${id}" already exists`);
|
|
1648
|
+
if (attachments.has(session)) throw new Error(`session "${id}" is already attached to a store`);
|
|
1649
|
+
const entry = {
|
|
1650
|
+
id,
|
|
1651
|
+
session,
|
|
1652
|
+
carrier,
|
|
1653
|
+
emitCtx: this.ctx,
|
|
1654
|
+
announced: false,
|
|
1655
|
+
announcing: false,
|
|
1656
|
+
appending: false,
|
|
1657
|
+
detachRequested: false,
|
|
1658
|
+
detach: () => {
|
|
1659
|
+
this.detachEntered(entry);
|
|
1660
|
+
}
|
|
1661
|
+
};
|
|
1662
|
+
this.store.set(id, entry);
|
|
1663
|
+
attachments.set(session, entry);
|
|
1664
|
+
let entered = true;
|
|
1665
|
+
const detach = () => {
|
|
1666
|
+
if (!entered) return;
|
|
1667
|
+
entered = false;
|
|
1668
|
+
if (entry.announcing || entry.appending) {
|
|
1669
|
+
entry.detachRequested = true;
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
entry.detach();
|
|
1673
|
+
};
|
|
1674
|
+
return detach;
|
|
1675
|
+
}
|
|
1676
|
+
/** Remove one exact entered session and emit its paired disposal when announced. */
|
|
1677
|
+
detachEntered(entry) {
|
|
1678
|
+
entry.detachRequested = false;
|
|
1679
|
+
/* v8 ignore next -- enter() rejects replacement while this single-shot detach capability is live. */
|
|
1680
|
+
if (this.store.get(entry.id) !== entry) return;
|
|
1681
|
+
this.store.delete(entry.id);
|
|
1682
|
+
attachments.delete(entry.session);
|
|
1683
|
+
if (entry.announced) this.emitDisposed(entry);
|
|
1684
|
+
}
|
|
1685
|
+
/** Emit `session/created` exactly once for an {@link enter}ed session (with
|
|
1686
|
+
* the carrier {@link enter} captured). Separate from {@link enter} so the
|
|
1687
|
+
* caller can yield the detach disposer first (rollback safety — see
|
|
1688
|
+
* {@link enter}).
|
|
1689
|
+
* @param session - the entered session to announce to listeners.
|
|
1690
|
+
* @throws if the session is not live or its announcement already began,
|
|
1691
|
+
* including a reentrant call from a creation listener. */
|
|
1692
|
+
announce(session) {
|
|
1693
|
+
const entry = this.liveEntryFor(session);
|
|
1694
|
+
if (entry.announced || entry.announcing) throw new Error(`session "${entry.id}" was already announced`);
|
|
1695
|
+
entry.announced = true;
|
|
1696
|
+
const callbackArgs = [session];
|
|
1697
|
+
entry.announcing = true;
|
|
1698
|
+
try {
|
|
1699
|
+
const callbacks = collectSessionCallbacks(this.ctx, [
|
|
1700
|
+
entry.carrier,
|
|
1701
|
+
"session/created",
|
|
1702
|
+
session
|
|
1703
|
+
]);
|
|
1704
|
+
for (const callback of callbacks) {
|
|
1705
|
+
const returned = callback(...callbackArgs);
|
|
1706
|
+
Promise.resolve(returned).catch((error) => {
|
|
1707
|
+
this.ctx.logger.warn(`session "${entry.id}": session/created listener rejected: ${String(error)}`);
|
|
1708
|
+
});
|
|
1709
|
+
}
|
|
1710
|
+
} finally {
|
|
1711
|
+
entry.announcing = false;
|
|
1712
|
+
if (entry.detachRequested && !entry.appending) entry.detach();
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
/** Emit the paired teardown notification with per-listener containment. */
|
|
1716
|
+
emitDisposed(entry) {
|
|
1717
|
+
const callbackArgs = [entry.session];
|
|
1718
|
+
try {
|
|
1719
|
+
const callbacks = collectSessionCallbacks(this.ctx, [
|
|
1720
|
+
entry.carrier,
|
|
1721
|
+
"session/disposed",
|
|
1722
|
+
entry.session
|
|
1723
|
+
]);
|
|
1724
|
+
invokeContainedSessionObservers(this.ctx, "session/disposed", entry.id, callbackArgs, callbacks);
|
|
1725
|
+
} catch (error) {
|
|
1726
|
+
this.ctx.logger.warn(`session "${entry.id}": session/disposed dispatch threw: ${String(error)}`);
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
/**
|
|
1730
|
+
* Dispatch the awaited `session/flush` durability checkpoint for `session`,
|
|
1731
|
+
* with the carrier captured at {@link enter}. THE flush entry point: the
|
|
1732
|
+
* store owns the carrier, so callers (the checkpoint policy's per-request
|
|
1733
|
+
* barrier, goal-session's idle checkpoint, teardown drains, and consumers
|
|
1734
|
+
* that flush themselves before reading storage) must come through here
|
|
1735
|
+
* rather than dispatch a raw `ctx.parallel('session/flush', …)` — one owner,
|
|
1736
|
+
* one spelling, and the scoped-dispatch invariant can pin it.
|
|
1737
|
+
* @param session - the session whose buffered events must reach durable storage.
|
|
1738
|
+
* @returns whether at least one durability listener participated, after every
|
|
1739
|
+
* listener has settled successfully.
|
|
1740
|
+
* @throws the first registered listener failure after every listener settles.
|
|
1741
|
+
*/
|
|
1742
|
+
async flush(session) {
|
|
1743
|
+
const { carrier } = this.liveEntryFor(session);
|
|
1744
|
+
const callbackArgs = [session];
|
|
1745
|
+
const callbacks = collectSessionCallbacks(this.ctx, [
|
|
1746
|
+
carrier,
|
|
1747
|
+
"session/flush",
|
|
1748
|
+
session
|
|
1749
|
+
]);
|
|
1750
|
+
const failure = (await Promise.allSettled(callbacks.map((callback) => {
|
|
1751
|
+
try {
|
|
1752
|
+
return callback(...callbackArgs);
|
|
1753
|
+
} catch (error) {
|
|
1754
|
+
return Promise.reject(error);
|
|
1755
|
+
}
|
|
1756
|
+
}))).find((result) => result.status === "rejected");
|
|
1757
|
+
if (failure !== void 0) throw failure.reason;
|
|
1758
|
+
return callbacks.length > 0;
|
|
1759
|
+
}
|
|
1760
|
+
/** Return the exact live entry; detached/prepared objects reject. */
|
|
1761
|
+
liveEntryFor(session) {
|
|
1762
|
+
const entry = attachments.get(session);
|
|
1763
|
+
if (entry === void 0 || this.store.get(entry.id) !== entry) throw new Error(`session "${session.id}" is not live in this store`);
|
|
1764
|
+
return entry;
|
|
1765
|
+
}
|
|
1766
|
+
/**
|
|
1767
|
+
* Look up a live session.
|
|
1768
|
+
* @param id - the session id to look up.
|
|
1769
|
+
* @returns the session, or undefined when no live session has that id.
|
|
1770
|
+
*/
|
|
1771
|
+
get(id) {
|
|
1772
|
+
return this.store.get(id)?.session;
|
|
1773
|
+
}
|
|
1774
|
+
/**
|
|
1775
|
+
* All live sessions, in creation order.
|
|
1776
|
+
* @returns a fresh array; mutating it does not affect the store.
|
|
1777
|
+
*/
|
|
1778
|
+
list() {
|
|
1779
|
+
return [...this.store.values()].map((entry) => entry.session);
|
|
1780
|
+
}
|
|
1781
|
+
/**
|
|
1782
|
+
* Create a live child session from a stable prefix of a live source.
|
|
1783
|
+
* `boundary` is an inclusive source event seq; omitted means the source's
|
|
1784
|
+
* current last event. The selected slice may end with a between-turn event
|
|
1785
|
+
* but must not end inside an open turn.
|
|
1786
|
+
*
|
|
1787
|
+
* @param source - Live source session object or id.
|
|
1788
|
+
* @param boundary - Inclusive source event seq to fork through; omitted means
|
|
1789
|
+
* the source's current last event, and omitted on an empty source forks an
|
|
1790
|
+
* empty child.
|
|
1791
|
+
* @param childSessionId - Optional child session id; omitted delegates to
|
|
1792
|
+
* `SessionStore`'s id policy.
|
|
1793
|
+
* @returns The created live child session.
|
|
1794
|
+
*/
|
|
1795
|
+
fork(source, boundary, childSessionId) {
|
|
1796
|
+
if (childSessionId !== void 0 && this.get(childSessionId) !== void 0) throw new SessionForkError(`session "${childSessionId}" already exists`, "SESSION_ALREADY_EXISTS");
|
|
1797
|
+
const liveSource = this._resolveForkSource(source);
|
|
1798
|
+
const seed = this._forkSeed(liveSource, boundary);
|
|
1799
|
+
return this.create(childSessionId, {
|
|
1800
|
+
seed,
|
|
1801
|
+
meta: {
|
|
1802
|
+
...liveSource.header.cwd !== void 0 ? { cwd: liveSource.header.cwd } : {},
|
|
1803
|
+
parentSession: liveSource.id,
|
|
1804
|
+
seedLength: seed.length
|
|
1805
|
+
}
|
|
1806
|
+
});
|
|
1807
|
+
}
|
|
1808
|
+
_forkSeed(session, requestedBoundary) {
|
|
1809
|
+
const events = session.events;
|
|
1810
|
+
const lastEvent = events.at(-1);
|
|
1811
|
+
let boundary;
|
|
1812
|
+
if (requestedBoundary !== void 0) boundary = requestedBoundary;
|
|
1813
|
+
else {
|
|
1814
|
+
if (lastEvent === void 0) return [];
|
|
1815
|
+
boundary = lastEvent.seq;
|
|
1816
|
+
}
|
|
1817
|
+
if (!Number.isSafeInteger(boundary) || boundary < 0) throw new SessionForkError(`fork boundary for session "${session.id}" must be a non-negative safe integer, got ${String(boundary)}`, "INVALID_BOUNDARY");
|
|
1818
|
+
if (boundary >= events.length) {
|
|
1819
|
+
const lastSeq = events.at(-1)?.seq;
|
|
1820
|
+
throw new SessionForkError(`fork boundary ${boundary} does not exist in session "${session.id}" (last seq: ${lastSeq ?? "none"})`, "INVALID_BOUNDARY");
|
|
1821
|
+
}
|
|
1822
|
+
const boundaryEvent = events[boundary];
|
|
1823
|
+
if (boundaryEvent === void 0 || boundaryEvent.seq !== boundary) throw new SessionForkError(`fork boundary ${boundary} does not match a contiguous event seq in session "${session.id}"`, "INVALID_BOUNDARY");
|
|
1824
|
+
const lastTurnBoundary = events.slice(0, boundary + 1).findLast((event) => event.type === "turn/start" || event.type === "turn/end");
|
|
1825
|
+
if (lastTurnBoundary?.type === "turn/start") throw new SessionForkError(`fork boundary ${boundary} in session "${session.id}" ends inside open turn ${lastTurnBoundary.data.turn}`, "OPEN_TURN");
|
|
1826
|
+
return events.slice(0, boundary + 1);
|
|
1827
|
+
}
|
|
1828
|
+
_resolveForkSource(source) {
|
|
1829
|
+
if (typeof source === "string") {
|
|
1830
|
+
const session = this.get(source);
|
|
1831
|
+
if (session === void 0) throw new SessionForkError(`session "${source}" not found`, "SESSION_NOT_FOUND");
|
|
1832
|
+
return session;
|
|
1833
|
+
}
|
|
1834
|
+
const live = this.get(source.id);
|
|
1835
|
+
if (live === void 0) throw new SessionForkError(`session "${source.id}" not found`, "SESSION_NOT_FOUND");
|
|
1836
|
+
if (live !== source) throw new SessionForkError(`session "${source.id}" is not the live store instance`, "SESSION_NOT_LIVE");
|
|
1837
|
+
return source;
|
|
1838
|
+
}
|
|
1839
|
+
};
|
|
1840
|
+
//#endregion
|
|
1841
|
+
export { SESSION_FORMAT_VERSION, Session, SessionForkError, SessionId, SessionPreparation, SessionStore, SessionStore as default, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN, adoptSessionEvent, canonicalHeader, decodeStorageRecord, deriveEventMessage, findLastMessageTurnEnd, foldRequestHeader, foldSurface, headerEquals, interruptedTurnClosers, isAppendSurfaceEvent, isJsonValue, isReplacementSurfaceEvent, isSurfaceEligibleType, isSurfaceEvent, lastActivityTime, packChunkRuns, snapshotJsonValue, snapshotSessionEvent };
|