@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/invariant.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { assertNever } from "@deepseek-ai/dsh-llm";
|
|
2
|
+
//#endregion
|
|
3
|
+
//#region lib/types/invariant.js
|
|
4
|
+
/**
|
|
5
|
+
* Package-owned relational invariants for the session event log. Load this
|
|
6
|
+
* companion beside `@deepseek-ai/dsh-invariants` to enable the checks.
|
|
7
|
+
*
|
|
8
|
+
* @module @deepseek-ai/dsh-session/invariant
|
|
9
|
+
*/
|
|
10
|
+
const PACKAGE_NAME = "@deepseek-ai/dsh-session";
|
|
11
|
+
/** Cordis companion plugin name. */
|
|
12
|
+
const name = "session-invariant";
|
|
13
|
+
/** Service required before the companion can reserve package ownership. */
|
|
14
|
+
const inject = ["invariants"];
|
|
15
|
+
/** Assert that a step-scoped event names the currently open turn and step. */
|
|
16
|
+
function requireOpenStep(trace, kind, turn, step, fail) {
|
|
17
|
+
if (trace.openTurn !== turn || trace.openStep !== step) fail(`${kind} names turn ${turn}/step ${step} but open is turn ${trace.openTurn}/step ${trace.openStep}`);
|
|
18
|
+
}
|
|
19
|
+
/** Validate one candidate event without mutating the committed trace. */
|
|
20
|
+
function validateEvent(trace, event, fail) {
|
|
21
|
+
if (event.seq <= trace.lastSeq) fail(`seq must strictly increase: saw ${event.seq} after ${trace.lastSeq}`);
|
|
22
|
+
let openTurn = trace.openTurn;
|
|
23
|
+
let openStep = trace.openStep;
|
|
24
|
+
let nextTurn = trace.nextTurn;
|
|
25
|
+
let nextStep = trace.nextStep;
|
|
26
|
+
let pendingCalls = { kind: "none" };
|
|
27
|
+
switch (event.type) {
|
|
28
|
+
case "turn/start":
|
|
29
|
+
if (trace.openTurn !== null) fail(`turn/start ${event.data.turn} while turn ${trace.openTurn} is still open`);
|
|
30
|
+
if (event.data.turn !== trace.nextTurn) fail(`turn/start expected turn ${trace.nextTurn}, got ${event.data.turn}`);
|
|
31
|
+
openTurn = event.data.turn;
|
|
32
|
+
nextStep = 1;
|
|
33
|
+
break;
|
|
34
|
+
case "turn/end":
|
|
35
|
+
if (trace.openTurn !== event.data.turn) fail(`turn/end ${event.data.turn} does not match open turn ${trace.openTurn}`);
|
|
36
|
+
if (trace.openStep !== null) fail(`turn/end ${event.data.turn} while step ${trace.openStep} is still open`);
|
|
37
|
+
openTurn = null;
|
|
38
|
+
nextTurn += 1;
|
|
39
|
+
break;
|
|
40
|
+
case "step/start":
|
|
41
|
+
if (trace.openTurn !== event.data.turn) fail(`step/start in turn ${event.data.turn} but open turn is ${trace.openTurn}`);
|
|
42
|
+
if (trace.openStep !== null) fail(`step/start ${event.data.step} while step ${trace.openStep} is still open`);
|
|
43
|
+
if (event.data.step !== trace.nextStep) fail(`step/start expected step ${trace.nextStep} in turn ${event.data.turn}, got ${event.data.step}`);
|
|
44
|
+
openStep = event.data.step;
|
|
45
|
+
break;
|
|
46
|
+
case "step/end":
|
|
47
|
+
requireOpenStep(trace, "step/end", event.data.turn, event.data.step, fail);
|
|
48
|
+
pendingCalls = { kind: "clear" };
|
|
49
|
+
openStep = null;
|
|
50
|
+
nextStep += 1;
|
|
51
|
+
break;
|
|
52
|
+
case "assistant/chunk":
|
|
53
|
+
requireOpenStep(trace, "assistant/chunk", event.data.turn, event.data.step, fail);
|
|
54
|
+
break;
|
|
55
|
+
case "assistant/message":
|
|
56
|
+
requireOpenStep(trace, "assistant/message", event.data.turn, event.data.step, fail);
|
|
57
|
+
break;
|
|
58
|
+
case "tool/call":
|
|
59
|
+
requireOpenStep(trace, "tool/call", event.data.turn, event.data.step, fail);
|
|
60
|
+
pendingCalls = {
|
|
61
|
+
kind: "add",
|
|
62
|
+
callId: event.data.callId
|
|
63
|
+
};
|
|
64
|
+
break;
|
|
65
|
+
case "tool/result": {
|
|
66
|
+
if (event.surfaceOp !== "append") {
|
|
67
|
+
if (trace.openTurn === null) fail("tool/result surface replacement appended outside any open turn");
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
requireOpenStep(trace, "tool/result", event.data.turn, event.data.step, fail);
|
|
71
|
+
const callId = event.data.message.source.callId;
|
|
72
|
+
const syntheticNotStarted = event.data.message.content[0].isError === true && event.data.error?.code === "TOOL_NOT_STARTED";
|
|
73
|
+
if (!trace.pendingCalls.has(callId) && !syntheticNotStarted) fail(`tool/result for ${callId} with no prior tool/call in this step`);
|
|
74
|
+
pendingCalls = {
|
|
75
|
+
kind: "delete",
|
|
76
|
+
callId
|
|
77
|
+
};
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
case "user/message": break;
|
|
81
|
+
case "session/end-seed": break;
|
|
82
|
+
case "todo/write":
|
|
83
|
+
case "request/header":
|
|
84
|
+
case "request/context":
|
|
85
|
+
if (trace.openTurn === null) fail(`${event.type} appended outside any open turn (core execution events must be turn-enclosed)`);
|
|
86
|
+
break;
|
|
87
|
+
default: break;
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
scalars: {
|
|
91
|
+
lastSeq: event.seq,
|
|
92
|
+
openTurn,
|
|
93
|
+
openStep,
|
|
94
|
+
nextTurn,
|
|
95
|
+
nextStep
|
|
96
|
+
},
|
|
97
|
+
pendingCalls
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** Apply one already-validated transition after its event commits. */
|
|
101
|
+
function applyTransition(trace, transition) {
|
|
102
|
+
Object.assign(trace, transition.scalars);
|
|
103
|
+
switch (transition.pendingCalls.kind) {
|
|
104
|
+
case "none": break;
|
|
105
|
+
case "add":
|
|
106
|
+
trace.pendingCalls.add(transition.pendingCalls.callId);
|
|
107
|
+
break;
|
|
108
|
+
case "delete":
|
|
109
|
+
trace.pendingCalls.delete(transition.pendingCalls.callId);
|
|
110
|
+
break;
|
|
111
|
+
case "clear":
|
|
112
|
+
trace.pendingCalls.clear();
|
|
113
|
+
break;
|
|
114
|
+
/* v8 ignore next -- validateEvent produces this closed transition union */
|
|
115
|
+
default: assertNever(transition.pendingCalls, "session trace pending-call transition");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Install the session contribution into its child registration fiber. */
|
|
119
|
+
const install = Object.assign((ctx, fail) => {
|
|
120
|
+
const traces = /* @__PURE__ */ new WeakMap();
|
|
121
|
+
const stagedTransitions = /* @__PURE__ */ new WeakMap();
|
|
122
|
+
const freshTrace = () => ({
|
|
123
|
+
lastSeq: -1,
|
|
124
|
+
openTurn: null,
|
|
125
|
+
openStep: null,
|
|
126
|
+
nextTurn: 1,
|
|
127
|
+
nextStep: 1,
|
|
128
|
+
pendingCalls: /* @__PURE__ */ new Set()
|
|
129
|
+
});
|
|
130
|
+
const seedSession = (session) => {
|
|
131
|
+
const trace = freshTrace();
|
|
132
|
+
traces.set(session, trace);
|
|
133
|
+
for (const event of session.events) applyTransition(trace, validateEvent(trace, event, fail));
|
|
134
|
+
return trace;
|
|
135
|
+
};
|
|
136
|
+
/* v8 ignore next -- session/event always follows list() or session/created seeding */
|
|
137
|
+
const traceFor = (session) => traces.get(session) ?? seedSession(session);
|
|
138
|
+
for (const session of ctx.sessions.list()) seedSession(session);
|
|
139
|
+
ctx.on("session/created", (session) => {
|
|
140
|
+
seedSession(session);
|
|
141
|
+
}, { global: true });
|
|
142
|
+
ctx.on("session/event", (session, event) => {
|
|
143
|
+
const staged = stagedTransitions.get(event);
|
|
144
|
+
/* v8 ignore next 2 -- internal/dispatch stages the exact callback arguments */
|
|
145
|
+
if (staged === void 0 || staged.session !== session) return fail("session/event reached publication without matching pre-commit validation");
|
|
146
|
+
stagedTransitions.delete(event);
|
|
147
|
+
applyTransition(staged.trace, staged.transition);
|
|
148
|
+
}, { global: true });
|
|
149
|
+
ctx.on("internal/dispatch", (_mode, eventName, args) => {
|
|
150
|
+
if (eventName !== "session/event") return;
|
|
151
|
+
const [session, event] = args;
|
|
152
|
+
const trace = traceFor(session);
|
|
153
|
+
const transition = validateEvent(trace, event, fail);
|
|
154
|
+
stagedTransitions.set(event, {
|
|
155
|
+
session,
|
|
156
|
+
trace,
|
|
157
|
+
transition
|
|
158
|
+
});
|
|
159
|
+
}, { global: true });
|
|
160
|
+
}, { inject: ["sessions"] });
|
|
161
|
+
/**
|
|
162
|
+
* Register the session invariant companion.
|
|
163
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
164
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
165
|
+
*/
|
|
166
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
167
|
+
//#endregion
|
|
168
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lossless storage packing for `assistant/chunk` delta runs. Providers stream
|
|
3
|
+
* token-sized deltas, so a log stores hundreds of near-identical event lines
|
|
4
|
+
* whose JSON envelopes dwarf their payloads (~56× measured on a real DeepSeek
|
|
5
|
+
* session). This module packs each run of consecutive same-block delta chunks
|
|
6
|
+
* into ONE storage row — `text-chunks`, `reasoning-chunks`, or
|
|
7
|
+
* `tool-call-chunks` — and expands rows back to the exact original events.
|
|
8
|
+
*
|
|
9
|
+
* Storage rows are a durable-encoding vocabulary, NOT session events: they
|
|
10
|
+
* never enter `Session.events`, have no `SessionEventMap` entry, and use bare
|
|
11
|
+
* (slash-less) type tags so a reader cannot confuse them with the event
|
|
12
|
+
* taxonomy (precedent: the JSONL header line's `session` tag). The encoder
|
|
13
|
+
* whitelists exact shapes — anything it does not fully recognize is stored
|
|
14
|
+
* verbatim, so unknown fields or future chunk variants lose compression, never
|
|
15
|
+
* data. The decoder validates before expanding and fails loud on a malformed
|
|
16
|
+
* row-tagged value instead of silently dropping a whole run.
|
|
17
|
+
*
|
|
18
|
+
* @module @deepseek-ai/dsh-session/chunk-rows
|
|
19
|
+
*/
|
|
20
|
+
import { CallId } from '@deepseek-ai/dsh-llm';
|
|
21
|
+
import type { SessionEvent } from './types.ts';
|
|
22
|
+
/**
|
|
23
|
+
* Fields shared by every packed run: placement, block correlation, and member
|
|
24
|
+
* timestamps as gaps. Member `k` reconstructs as seq `seq0 + k` and time
|
|
25
|
+
* `time0` plus the first `k` gaps; a gap may be negative when the wall clock
|
|
26
|
+
* stepped backwards between events.
|
|
27
|
+
*/
|
|
28
|
+
interface RunDataBase {
|
|
29
|
+
turn: number;
|
|
30
|
+
step: number;
|
|
31
|
+
/** The stream block index every member shares. */
|
|
32
|
+
index: number;
|
|
33
|
+
/** Epoch-ms gaps between consecutive members; length is one less than the member count. */
|
|
34
|
+
dt: number[];
|
|
35
|
+
}
|
|
36
|
+
/** Payload of a `text-chunks`/`reasoning-chunks` row: one entry per member, never joined — token boundaries are data. */
|
|
37
|
+
interface TextRunData extends RunDataBase {
|
|
38
|
+
texts: string[];
|
|
39
|
+
}
|
|
40
|
+
/** Payload of a `tool-call-chunks` row: the run-constant call identity plus each member's raw arguments fragment. */
|
|
41
|
+
interface ToolCallRunData extends RunDataBase {
|
|
42
|
+
id: CallId;
|
|
43
|
+
/** Present iff every member carried it, with one uniform value (a mixed run never packs). */
|
|
44
|
+
name?: string;
|
|
45
|
+
args: string[];
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A packed run of consecutive delta chunk events, discriminated on `type`.
|
|
49
|
+
* `seq0`/`time0` anchor the first member; text and reasoning rows share the
|
|
50
|
+
* {@link TextRunData} payload, tool-call rows carry {@link ToolCallRunData}.
|
|
51
|
+
*/
|
|
52
|
+
export type ChunkRow = {
|
|
53
|
+
type: 'text-chunks';
|
|
54
|
+
seq0: number;
|
|
55
|
+
time0: number;
|
|
56
|
+
data: TextRunData;
|
|
57
|
+
} | {
|
|
58
|
+
type: 'reasoning-chunks';
|
|
59
|
+
seq0: number;
|
|
60
|
+
time0: number;
|
|
61
|
+
data: TextRunData;
|
|
62
|
+
} | {
|
|
63
|
+
type: 'tool-call-chunks';
|
|
64
|
+
seq0: number;
|
|
65
|
+
time0: number;
|
|
66
|
+
data: ToolCallRunData;
|
|
67
|
+
};
|
|
68
|
+
/** One durable log line's JSON value: a session event verbatim, or a packed chunk row. */
|
|
69
|
+
export type StorageRecord = SessionEvent | ChunkRow;
|
|
70
|
+
/**
|
|
71
|
+
* Pack an event batch for storage: each run of at least {@link MIN_RUN}
|
|
72
|
+
* consecutive whitelisted same-kind, same-block delta chunk events becomes one
|
|
73
|
+
* {@link ChunkRow}; every other event passes through verbatim, in order.
|
|
74
|
+
* Pure and stateless — safe over any array, including a batch whose runs were
|
|
75
|
+
* split by flush boundaries (the split runs simply pack per batch).
|
|
76
|
+
*
|
|
77
|
+
* @param events - the batch to encode, in log order.
|
|
78
|
+
* @returns the storage records to write, one JSONL line each.
|
|
79
|
+
*/
|
|
80
|
+
export declare function packChunkRuns(events: readonly SessionEvent[]): StorageRecord[];
|
|
81
|
+
/**
|
|
82
|
+
* Decode one parsed JSONL line value into the session event(s) it stores.
|
|
83
|
+
* Chunk-row-tagged values validate and expand (a malformed row throws — it is
|
|
84
|
+
* corrupt storage, and treating it as an event would silently drop a whole
|
|
85
|
+
* run); every other value passes through as a single event, unvalidated.
|
|
86
|
+
*
|
|
87
|
+
* @param value - one line's `JSON.parse` result.
|
|
88
|
+
* @returns the stored events, in log order.
|
|
89
|
+
*/
|
|
90
|
+
export declare function decodeStorageRecord(value: unknown): SessionEvent[];
|
|
91
|
+
export {};
|
|
92
|
+
//# sourceMappingURL=chunk-rows.d.ts.map
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lossless storage packing for `assistant/chunk` delta runs. Providers stream
|
|
3
|
+
* token-sized deltas, so a log stores hundreds of near-identical event lines
|
|
4
|
+
* whose JSON envelopes dwarf their payloads (~56× measured on a real DeepSeek
|
|
5
|
+
* session). This module packs each run of consecutive same-block delta chunks
|
|
6
|
+
* into ONE storage row — `text-chunks`, `reasoning-chunks`, or
|
|
7
|
+
* `tool-call-chunks` — and expands rows back to the exact original events.
|
|
8
|
+
*
|
|
9
|
+
* Storage rows are a durable-encoding vocabulary, NOT session events: they
|
|
10
|
+
* never enter `Session.events`, have no `SessionEventMap` entry, and use bare
|
|
11
|
+
* (slash-less) type tags so a reader cannot confuse them with the event
|
|
12
|
+
* taxonomy (precedent: the JSONL header line's `session` tag). The encoder
|
|
13
|
+
* whitelists exact shapes — anything it does not fully recognize is stored
|
|
14
|
+
* verbatim, so unknown fields or future chunk variants lose compression, never
|
|
15
|
+
* data. The decoder validates before expanding and fails loud on a malformed
|
|
16
|
+
* row-tagged value instead of silently dropping a whole run.
|
|
17
|
+
*
|
|
18
|
+
* @module @deepseek-ai/dsh-session/chunk-rows
|
|
19
|
+
*/
|
|
20
|
+
import { CallId, assertNever } from '@deepseek-ai/dsh-llm';
|
|
21
|
+
/**
|
|
22
|
+
* Minimum members before a run packs. Below it a row's envelope rivals the
|
|
23
|
+
* event lines it replaces. A format constant, not a tunable: both layouts
|
|
24
|
+
* decode identically, so changing it never invalidates stored logs.
|
|
25
|
+
*/
|
|
26
|
+
const MIN_RUN = 3;
|
|
27
|
+
function isRecord(value) {
|
|
28
|
+
return typeof value === 'object' && value !== null;
|
|
29
|
+
}
|
|
30
|
+
/** Exact-key check: `value` has every key in `keys` and nothing else. */
|
|
31
|
+
function hasExactKeys(value, keys) {
|
|
32
|
+
return Object.keys(value).length === keys.length && keys.every(k => Object.hasOwn(value, k));
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Classify an event for packing: its delta kind when the ENTIRE shape
|
|
36
|
+
* (envelope, data, chunk — exact keys, primitive types, integer seq/time) is
|
|
37
|
+
* whitelisted, else `undefined` (store verbatim). Inputs come from live typed
|
|
38
|
+
* appends AND parsed fixture files, so the checks are structural, not
|
|
39
|
+
* type-trusted. Integer times keep gap encoding exact: a fractional time would
|
|
40
|
+
* reconstruct through float subtraction/addition, which need not round-trip.
|
|
41
|
+
*/
|
|
42
|
+
function classify(event) {
|
|
43
|
+
if (event.type !== 'assistant/chunk')
|
|
44
|
+
return undefined;
|
|
45
|
+
if (!hasExactKeys(event, ['type', 'seq', 'time', 'data']))
|
|
46
|
+
return undefined;
|
|
47
|
+
if (!Number.isSafeInteger(event.seq) || event.seq < 0 || !Number.isSafeInteger(event.time))
|
|
48
|
+
return undefined;
|
|
49
|
+
const data = event.data;
|
|
50
|
+
if (!isRecord(data) || !hasExactKeys(data, ['turn', 'step', 'chunk']))
|
|
51
|
+
return undefined;
|
|
52
|
+
if (typeof data.turn !== 'number' || typeof data.step !== 'number')
|
|
53
|
+
return undefined;
|
|
54
|
+
const chunk = data.chunk;
|
|
55
|
+
if (!isRecord(chunk) || typeof chunk.index !== 'number')
|
|
56
|
+
return undefined;
|
|
57
|
+
switch (chunk.type) {
|
|
58
|
+
case 'text-delta':
|
|
59
|
+
case 'reasoning-delta':
|
|
60
|
+
return hasExactKeys(chunk, ['type', 'index', 'text']) && typeof chunk.text === 'string'
|
|
61
|
+
? chunk.type
|
|
62
|
+
: undefined;
|
|
63
|
+
case 'tool-call-delta': {
|
|
64
|
+
const shapeOk = hasExactKeys(chunk, ['type', 'index', 'id', 'argumentsDelta'])
|
|
65
|
+
|| (hasExactKeys(chunk, ['type', 'index', 'id', 'name', 'argumentsDelta']) && typeof chunk.name === 'string');
|
|
66
|
+
return shapeOk && typeof chunk.id === 'string' && typeof chunk.argumentsDelta === 'string'
|
|
67
|
+
? chunk.type
|
|
68
|
+
: undefined;
|
|
69
|
+
}
|
|
70
|
+
// Whitelist fall-through over parsed data: block-start/end, usage, finish,
|
|
71
|
+
// and any future chunk variant stay one event per line.
|
|
72
|
+
default:
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** The tool-call fields of a whitelisted delta chunk (only after {@link classify} returned `'tool-call-delta'`). */
|
|
77
|
+
function toolCallOf(event) {
|
|
78
|
+
return event.data.chunk;
|
|
79
|
+
}
|
|
80
|
+
/** The block index of a whitelisted delta chunk (not every {@link StreamChunk} variant carries one). */
|
|
81
|
+
function indexOf(event) {
|
|
82
|
+
return event.data.chunk.index;
|
|
83
|
+
}
|
|
84
|
+
/** Whether `next` extends a run ending in `prev` (same kind already checked by the caller). */
|
|
85
|
+
function continues(prev, next, kind) {
|
|
86
|
+
if (next.seq !== prev.seq + 1)
|
|
87
|
+
return false;
|
|
88
|
+
// Two safe-integer times can sit further apart than a double subtracts
|
|
89
|
+
// exactly (2^53-1 and its negation differ by ~2^54); a rounded gap would
|
|
90
|
+
// decode to a different timestamp. The check is exact in both directions: a
|
|
91
|
+
// true gap within safe range subtracts without rounding and passes, while a
|
|
92
|
+
// true gap beyond it rounds to a value that is itself beyond and fails.
|
|
93
|
+
if (!Number.isSafeInteger(next.time - prev.time))
|
|
94
|
+
return false;
|
|
95
|
+
if (next.data.turn !== prev.data.turn || next.data.step !== prev.data.step)
|
|
96
|
+
return false;
|
|
97
|
+
if (indexOf(next) !== indexOf(prev))
|
|
98
|
+
return false;
|
|
99
|
+
if (kind !== 'tool-call-delta')
|
|
100
|
+
return true;
|
|
101
|
+
const a = toolCallOf(prev);
|
|
102
|
+
const b = toolCallOf(next);
|
|
103
|
+
// `name` must match in presence AND value — a mixed run is not representable.
|
|
104
|
+
return a.id === b.id && Object.hasOwn(a, 'name') === Object.hasOwn(b, 'name') && a.name === b.name;
|
|
105
|
+
}
|
|
106
|
+
/** Build the row for a completed run (`run.length >= MIN_RUN`, uniform per {@link continues}). */
|
|
107
|
+
function buildRow(kind, run) {
|
|
108
|
+
const first = run[0];
|
|
109
|
+
const base = {
|
|
110
|
+
turn: first.data.turn,
|
|
111
|
+
step: first.data.step,
|
|
112
|
+
index: indexOf(first),
|
|
113
|
+
dt: run.slice(1).map((event, i) => event.time - run[i].time),
|
|
114
|
+
};
|
|
115
|
+
const envelope = { seq0: first.seq, time0: first.time };
|
|
116
|
+
if (kind === 'tool-call-delta') {
|
|
117
|
+
const call = toolCallOf(first);
|
|
118
|
+
return {
|
|
119
|
+
type: 'tool-call-chunks',
|
|
120
|
+
...envelope,
|
|
121
|
+
data: {
|
|
122
|
+
...base,
|
|
123
|
+
id: CallId(call.id),
|
|
124
|
+
...Object.hasOwn(call, 'name') ? { name: call.name } : {},
|
|
125
|
+
args: run.map(event => event.data.chunk.argumentsDelta),
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const data = { ...base, texts: run.map(event => event.data.chunk.text) };
|
|
130
|
+
return kind === 'text-delta'
|
|
131
|
+
? { type: 'text-chunks', ...envelope, data }
|
|
132
|
+
: { type: 'reasoning-chunks', ...envelope, data };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Pack an event batch for storage: each run of at least {@link MIN_RUN}
|
|
136
|
+
* consecutive whitelisted same-kind, same-block delta chunk events becomes one
|
|
137
|
+
* {@link ChunkRow}; every other event passes through verbatim, in order.
|
|
138
|
+
* Pure and stateless — safe over any array, including a batch whose runs were
|
|
139
|
+
* split by flush boundaries (the split runs simply pack per batch).
|
|
140
|
+
*
|
|
141
|
+
* @param events - the batch to encode, in log order.
|
|
142
|
+
* @returns the storage records to write, one JSONL line each.
|
|
143
|
+
*/
|
|
144
|
+
export function packChunkRuns(events) {
|
|
145
|
+
const out = [];
|
|
146
|
+
let kind;
|
|
147
|
+
let run = [];
|
|
148
|
+
const flush = () => {
|
|
149
|
+
if (kind !== undefined && run.length >= MIN_RUN)
|
|
150
|
+
out.push(buildRow(kind, run));
|
|
151
|
+
else
|
|
152
|
+
out.push(...run);
|
|
153
|
+
kind = undefined;
|
|
154
|
+
run = [];
|
|
155
|
+
};
|
|
156
|
+
for (const event of events) {
|
|
157
|
+
const k = classify(event);
|
|
158
|
+
if (k === undefined) {
|
|
159
|
+
flush();
|
|
160
|
+
out.push(event);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const delta = event;
|
|
164
|
+
const last = run[run.length - 1];
|
|
165
|
+
if (k === kind && last !== undefined && continues(last, delta, k)) {
|
|
166
|
+
run.push(delta);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
flush();
|
|
170
|
+
kind = k;
|
|
171
|
+
run = [delta];
|
|
172
|
+
}
|
|
173
|
+
flush();
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
/** Throw the uniform malformed-row diagnostic. */
|
|
177
|
+
function malformed(tag, why) {
|
|
178
|
+
throw new Error(`malformed ${tag} storage row: ${why}`);
|
|
179
|
+
}
|
|
180
|
+
/** Validate the shared run-data fields and the payload/dt arity; returns the member payload. */
|
|
181
|
+
function validateRunData(tag, data, payloadKey) {
|
|
182
|
+
if (typeof data.turn !== 'number' || typeof data.step !== 'number' || typeof data.index !== 'number') {
|
|
183
|
+
malformed(tag, 'turn/step/index must be numbers');
|
|
184
|
+
}
|
|
185
|
+
const payload = data[payloadKey];
|
|
186
|
+
if (!Array.isArray(payload) || payload.length === 0 || payload.some(entry => typeof entry !== 'string')) {
|
|
187
|
+
malformed(tag, `${payloadKey} must be a non-empty string array`);
|
|
188
|
+
}
|
|
189
|
+
const dt = data.dt;
|
|
190
|
+
if (!Array.isArray(dt) || dt.some(gap => !Number.isSafeInteger(gap))) {
|
|
191
|
+
malformed(tag, 'dt must be an array of safe integers');
|
|
192
|
+
}
|
|
193
|
+
if (dt.length !== payload.length - 1) {
|
|
194
|
+
malformed(tag, `dt length ${dt.length} does not match ${payload.length} members`);
|
|
195
|
+
}
|
|
196
|
+
return payload;
|
|
197
|
+
}
|
|
198
|
+
/** Validate a row-tagged parsed value's envelope and data, throwing on any malformation. */
|
|
199
|
+
function validateRow(value, tag) {
|
|
200
|
+
if (!hasExactKeys(value, ['type', 'seq0', 'time0', 'data'])) {
|
|
201
|
+
malformed(tag, 'envelope must be exactly {type, seq0, time0, data}');
|
|
202
|
+
}
|
|
203
|
+
if (!Number.isSafeInteger(value.seq0) || value.seq0 < 0) {
|
|
204
|
+
malformed(tag, 'seq0 must be a non-negative safe integer');
|
|
205
|
+
}
|
|
206
|
+
if (!Number.isSafeInteger(value.time0)) {
|
|
207
|
+
malformed(tag, 'time0 must be a safe integer');
|
|
208
|
+
}
|
|
209
|
+
const data = value.data;
|
|
210
|
+
if (!isRecord(data))
|
|
211
|
+
malformed(tag, 'data must be an object');
|
|
212
|
+
let payload;
|
|
213
|
+
if (tag === 'tool-call-chunks') {
|
|
214
|
+
const withName = hasExactKeys(data, ['turn', 'step', 'index', 'id', 'name', 'dt', 'args']);
|
|
215
|
+
if (!withName && !hasExactKeys(data, ['turn', 'step', 'index', 'id', 'dt', 'args'])) {
|
|
216
|
+
malformed(tag, 'data must be exactly {turn, step, index, id, name?, dt, args}');
|
|
217
|
+
}
|
|
218
|
+
if (typeof data.id !== 'string' || (withName && typeof data.name !== 'string')) {
|
|
219
|
+
malformed(tag, 'id (and name when present) must be strings');
|
|
220
|
+
}
|
|
221
|
+
payload = validateRunData(tag, data, 'args');
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
if (!hasExactKeys(data, ['turn', 'step', 'index', 'dt', 'texts'])) {
|
|
225
|
+
malformed(tag, 'data must be exactly {turn, step, index, dt, texts}');
|
|
226
|
+
}
|
|
227
|
+
payload = validateRunData(tag, data, 'texts');
|
|
228
|
+
}
|
|
229
|
+
// Reconstruction bounds. The encoder only packs runs whose member seqs and
|
|
230
|
+
// times are all safe integers, so a running value that leaves safe range is
|
|
231
|
+
// outside any encoder's image: float arithmetic would round it to a
|
|
232
|
+
// different number than exact arithmetic, a silent corruption. Within safe
|
|
233
|
+
// range every step is exact, so the first departure is always caught.
|
|
234
|
+
if (!Number.isSafeInteger(value.seq0 + payload.length - 1)) {
|
|
235
|
+
malformed(tag, 'member seqs must stay safe integers');
|
|
236
|
+
}
|
|
237
|
+
let time = value.time0;
|
|
238
|
+
for (const gap of data.dt) {
|
|
239
|
+
time += gap;
|
|
240
|
+
if (!Number.isSafeInteger(time))
|
|
241
|
+
malformed(tag, 'member times must stay safe integers');
|
|
242
|
+
}
|
|
243
|
+
return value;
|
|
244
|
+
}
|
|
245
|
+
/** Expand a validated row back into its exact original events, in order. */
|
|
246
|
+
function expandRow(row) {
|
|
247
|
+
const members = row.type === 'tool-call-chunks' ? row.data.args : row.data.texts;
|
|
248
|
+
const events = [];
|
|
249
|
+
let time = row.time0;
|
|
250
|
+
for (let k = 0; k < members.length; k++) {
|
|
251
|
+
if (k > 0)
|
|
252
|
+
time += row.data.dt[k - 1];
|
|
253
|
+
let chunk;
|
|
254
|
+
switch (row.type) {
|
|
255
|
+
case 'text-chunks':
|
|
256
|
+
chunk = { type: 'text-delta', index: row.data.index, text: members[k] };
|
|
257
|
+
break;
|
|
258
|
+
case 'reasoning-chunks':
|
|
259
|
+
chunk = { type: 'reasoning-delta', index: row.data.index, text: members[k] };
|
|
260
|
+
break;
|
|
261
|
+
case 'tool-call-chunks':
|
|
262
|
+
chunk = {
|
|
263
|
+
type: 'tool-call-delta',
|
|
264
|
+
index: row.data.index,
|
|
265
|
+
id: row.data.id,
|
|
266
|
+
...Object.hasOwn(row.data, 'name') ? { name: row.data.name } : {},
|
|
267
|
+
argumentsDelta: members[k],
|
|
268
|
+
};
|
|
269
|
+
break;
|
|
270
|
+
/* v8 ignore next 2 -- validateRow only returns the three row tags */
|
|
271
|
+
default:
|
|
272
|
+
return assertNever(row, 'chunk-rows expandRow');
|
|
273
|
+
}
|
|
274
|
+
events.push({
|
|
275
|
+
type: 'assistant/chunk',
|
|
276
|
+
seq: row.seq0 + k,
|
|
277
|
+
time,
|
|
278
|
+
data: { turn: row.data.turn, step: row.data.step, chunk },
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return events;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Decode one parsed JSONL line value into the session event(s) it stores.
|
|
285
|
+
* Chunk-row-tagged values validate and expand (a malformed row throws — it is
|
|
286
|
+
* corrupt storage, and treating it as an event would silently drop a whole
|
|
287
|
+
* run); every other value passes through as a single event, unvalidated.
|
|
288
|
+
*
|
|
289
|
+
* @param value - one line's `JSON.parse` result.
|
|
290
|
+
* @returns the stored events, in log order.
|
|
291
|
+
*/
|
|
292
|
+
export function decodeStorageRecord(value) {
|
|
293
|
+
if (!isRecord(value))
|
|
294
|
+
return [value];
|
|
295
|
+
const tag = value.type;
|
|
296
|
+
if (tag !== 'text-chunks' && tag !== 'reasoning-chunks' && tag !== 'tool-call-chunks') {
|
|
297
|
+
return [value];
|
|
298
|
+
}
|
|
299
|
+
return expandRow(validateRow(value, tag));
|
|
300
|
+
}
|
|
301
|
+
//# sourceMappingURL=chunk-rows.js.map
|