@deepseek-ai/dsh-session-persistence-jsonl 0.1.5-rc.1 → 0.1.6-alpha.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/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/session/session-persistence-jsonl/README.md
5
- README.md: 194ea835f87e4c34e0821083246bcf89c0e0a684
6
- README.zh.md: 4512d67e3439cbb4cf8642ca67be56bf0c7ac5b1
5
+ README.md: fc431ff27bcd236c4d44b8d3e7b20d4291b936d4
6
+ README.zh.md: 3b265bf43f058d4c0711568df156825febed4d24
package/README.md CHANGED
@@ -53,7 +53,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
53
53
 
54
54
  ### On-disk layout
55
55
 
56
- Each session gets a session-owned directory under a readable project directory. Every canonical generation starts with a physical header whose version equals its filename. The current format stores one physical row per durable event; the frozen v0 and v1 readers also understand their historical packed Assistant-delta rows. The current format stores `isSeeded` in the header and derives the inherited cut from the last tagged `session/end-seed` marker, while historical codecs translate their numeric `seedLength`. The format catalog completes that translation before a handle exposes current logical values. Current storage records use the lossless provenance representation described below:
56
+ Each session gets a session-owned directory under a readable project directory. Every canonical generation starts with a physical header whose version equals its filename. The current format stores one physical row per durable event; the frozen v0 and v1 readers also understand their historical packed Assistant-delta rows. The current format stores `isSeeded` in the header and derives the inherited cut from the last tagged `session/end-seed` marker, while historical codecs translate their numeric `seedLength`. The format catalog completes that translation before a handle exposes current logical values. Current storage records use the lossless source-event representation described below:
57
57
 
58
58
  ```text
59
59
  <root>/
@@ -62,9 +62,11 @@ Each session gets a session-owned directory under a readable project directory.
62
62
  session.jsonl.zstd # released v0, compressed root
63
63
  session.v1.jsonl.zstd # released v1, compressed root
64
64
  session.v2.jsonl.zstd # released v2, compressed root
65
+ session.v3.jsonl.zstd # released v3/current, compressed root
65
66
  session.jsonl # released v0, raw root
66
67
  session.v1.jsonl # released v1, raw root
67
- session.v2.jsonl # released v2, raw root; later versions use vN
68
+ session.v2.jsonl # released v2, raw root
69
+ session.v3.jsonl # released v3/current, raw root; later versions use vN
68
70
  ```
69
71
 
70
72
  Session ids are injectively escaped to one safe path segment before use (no traversal, no collision). The normalized cwd keeps the project directory readable for navigation; cwd strings that normalize alike share a project directory while session ids still select distinct session directories. Runtime operations select the numerically highest canonical generation, and format-refusal diagnostics name that absolute path so an operator can find the raw log a build refused to interpret.
package/README.zh.md CHANGED
@@ -29,7 +29,7 @@ kind: "package-reference"
29
29
 
30
30
  ### 何时选择
31
31
 
32
- 当消费方受益于每会话一份产物——导航、外部工具或可逐行读取的原始日志——时选择此后端。它是随产品交付的唯一 Session 持久化 provider。后端把会话保存在部署控制的根下:项目本地、共享、临时或集中式。
32
+ 当消费方受益于每会话一份产物——导航、外部工具或可逐行读取的原始日志——时选择此后端。它是唯一的第一方会话持久化提供方。后端把会话保存在部署控制的根下:项目本地、共享、临时或集中式。
33
33
 
34
34
  ### 最小配置
35
35
 
@@ -62,12 +62,14 @@ kind: "package-reference"
62
62
  session.jsonl.zstd # released v0, compressed root
63
63
  session.v1.jsonl.zstd # released v1, compressed root
64
64
  session.v2.jsonl.zstd # released v2, compressed root
65
+ session.v3.jsonl.zstd # released v3/current, compressed root
65
66
  session.jsonl # released v0, raw root
66
67
  session.v1.jsonl # released v1, raw root
67
- session.v2.jsonl # released v2, raw root; later versions use vN
68
+ session.v2.jsonl # released v2, raw root
69
+ session.v3.jsonl # released v3/current, raw root; later versions use vN
68
70
  ```
69
71
 
70
- 会话 id 在使用前被单射转义为一个安全路径段(无遍历、无冲突)。规范化 cwd 让项目目录保持可读、便于导航;规范化相同的 cwd 字符串共享项目目录,而会话 id 仍选择不同会话目录。runtime 操作选择数值最高的规范 generation,格式拒绝诊断会点名该绝对路径,让操作者能找到构建拒绝解读的原始日志。
72
+ 会话 id 在使用前被单射转义为一个安全路径段(无遍历、无冲突)。规范化 cwd 让项目目录保持可读、便于导航;规范化相同的 cwd 字符串共享项目目录,而会话 id 仍选择不同会话目录。运行时操作选择数值最高的规范 generation,格式拒绝诊断会点名该绝对路径,让操作者能找到构建拒绝解读的原始日志。
71
73
 
72
74
  ### 持久性与崩溃语义
73
75
 
@@ -77,7 +79,7 @@ kind: "package-reference"
77
79
 
78
80
  ### 读取日志
79
81
 
80
- `open(id, 'read'|'write')` 选择最高规范 generation。当前格式输入走普通快速路径。对于历史输入,只读 open 会单遍解码并迁移源、校验当前逻辑结果,然后在不发布后继的情况下返回。写 open 会在可用时复用按 revision 为键的 preparation,否则执行同一套 preparation,再按有界分片编码同目录临时文件、在 Worker Thread 中校验、复查源修订,并在返回前以不覆盖方式发布当前后继。源保持逐字节不变。如果源在 preparation 后发生变化,该次写 open 会失败,已经返回给读方的逻辑历史不会被替换;后续写 open 会针对新的 revision 重新执行 preparation。Backend memo 化前冻结已解码的 event graph,并在此时将其标记为 `shared-frozen`;句柄读取和 slice 即使为空也保留该状态。只有尚未实体化的 pending 空日志报告 `detached`。`stat(id)` 与 `list()` 只选择并转换最高 generation 的 header,不读取事件行,也不启动迁移;快照携带所选文件的 `sizeBytes` 与尽力而为的 stat 派生修订号。选择 `compression: 'none'` 后,日志是外部读取方可直接消费的换行分隔文本;压缩默认值必须经后端读取。
82
+ `open(id, 'read'|'write')` 选择最高规范 generation。当前格式输入走普通快速路径。对于历史输入,只读 open 会单遍解码并迁移源、校验当前逻辑结果,然后在不发布后继的情况下返回。写 open 会在可用时复用按 revision 为键的 preparation,否则执行同一套 preparation,再按有界分片编码同目录临时文件、在 Worker Thread 中校验、复查源修订,并在返回前以不覆盖方式发布当前后继。源保持逐字节不变。如果源在 preparation 后发生变化,该次写 open 会失败,已经返回给读方的逻辑历史不会被替换;后续写 open 会针对新的 revision 重新执行 preparation。后端在 memo 化前冻结已解码的 event graph,并在此时将其标记为 `shared-frozen`;句柄读取和 slice 即使为空也保留该状态。只有尚未实体化的 pending 空日志报告 `detached`。`stat(id)` 与 `list()` 只选择并转换最高 generation 的 header,不读取事件行,也不启动迁移;快照携带所选文件的 `sizeBytes` 与尽力而为的 stat 派生修订号。选择 `compression: 'none'` 后,日志是外部读取方可直接消费的换行分隔文本;压缩默认值必须经后端读取。
81
83
 
82
84
  -----
83
85
 
@@ -108,7 +110,7 @@ kind: "package-reference"
108
110
  | [`src/migration-verifier.ts`](src/migration-verifier.ts) | stage 与竞争 generation 校验的 Worker 生命周期 |
109
111
  | [`src/zstd.ts`](src/zstd.ts) | Zstandard 帧压缩、解码与帧扫描 |
110
112
  | [`src/win32.ts`](src/win32.ts) | Windows write-through 发布与目录创建 |
111
- | — | 不发布运行时不变式伴生入口;身份在存储层强制。 |
113
+ | — | 不发布运行时不变式伴生入口;身份在存储层强制;持久化正确性依赖后端往返与崩溃尾部测试,本包不公开可持续观察的进程内关系。 |
112
114
 
113
115
  </details>
114
116
 
@@ -151,11 +153,11 @@ JSONL 存储不修改实时请求前缀。只有重建历史、当前 envelope
151
153
 
152
154
  这些限制说明本后端何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是任务积压。
153
155
 
154
- - **格式迁移保留已配置编码,且只支持 catalog 中的链**——本 build 把受支持的历史代迁移到当前格式;更改压缩需要独立根,保留的前任不提供自动 fallback 或 downgrade 支持。
156
+ - **格式迁移保留已配置编码,且只支持 catalog 中的链**——本 build 把受支持的历史代迁移到当前格式;更改压缩需要独立根,保留的旧版本不提供自动 fallback 或 downgrade 支持。
155
157
  - **平铺文件存储布局不加载**——加载前使用独立根,或将预发布产物移入项目/会话目录布局。
156
158
  - **压缩文件不能直接按行读取**——使用后端加载;或在写入新根前选择 `compression: 'none'`,供外部行读取方使用。
157
159
  - **不删除会话文件**——日志在 `root` 下累积,直到外部移除;seam 无删除接口。
158
- - **每会话一个活动写入方**——写句柄认领在所属后端实例内排除第二个写入方,内核锁(`session.lock` 上的非阻塞 `flock(2)`;Windows 上为由该路径派生的命名内核信号量,零文件系统足迹)排除其他所有实例与进程;锁在写打开既有工件时立即获取,新建会话则仅在首次物化写入之前获取,因此未物化的会话不留任何文件系统足迹。崩溃持有者的锁随其进程消亡,会话立即可再写入,而活着但卡死的持有者会阻塞写入方直到其进程退出(POSIX 上删除锁文件即放弃该排他;释放本身从不删除它)。咨询式 `flock` 在部分网络文件系统(NFSv3)上不可靠,Windows 信号量名按登录会话隔离。
160
+ - **每会话一个活动写入方**——写句柄认领在所属后端实例内排除第二个写入方,内核锁(`session.lock` 上的非阻塞 `flock(2)`;Windows 上为由该路径派生的命名内核信号量,零文件系统足迹)排除其他所有实例与进程;锁在以写模式打开既有产物时立即获取,新建会话则仅在首次实体化写入之前获取,因此未实体化的会话不留任何文件系统足迹。崩溃持有者的锁随其进程消亡,会话立即可再写入,而活着但卡死的持有者会阻塞写入方直到其进程退出(POSIX 上删除锁文件即放弃该排他;释放本身从不删除它)。咨询式 `flock` 在部分网络文件系统(NFSv3)上不可靠,Windows 信号量名按登录会话隔离。
159
161
  - **POSIX 实体化需要硬链接支持**——第一次 append 使用 `link()`,使同 id 竞态失败而不覆盖已提交日志;Windows 使用无替换 write-through rename。
160
162
  - **POSIX 写入需要匹配的预编译系统 addon**——[`node-addon-system`](../../../native/system/README.zh.md) 提供异步 flock,无须在用户侧编译。addon 缺失时拒绝写入所有权;Windows 保留其信号量实现。
161
163
 
package/lib/index.js CHANGED
@@ -16,6 +16,7 @@ import { constants, createZstdCompress, createZstdDecompress, zstdCompress, zstd
16
16
  import { isDeepStrictEqual, promisify } from "node:util";
17
17
  import { constants as constants$1 } from "node:buffer";
18
18
  import { Worker } from "node:worker_threads";
19
+ import { currentSessionMessageProjections } from "@deepseek-ai/dsh-session-format-catalog/message-projections";
19
20
  import { Readable, pipeline } from "node:stream";
20
21
  /**
21
22
  * The JSONL session handle. Mutations serialize on a per-handle promise
@@ -1819,7 +1820,7 @@ async function verifyCurrentGeneration(path, compression, expectedId, expectedEv
1819
1820
  });
1820
1821
  if (generation.meta.id !== expectedId) throw new Error(`current session generation contains id "${generation.meta.id}", expected "${expectedId}"`);
1821
1822
  if (generation.events.length !== expectedEventCount) throw new Error(`current session generation contains ${generation.events.length} events, expected ${expectedEventCount}`);
1822
- Session.fromRestore(generation.meta.id, generation.events, generation.meta, generation.inheritedEventCount, "detached");
1823
+ Session.fromRestore(generation.meta.id, generation.events, generation.meta, generation.inheritedEventCount, "detached", currentSessionMessageProjections);
1823
1824
  assertCurrentAssistantStreams(generation.events);
1824
1825
  return {
1825
1826
  identity: snapshot.identity,
package/lib/worker.cjs CHANGED
@@ -9,6 +9,320 @@ let node_util = require("node:util");
9
9
  let node_zlib = require("node:zlib");
10
10
  let node_module = require("node:module");
11
11
  let node_buffer = require("node:buffer");
12
+ //#region ../../util/values/src/index.ts
13
+ /**
14
+ * Mark an unreachable closed-union branch.
15
+ * @param value - impossible value; an unhandled typed variant fails at the call site.
16
+ * @param context - optional switch-site label included in the failure message.
17
+ * @returns never; a runtime value that escaped its type always throws.
18
+ */
19
+ function assertNever(value, context) {
20
+ const rendered = JSON.stringify(value) ?? String(value);
21
+ throw new Error(`unreachable variant${context ? ` in ${context}` : ""}: ${rendered}`);
22
+ }
23
+ /** Whether a realm-owned intrinsic prototype is backed by its native constructor. */
24
+ function hasIntrinsicConstructor(prototype, name) {
25
+ const constructor = Object.getOwnPropertyDescriptor(prototype, "constructor")?.value;
26
+ if (typeof constructor !== "function") return false;
27
+ try {
28
+ return constructor.name === name && constructor.prototype === prototype && Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+ /** Whether a candidate is one realm's intrinsic `Object.prototype`. */
34
+ function isIntrinsicObjectPrototype(value) {
35
+ return Object.getPrototypeOf(value) === null && hasIntrinsicConstructor(value, "Object");
36
+ }
37
+ /** Whether an array uses one realm's intrinsic `Array.prototype`, not a subclass or forged prototype. */
38
+ function hasPlainArrayPrototype(value) {
39
+ const prototype = Object.getPrototypeOf(value);
40
+ if (!Array.isArray(prototype) || !hasIntrinsicConstructor(prototype, "Array")) return false;
41
+ const objectPrototype = Object.getPrototypeOf(prototype);
42
+ return typeof objectPrototype === "object" && objectPrototype !== null && isIntrinsicObjectPrototype(objectPrototype);
43
+ }
44
+ /** Whether an object is a plain or null-prototype record from any JavaScript realm. */
45
+ function hasPlainObjectPrototype(value) {
46
+ const prototype = Object.getPrototypeOf(value);
47
+ return prototype === null || typeof prototype === "object" && isIntrinsicObjectPrototype(prototype);
48
+ }
49
+ /** Return every JSON-visible object key, or reject own data JSON would discard. */
50
+ function enumerableStringKeys(value) {
51
+ const keys = Reflect.ownKeys(value);
52
+ if (keys.some((key) => typeof key !== "string" || !Object.prototype.propertyIsEnumerable.call(value, key))) return void 0;
53
+ return keys;
54
+ }
55
+ /** Validate lossless JSON iteratively, optionally materializing a detached snapshot. */
56
+ function walkJsonValue(value, detach) {
57
+ const ancestors = /* @__PURE__ */ new Set();
58
+ let root;
59
+ const assign = (destination, item) => {
60
+ if (destination === void 0) return;
61
+ if (destination.kind === "root") root = item;
62
+ else if (destination.kind === "array") destination.target[destination.index] = item;
63
+ else Object.defineProperty(destination.target, destination.key, {
64
+ value: item,
65
+ enumerable: true,
66
+ configurable: true,
67
+ writable: true
68
+ });
69
+ };
70
+ const tasks = [{
71
+ kind: "visit",
72
+ value,
73
+ ...detach ? { destination: { kind: "root" } } : {}
74
+ }];
75
+ for (let task = tasks.pop(); task !== void 0; task = tasks.pop()) {
76
+ if (task.kind === "leave") {
77
+ ancestors.delete(task.source);
78
+ continue;
79
+ }
80
+ if (task.kind === "array-item") {
81
+ if (!Object.prototype.hasOwnProperty.call(task.source, task.index)) return void 0;
82
+ tasks.push({
83
+ kind: "visit",
84
+ value: task.source[task.index],
85
+ ...task.target === void 0 ? {} : { destination: {
86
+ kind: "array",
87
+ target: task.target,
88
+ index: task.index
89
+ } }
90
+ });
91
+ continue;
92
+ }
93
+ if (task.kind === "object-property") {
94
+ tasks.push({
95
+ kind: "visit",
96
+ value: task.source[task.key],
97
+ ...task.target === void 0 ? {} : { destination: {
98
+ kind: "object",
99
+ target: task.target,
100
+ key: task.key
101
+ } }
102
+ });
103
+ continue;
104
+ }
105
+ const current = task.value;
106
+ if (current === null) {
107
+ assign(task.destination, null);
108
+ continue;
109
+ }
110
+ if (typeof current === "boolean" || typeof current === "string") {
111
+ assign(task.destination, current);
112
+ continue;
113
+ }
114
+ if (typeof current === "number") {
115
+ if (!Number.isFinite(current) || Object.is(current, -0)) return void 0;
116
+ assign(task.destination, current);
117
+ continue;
118
+ }
119
+ if (typeof current !== "object") return void 0;
120
+ if (ancestors.has(current)) return void 0;
121
+ if (Array.isArray(current)) {
122
+ if (!hasPlainArrayPrototype(current)) return void 0;
123
+ const length = current.length;
124
+ if (Reflect.ownKeys(current).length !== length + 1) return void 0;
125
+ const target = detach ? [] : void 0;
126
+ if (target !== void 0) assign(task.destination, target);
127
+ ancestors.add(current);
128
+ tasks.push({
129
+ kind: "leave",
130
+ source: current
131
+ });
132
+ for (let index = length - 1; index >= 0; index--) tasks.push({
133
+ kind: "array-item",
134
+ source: current,
135
+ index,
136
+ ...target === void 0 ? {} : { target }
137
+ });
138
+ continue;
139
+ }
140
+ if (!hasPlainObjectPrototype(current)) return void 0;
141
+ const keys = enumerableStringKeys(current);
142
+ if (keys === void 0) return void 0;
143
+ const target = detach ? {} : void 0;
144
+ if (target !== void 0) assign(task.destination, target);
145
+ ancestors.add(current);
146
+ tasks.push({
147
+ kind: "leave",
148
+ source: current
149
+ });
150
+ for (let index = keys.length - 1; index >= 0; index--) {
151
+ const key = keys[index];
152
+ /* v8 ignore next -- the loop is bounded by the captured key count. */
153
+ if (key === void 0) return void 0;
154
+ tasks.push({
155
+ kind: "object-property",
156
+ source: current,
157
+ key,
158
+ ...target === void 0 ? {} : { target }
159
+ });
160
+ }
161
+ }
162
+ return detach ? root : true;
163
+ }
164
+ /**
165
+ * Validate and detach lossless JSON in one read per property.
166
+ * @param value - candidate value to validate and detach.
167
+ * @returns the detached snapshot, or `undefined` when the value is not losslessly JSON-serializable.
168
+ */
169
+ function snapshotJsonValue(value) {
170
+ return walkJsonValue(value, true);
171
+ }
172
+ /**
173
+ * Test the same lossless JSON rules as {@link snapshotJsonValue} without detaching the value.
174
+ * @param value - candidate value to test.
175
+ * @returns whether the value survives a JSON round trip without loss.
176
+ */
177
+ function isJsonValue(value) {
178
+ return walkJsonValue(value, false) === true;
179
+ }
180
+ /**
181
+ * Compare JSON-compatible values structurally.
182
+ * @param a - one JSON-compatible value.
183
+ * @param b - the other JSON-compatible value.
184
+ * @returns whether both values contain the same JSON data.
185
+ */
186
+ function deepEqualJson(a, b) {
187
+ if (a === b) return true;
188
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
189
+ if (Array.isArray(a) || Array.isArray(b)) {
190
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
191
+ return a.every((entry, index) => deepEqualJson(entry, b[index]));
192
+ }
193
+ const left = a;
194
+ const right = b;
195
+ const keys = Object.keys(left);
196
+ if (keys.length !== Object.keys(right).length) return false;
197
+ return keys.every((key) => key in right && deepEqualJson(left[key], right[key]));
198
+ }
199
+ /**
200
+ * Deep-freeze an object graph in place while leaving live AbortSignal objects mutable.
201
+ * @param value - value to freeze.
202
+ * @returns the same value after every reachable enumerable child is frozen.
203
+ */
204
+ function deepFreeze(value) {
205
+ const seen = /* @__PURE__ */ new WeakSet();
206
+ const pending = [{
207
+ kind: "visit",
208
+ node: value
209
+ }];
210
+ while (pending.length > 0) {
211
+ const task = pending.pop();
212
+ /* v8 ignore next -- the loop condition guarantees one pending task. */
213
+ if (task === void 0) continue;
214
+ if (task.kind === "property") {
215
+ pending.push({
216
+ kind: "visit",
217
+ node: task.source[task.key]
218
+ });
219
+ continue;
220
+ }
221
+ const node = task.node;
222
+ if (node === null || typeof node !== "object") continue;
223
+ if (node instanceof AbortSignal) continue;
224
+ if (seen.has(node)) continue;
225
+ seen.add(node);
226
+ Object.freeze(node);
227
+ const keys = Object.keys(node);
228
+ for (let index = keys.length - 1; index >= 0; index--) {
229
+ const key = keys[index];
230
+ /* v8 ignore next -- the loop is bounded by the captured key count. */
231
+ if (key === void 0) continue;
232
+ pending.push({
233
+ kind: "property",
234
+ source: node,
235
+ key
236
+ });
237
+ }
238
+ }
239
+ return value;
240
+ }
241
+ //#endregion
242
+ //#region ../../compaction/compaction-image-offload/src/project-message.ts
243
+ /**
244
+ * Project selected image occurrences to immutable offloaded blocks.
245
+ * @param message - message projected before this decision.
246
+ * @param indexes - nonempty, strictly increasing depth-first image indexes.
247
+ * @returns an immutable message with the same identity and selected images marked.
248
+ * @throws when a selected occurrence is missing or already offloaded.
249
+ */
250
+ function offloadMessageImages(message, indexes) {
251
+ let imageIndex = 0;
252
+ let selected = 0;
253
+ const visit = (blocks) => {
254
+ let next;
255
+ for (const [index, block] of blocks.entries()) {
256
+ let projected = block;
257
+ if (block.type === "image") {
258
+ if (imageIndex === indexes[selected]) {
259
+ if (block.offloaded === true) throw new Error(`image/offload: image index ${imageIndex} is already offloaded`);
260
+ projected = {
261
+ ...block,
262
+ offloaded: true
263
+ };
264
+ selected += 1;
265
+ }
266
+ imageIndex += 1;
267
+ } else if (block.type === "tool-result") {
268
+ const content = visit(block.content);
269
+ if (content !== block.content) projected = {
270
+ ...block,
271
+ content
272
+ };
273
+ }
274
+ if (projected !== block) next ??= blocks.slice(0, index);
275
+ next?.push(projected);
276
+ }
277
+ return next ?? blocks;
278
+ };
279
+ const content = visit(message.content);
280
+ if (selected !== indexes.length) throw new Error(`image/offload: image index ${indexes[selected]} does not exist`);
281
+ return deepFreeze({
282
+ ...message,
283
+ content
284
+ });
285
+ }
286
+ //#endregion
287
+ //#region ../../compaction/compaction-image-offload/src/projection.ts
288
+ /** Whether a durable value is a JSON object. */
289
+ function isRecord$1(value) {
290
+ return typeof value === "object" && value !== null && !Array.isArray(value);
291
+ }
292
+ /** Whether a durable occurrence index or sequence is canonical. */
293
+ function isIndex(value) {
294
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0);
295
+ }
296
+ //#endregion
297
+ //#region ../session-format-catalog/src/message-projections.ts
298
+ /** Pure first-party message interpreters for detached current-format replay, including browser readers. */
299
+ /** Installed interpretation definitions; recovery listeners are mounted separately by their owning plugins. */
300
+ const currentSessionMessageProjections = [{
301
+ type: "image/offload",
302
+ project(event, context) {
303
+ const data = event.data;
304
+ if (!isRecord$1(data) || Object.keys(data).length !== 1 || !Array.isArray(data["targets"]) || data["targets"].length === 0) throw new Error("image/offload: data must contain a nonempty targets array");
305
+ const messages = /* @__PURE__ */ new Map();
306
+ const nodes = new Set(context.nodes);
307
+ for (const target of data["targets"]) {
308
+ if (!isRecord$1(target) || Object.keys(target).length !== 2 || !isIndex(target["seq"]) || !Array.isArray(target["imageIndexes"]) || target["imageIndexes"].length === 0) throw new Error("image/offload: each target must contain a seq and nonempty imageIndexes");
309
+ const seq = target["seq"];
310
+ if (messages.has(seq)) throw new Error(`image/offload: duplicate target seq ${seq}`);
311
+ if (!nodes.has(seq)) throw new Error(`image/offload: target seq ${seq} is not a current surface node`);
312
+ const source = context.events[seq - context.baseSeq];
313
+ if (source?.type !== "user/message" && source?.type !== "tool/result") throw new Error(`image/offload: target seq ${seq} must be user/message or tool/result`);
314
+ let previous = -1;
315
+ for (const index of target["imageIndexes"]) {
316
+ if (!isIndex(index) || index <= previous) throw new Error("image/offload: imageIndexes must be strictly increasing non-negative safe integers");
317
+ previous = index;
318
+ }
319
+ const message = context.messages.get(seq) ?? (source.type === "user/message" ? source.data : source.data.message);
320
+ messages.set(seq, offloadMessageImages(message, target["imageIndexes"]));
321
+ }
322
+ return messages;
323
+ }
324
+ }];
325
+ //#endregion
12
326
  //#region ../../../vendor/cosmokit/src/misc.ts
13
327
  /** Return true when a value is `null` or `undefined`. */
14
328
  function isNullable(value) {
@@ -1390,8 +1704,8 @@ var Fiber = class {
1390
1704
  *
1391
1705
  * @param config — the new raw config; validated before anything restarts.
1392
1706
  * @param noSave — hint for persistence hooks not to write the change back.
1393
- * @returns the update waterfall result; the default restart returns a promise.
1394
- * @throws when validation, an update listener, or the restarted plugin fails.
1707
+ * @returns nothing; the restart runs behind the `internal/update` waterfall.
1708
+ * @throws {ValidationError} when the new config fails validation.
1395
1709
  */
1396
1710
  update(config, noSave = false) {
1397
1711
  this.assertActive();
@@ -1403,7 +1717,7 @@ var Fiber = class {
1403
1717
  return;
1404
1718
  }
1405
1719
  config = this._resolveConfig(config);
1406
- return this.context.waterfall(this, "internal/update", config, noSave, () => {
1720
+ this.context.waterfall(this, "internal/update", config, noSave, () => {
1407
1721
  this.config = config;
1408
1722
  this._error = void 0;
1409
1723
  return this.restart();
@@ -2083,236 +2397,6 @@ function brandNumber(value) {
2083
2397
  return value;
2084
2398
  }
2085
2399
  //#endregion
2086
- //#region ../../util/values/src/index.ts
2087
- /**
2088
- * Mark an unreachable closed-union branch.
2089
- * @param value - impossible value; an unhandled typed variant fails at the call site.
2090
- * @param context - optional switch-site label included in the failure message.
2091
- * @returns never; a runtime value that escaped its type always throws.
2092
- */
2093
- function assertNever(value, context) {
2094
- const rendered = JSON.stringify(value) ?? String(value);
2095
- throw new Error(`unreachable variant${context ? ` in ${context}` : ""}: ${rendered}`);
2096
- }
2097
- /** Whether a realm-owned intrinsic prototype is backed by its native constructor. */
2098
- function hasIntrinsicConstructor(prototype, name) {
2099
- const constructor = Object.getOwnPropertyDescriptor(prototype, "constructor")?.value;
2100
- if (typeof constructor !== "function") return false;
2101
- try {
2102
- return constructor.name === name && constructor.prototype === prototype && Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`;
2103
- } catch {
2104
- return false;
2105
- }
2106
- }
2107
- /** Whether a candidate is one realm's intrinsic `Object.prototype`. */
2108
- function isIntrinsicObjectPrototype(value) {
2109
- return Object.getPrototypeOf(value) === null && hasIntrinsicConstructor(value, "Object");
2110
- }
2111
- /** Whether an array uses one realm's intrinsic `Array.prototype`, not a subclass or forged prototype. */
2112
- function hasPlainArrayPrototype(value) {
2113
- const prototype = Object.getPrototypeOf(value);
2114
- if (!Array.isArray(prototype) || !hasIntrinsicConstructor(prototype, "Array")) return false;
2115
- const objectPrototype = Object.getPrototypeOf(prototype);
2116
- return typeof objectPrototype === "object" && objectPrototype !== null && isIntrinsicObjectPrototype(objectPrototype);
2117
- }
2118
- /** Whether an object is a plain or null-prototype record from any JavaScript realm. */
2119
- function hasPlainObjectPrototype(value) {
2120
- const prototype = Object.getPrototypeOf(value);
2121
- return prototype === null || typeof prototype === "object" && isIntrinsicObjectPrototype(prototype);
2122
- }
2123
- /** Return every JSON-visible object key, or reject own data JSON would discard. */
2124
- function enumerableStringKeys(value) {
2125
- const keys = Reflect.ownKeys(value);
2126
- if (keys.some((key) => typeof key !== "string" || !Object.prototype.propertyIsEnumerable.call(value, key))) return void 0;
2127
- return keys;
2128
- }
2129
- /** Validate lossless JSON iteratively, optionally materializing a detached snapshot. */
2130
- function walkJsonValue(value, detach) {
2131
- const ancestors = /* @__PURE__ */ new Set();
2132
- let root;
2133
- const assign = (destination, item) => {
2134
- if (destination === void 0) return;
2135
- if (destination.kind === "root") root = item;
2136
- else if (destination.kind === "array") destination.target[destination.index] = item;
2137
- else Object.defineProperty(destination.target, destination.key, {
2138
- value: item,
2139
- enumerable: true,
2140
- configurable: true,
2141
- writable: true
2142
- });
2143
- };
2144
- const tasks = [{
2145
- kind: "visit",
2146
- value,
2147
- ...detach ? { destination: { kind: "root" } } : {}
2148
- }];
2149
- for (let task = tasks.pop(); task !== void 0; task = tasks.pop()) {
2150
- if (task.kind === "leave") {
2151
- ancestors.delete(task.source);
2152
- continue;
2153
- }
2154
- if (task.kind === "array-item") {
2155
- if (!Object.prototype.hasOwnProperty.call(task.source, task.index)) return void 0;
2156
- tasks.push({
2157
- kind: "visit",
2158
- value: task.source[task.index],
2159
- ...task.target === void 0 ? {} : { destination: {
2160
- kind: "array",
2161
- target: task.target,
2162
- index: task.index
2163
- } }
2164
- });
2165
- continue;
2166
- }
2167
- if (task.kind === "object-property") {
2168
- tasks.push({
2169
- kind: "visit",
2170
- value: task.source[task.key],
2171
- ...task.target === void 0 ? {} : { destination: {
2172
- kind: "object",
2173
- target: task.target,
2174
- key: task.key
2175
- } }
2176
- });
2177
- continue;
2178
- }
2179
- const current = task.value;
2180
- if (current === null) {
2181
- assign(task.destination, null);
2182
- continue;
2183
- }
2184
- if (typeof current === "boolean" || typeof current === "string") {
2185
- assign(task.destination, current);
2186
- continue;
2187
- }
2188
- if (typeof current === "number") {
2189
- if (!Number.isFinite(current) || Object.is(current, -0)) return void 0;
2190
- assign(task.destination, current);
2191
- continue;
2192
- }
2193
- if (typeof current !== "object") return void 0;
2194
- if (ancestors.has(current)) return void 0;
2195
- if (Array.isArray(current)) {
2196
- if (!hasPlainArrayPrototype(current)) return void 0;
2197
- const length = current.length;
2198
- if (Reflect.ownKeys(current).length !== length + 1) return void 0;
2199
- const target = detach ? [] : void 0;
2200
- if (target !== void 0) assign(task.destination, target);
2201
- ancestors.add(current);
2202
- tasks.push({
2203
- kind: "leave",
2204
- source: current
2205
- });
2206
- for (let index = length - 1; index >= 0; index--) tasks.push({
2207
- kind: "array-item",
2208
- source: current,
2209
- index,
2210
- ...target === void 0 ? {} : { target }
2211
- });
2212
- continue;
2213
- }
2214
- if (!hasPlainObjectPrototype(current)) return void 0;
2215
- const keys = enumerableStringKeys(current);
2216
- if (keys === void 0) return void 0;
2217
- const target = detach ? {} : void 0;
2218
- if (target !== void 0) assign(task.destination, target);
2219
- ancestors.add(current);
2220
- tasks.push({
2221
- kind: "leave",
2222
- source: current
2223
- });
2224
- for (let index = keys.length - 1; index >= 0; index--) {
2225
- const key = keys[index];
2226
- /* v8 ignore next -- the loop is bounded by the captured key count. */
2227
- if (key === void 0) return void 0;
2228
- tasks.push({
2229
- kind: "object-property",
2230
- source: current,
2231
- key,
2232
- ...target === void 0 ? {} : { target }
2233
- });
2234
- }
2235
- }
2236
- return detach ? root : true;
2237
- }
2238
- /**
2239
- * Validate and detach lossless JSON in one read per property.
2240
- * @param value - candidate value to validate and detach.
2241
- * @returns the detached snapshot, or `undefined` when the value is not losslessly JSON-serializable.
2242
- */
2243
- function snapshotJsonValue(value) {
2244
- return walkJsonValue(value, true);
2245
- }
2246
- /**
2247
- * Test the same lossless JSON rules as {@link snapshotJsonValue} without detaching the value.
2248
- * @param value - candidate value to test.
2249
- * @returns whether the value survives a JSON round trip without loss.
2250
- */
2251
- function isJsonValue(value) {
2252
- return walkJsonValue(value, false) === true;
2253
- }
2254
- /**
2255
- * Compare JSON-compatible values structurally.
2256
- * @param a - one JSON-compatible value.
2257
- * @param b - the other JSON-compatible value.
2258
- * @returns whether both values contain the same JSON data.
2259
- */
2260
- function deepEqualJson(a, b) {
2261
- if (a === b) return true;
2262
- if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
2263
- if (Array.isArray(a) || Array.isArray(b)) {
2264
- if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
2265
- return a.every((entry, index) => deepEqualJson(entry, b[index]));
2266
- }
2267
- const left = a;
2268
- const right = b;
2269
- const keys = Object.keys(left);
2270
- if (keys.length !== Object.keys(right).length) return false;
2271
- return keys.every((key) => key in right && deepEqualJson(left[key], right[key]));
2272
- }
2273
- /**
2274
- * Deep-freeze an object graph in place while leaving live AbortSignal objects mutable.
2275
- * @param value - value to freeze.
2276
- * @returns the same value after every reachable enumerable child is frozen.
2277
- */
2278
- function deepFreeze(value) {
2279
- const seen = /* @__PURE__ */ new WeakSet();
2280
- const pending = [{
2281
- kind: "visit",
2282
- node: value
2283
- }];
2284
- while (pending.length > 0) {
2285
- const task = pending.pop();
2286
- /* v8 ignore next -- the loop condition guarantees one pending task. */
2287
- if (task === void 0) continue;
2288
- if (task.kind === "property") {
2289
- pending.push({
2290
- kind: "visit",
2291
- node: task.source[task.key]
2292
- });
2293
- continue;
2294
- }
2295
- const node = task.node;
2296
- if (node === null || typeof node !== "object") continue;
2297
- if (node instanceof AbortSignal) continue;
2298
- if (seen.has(node)) continue;
2299
- seen.add(node);
2300
- Object.freeze(node);
2301
- const keys = Object.keys(node);
2302
- for (let index = keys.length - 1; index >= 0; index--) {
2303
- const key = keys[index];
2304
- /* v8 ignore next -- the loop is bounded by the captured key count. */
2305
- if (key === void 0) continue;
2306
- pending.push({
2307
- kind: "property",
2308
- source: node,
2309
- key
2310
- });
2311
- }
2312
- }
2313
- return value;
2314
- }
2315
- //#endregion
2316
2400
  //#region ../../core/session/src/types.ts
2317
2401
  /**
2318
2402
  * Brand a string as a {@link SessionId}.
@@ -2383,6 +2467,7 @@ const KNOWN_SESSION_EVENT_TYPES = new Set([
2383
2467
  "goal/change",
2384
2468
  "hook/invoked",
2385
2469
  "hook/result",
2470
+ "image/offload",
2386
2471
  "llm/retry",
2387
2472
  "llm/retry-started",
2388
2473
  "model/selection",
@@ -2420,6 +2505,8 @@ const KNOWN_SESSION_EVENT_TYPES = new Set([
2420
2505
  "user/message",
2421
2506
  "web/deepseek-search-llm-request"
2422
2507
  ]);
2508
+ /** Event types whose model-visible effects require an explicit pure interpreter. */
2509
+ const MESSAGE_PROJECTION_EVENT_TYPES = new Set(["image/offload"]);
2423
2510
  //#endregion
2424
2511
  //#region ../../core/session/src/surface.ts
2425
2512
  /** Runtime counterpart of the message-producing event union. */
@@ -2440,17 +2527,18 @@ function isSurfaceEligibleType(type) {
2440
2527
  /**
2441
2528
  * Project a single event into the LLM message it derives to, or null when it
2442
2529
  * produces none — a non-surface event (attempt, boundary, log-only record) or an
2443
- * empty-content assistant/message (which exists only to host usage). This is
2444
- * THE per-node projection rule: `Session.deriveMessages` folds it over the
2445
- * live surface, external reconstructors and pure projections fold the same
2446
- * function over a log prefix's surface to rebuild the exact messages any
2447
- * request was built from. The returned message is the already frozen message
2448
- * nested in the event wrapper and shared by delivery, durable history, and
2449
- * model requests.
2530
+ * empty-content assistant/message (which exists only to host usage). A caller
2531
+ * reconstructing model input supplies the same prefix's `projectedMessages`
2532
+ * from {@link foldSurface}; without that map this function reads original
2533
+ * event content. Session instance methods apply the live projection. Messages
2534
+ * are immutable and unchanged content retains its durable identity.
2450
2535
  * @param event - the event to project.
2536
+ * @param projectedMessages - message projections from the same log prefix's surface fold.
2451
2537
  * @returns the derived message, or null when the event produces none.
2452
2538
  */
2453
- function deriveEventMessage(event) {
2539
+ function deriveEventMessage(event, projectedMessages) {
2540
+ const projected = projectedMessages?.get(event.seq);
2541
+ if (projected !== void 0) return projected;
2454
2542
  switch (event.type) {
2455
2543
  case "user/message": return event.data;
2456
2544
  case "system/message":
@@ -2495,7 +2583,10 @@ function validateSessionEventData(event, subject) {
2495
2583
  function createFoldState() {
2496
2584
  return {
2497
2585
  nodes: [],
2498
- replaceGeneration: 0
2586
+ replaceGeneration: 0,
2587
+ contentGeneration: 0,
2588
+ projectedMessages: /* @__PURE__ */ new Map(),
2589
+ projections: /* @__PURE__ */ new Set()
2499
2590
  };
2500
2591
  }
2501
2592
  /** Whether a runtime value is a non-negative safe event sequence. */
@@ -2524,7 +2615,7 @@ function surfaceOpOf(event) {
2524
2615
  return op;
2525
2616
  }
2526
2617
  /** Validate cited source-event seqs against prior log entries and the replacement range. */
2527
- function assertProvenance(event, shadowedSeqs) {
2618
+ function assertSourceEventReferences(event, shadowedSeqs) {
2528
2619
  const raw = event.sourceEventSeqs;
2529
2620
  if (event.type === "assistant/message" && raw !== void 0) throw new Error("assistant/message embeds its source stream and cannot carry sourceEventSeqs");
2530
2621
  const sources = /* @__PURE__ */ new Set();
@@ -2553,7 +2644,7 @@ function assertProvenance(event, shadowedSeqs) {
2553
2644
  function validateSurfaceMetadata(event) {
2554
2645
  const op = surfaceOpOf(event);
2555
2646
  if (op !== void 0 && op !== "append" && (op.startSeq >= event.seq || op.endSeq >= event.seq)) throw new Error(`surface replace at seq ${event.seq}: startSeq and endSeq must reference earlier events`);
2556
- if (op !== void 0) assertProvenance(event, []);
2647
+ if (op !== void 0) assertSourceEventReferences(event, []);
2557
2648
  return op;
2558
2649
  }
2559
2650
  /** Locate one replacement range without mutating the current fold state. */
@@ -2626,16 +2717,28 @@ function assertSystemHeadRewrite(event, state, startIdx, shadowedSeqs, events, b
2626
2717
  if (event.type !== "system/message" || shadowedSeqs.length !== 1) throw new Error("surface replace: node 0 holds the system prompt and may be rewritten only by a system/message over exactly that node");
2627
2718
  }
2628
2719
  /** Validate one event at its replay boundary and prepare its atomic fold transition. */
2629
- function planSurfaceEvent(state, event, expectedSeq, events, baseSeq) {
2720
+ function planSurfaceEvent(state, event, expectedSeq, events, baseSeq, projections) {
2630
2721
  if (event.seq !== expectedSeq) throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`);
2631
2722
  const surfaceOp = validateSurfaceMetadata(event);
2723
+ const projection = projections.find((item) => item.type === event.type);
2724
+ if (projection !== void 0) return {
2725
+ kind: "project",
2726
+ projection,
2727
+ messages: projection.project(event, {
2728
+ nodes: state.nodes,
2729
+ events,
2730
+ baseSeq,
2731
+ messages: state.projectedMessages
2732
+ })
2733
+ };
2734
+ if (MESSAGE_PROJECTION_EVENT_TYPES.has(event.type)) throw new Error(`session event "${event.type}" requires a message projection; load its owning plugin or supply its projection definition`);
2632
2735
  if (surfaceOp === void 0) return;
2633
2736
  if (surfaceOp === "append") return {
2634
2737
  kind: "append",
2635
2738
  seq: event.seq
2636
2739
  };
2637
2740
  const range = replacementRange(state, surfaceOp);
2638
- assertProvenance(event, range.shadowedSeqs);
2741
+ assertSourceEventReferences(event, range.shadowedSeqs);
2639
2742
  assertToolResultRewrite(event, range.shadowedSeqs, events, baseSeq);
2640
2743
  assertSystemHeadRewrite(event, state, range.startIdx, range.shadowedSeqs, events, baseSeq);
2641
2744
  return {
@@ -2647,8 +2750,8 @@ function planSurfaceEvent(state, event, expectedSeq, events, baseSeq) {
2647
2750
  };
2648
2751
  }
2649
2752
  /** Apply one event and return replacement metadata only when one occurred. */
2650
- function applySurfaceEvent(state, event, expectedSeq, events, baseSeq) {
2651
- return applySurfacePlan(state, planSurfaceEvent(state, event, expectedSeq, events, baseSeq));
2753
+ function applySurfaceEvent(state, event, expectedSeq, events, baseSeq, projections) {
2754
+ return applySurfacePlan(state, planSurfaceEvent(state, event, expectedSeq, events, baseSeq, projections));
2652
2755
  }
2653
2756
  /** Commit one previously validated surface transition. */
2654
2757
  function applySurfacePlan(state, plan) {
@@ -2656,6 +2759,11 @@ function applySurfacePlan(state, plan) {
2656
2759
  else if (plan?.kind === "replace") {
2657
2760
  state.nodes.splice(plan.startIdx, plan.endIdx - plan.startIdx + 1, plan.seq);
2658
2761
  state.replaceGeneration += 1;
2762
+ state.contentGeneration += 1;
2763
+ } else if (plan?.kind === "project") {
2764
+ for (const [seq, message] of plan.messages) state.projectedMessages.set(seq, message);
2765
+ state.projections.add(plan.projection);
2766
+ state.contentGeneration += 1;
2659
2767
  }
2660
2768
  if (plan?.kind !== "replace") return;
2661
2769
  return {
@@ -2669,6 +2777,7 @@ function applySurfacePlan(state, plan) {
2669
2777
  var SurfaceManager = class {
2670
2778
  log;
2671
2779
  baseSeq;
2780
+ projections;
2672
2781
  /** Shared transition state; replacement history is not retained. */
2673
2782
  _state = createFoldState();
2674
2783
  /** Last processed absolute seq. */
@@ -2678,10 +2787,12 @@ var SurfaceManager = class {
2678
2787
  /**
2679
2788
  * @param log - Contiguous complete log or loaded event window.
2680
2789
  * @param baseSeq - Absolute sequence of the window's first event.
2790
+ * @param projections - live borrowed definitions; removing a used definition invalidates further reads.
2681
2791
  */
2682
- constructor(log, baseSeq = SessionLogOffset(0)) {
2792
+ constructor(log, baseSeq = SessionLogOffset(0), projections = []) {
2683
2793
  this.log = log;
2684
2794
  this.baseSeq = baseSeq;
2795
+ this.projections = projections;
2685
2796
  this._lastProcessedSeq = baseSeq === 0 ? -1 : SessionSeq(baseSeq - 1);
2686
2797
  }
2687
2798
  /**
@@ -2689,21 +2800,40 @@ var SurfaceManager = class {
2689
2800
  * @param event - candidate event that has not entered the log yet.
2690
2801
  */
2691
2802
  validateNext(event) {
2803
+ this._assertProjections();
2692
2804
  if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta();
2693
2805
  const expectedSeq = SessionSeq(this.baseSeq + this.log.length);
2694
2806
  this._pendingPlan = {
2695
2807
  event,
2696
2808
  expectedSeq,
2697
- plan: planSurfaceEvent(this._state, event, expectedSeq, this.log, this.baseSeq)
2809
+ plan: planSurfaceEvent(this._state, event, expectedSeq, this.log, this.baseSeq, this.projections)
2698
2810
  };
2699
2811
  }
2700
2812
  /** Monotonic count of folded positional replacements. */
2701
2813
  get replaceGeneration() {
2814
+ this._assertProjections();
2702
2815
  if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta();
2703
2816
  return this._state.replaceGeneration;
2704
2817
  }
2818
+ /** Monotonic count of committed changes to existing model-visible content. */
2819
+ get contentGeneration() {
2820
+ this._assertProjections();
2821
+ if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta();
2822
+ return this._state.contentGeneration;
2823
+ }
2824
+ /**
2825
+ * Project one message with every committed message projection applied.
2826
+ * @param event - message-producing or log-only event.
2827
+ * @returns its immutable projected message, or null when it produces none.
2828
+ */
2829
+ deriveEventMessage(event) {
2830
+ this._assertProjections();
2831
+ if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta();
2832
+ return deriveEventMessage(event, this._state.projectedMessages);
2833
+ }
2705
2834
  /** Surface event sequences in model-visible order. */
2706
2835
  get nodes() {
2836
+ this._assertProjections();
2707
2837
  if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta();
2708
2838
  return this._state.nodes;
2709
2839
  }
@@ -2715,11 +2845,18 @@ var SurfaceManager = class {
2715
2845
  const event = this.log[index];
2716
2846
  const pending = this._pendingPlan;
2717
2847
  if (pending?.event === event && pending.expectedSeq === seq) applySurfacePlan(this._state, pending.plan);
2718
- else applySurfaceEvent(this._state, event, SessionSeq(seq), this.log, this.baseSeq);
2848
+ else applySurfaceEvent(this._state, event, SessionSeq(seq), this.log, this.baseSeq, this.projections);
2719
2849
  if (pending !== void 0 && pending.expectedSeq <= seq) this._pendingPlan = void 0;
2720
2850
  this._lastProcessedSeq = SessionSeq(seq);
2721
2851
  }
2722
2852
  }
2853
+ /** Cached messages cannot outlive the definitions that interpreted their log. */
2854
+ _assertProjections() {
2855
+ const candidate = this._pendingPlan;
2856
+ const pending = candidate !== void 0 && this.log[candidate.expectedSeq - this.baseSeq] === candidate.event ? candidate.plan : void 0;
2857
+ const required = pending?.kind === "project" ? [...this._state.projections, pending.projection] : this._state.projections;
2858
+ for (const projection of required) if (!this.projections.includes(projection)) throw new Error(`session message projection "${projection.type}" was removed or replaced; restore the session with its owning plugin`);
2859
+ }
2723
2860
  };
2724
2861
  //#endregion
2725
2862
  //#region ../../typert/protocol/src/remote-error.ts
@@ -3719,13 +3856,15 @@ function failureSnapshot(value) {
3719
3856
  const status = candidate.status;
3720
3857
  const providerRetryAfterMs = candidate.providerRetryAfterMs;
3721
3858
  const requestId = candidate.requestId;
3722
- if (typeof message !== "string" || message.length === 0 || typeof code !== "string" || code.length === 0 || status !== void 0 && (!Number.isInteger(status) || status < 100 || status > 599) || providerRetryAfterMs !== void 0 && (!Number.isFinite(providerRetryAfterMs) || providerRetryAfterMs <= 0) || requestId !== void 0 && (typeof requestId !== "string" || requestId.length === 0)) return void 0;
3859
+ const offloadImages = candidate.offloadImages;
3860
+ if (typeof message !== "string" || message.length === 0 || typeof code !== "string" || code.length === 0 || status !== void 0 && (!Number.isInteger(status) || status < 100 || status > 599) || providerRetryAfterMs !== void 0 && (!Number.isFinite(providerRetryAfterMs) || providerRetryAfterMs <= 0) || requestId !== void 0 && (typeof requestId !== "string" || requestId.length === 0) || offloadImages !== void 0 && (!Number.isSafeInteger(offloadImages) || offloadImages <= 0)) return void 0;
3723
3861
  return Object.freeze({
3724
3862
  message,
3725
3863
  code,
3726
3864
  ...status === void 0 ? {} : { status },
3727
3865
  ...providerRetryAfterMs === void 0 ? {} : { providerRetryAfterMs },
3728
- ...requestId === void 0 ? {} : { requestId }
3866
+ ...requestId === void 0 ? {} : { requestId },
3867
+ ...offloadImages === void 0 ? {} : { offloadImages }
3729
3868
  });
3730
3869
  } catch (_sdkFailureGetter) {
3731
3870
  return;
@@ -4414,7 +4553,8 @@ var LlmError = class extends HarnessError {
4414
4553
  code,
4415
4554
  ...options?.status === void 0 ? {} : { status: options.status },
4416
4555
  ...options?.providerRetryAfterMs === void 0 ? {} : { providerRetryAfterMs: options.providerRetryAfterMs },
4417
- ...options?.requestId === void 0 ? {} : { requestId: options.requestId }
4556
+ ...options?.requestId === void 0 ? {} : { requestId: options.requestId },
4557
+ ...options?.offloadImages === void 0 ? {} : { offloadImages: options.offloadImages }
4418
4558
  });
4419
4559
  }
4420
4560
  };
@@ -5291,7 +5431,7 @@ const attachments = /* @__PURE__ */ new WeakMap();
5291
5431
  var Session = class Session {
5292
5432
  log = [];
5293
5433
  /** Single incremental owner of surface acceptance and projection state. */
5294
- surfaceManager = new SurfaceManager(this.log);
5434
+ surfaceManager;
5295
5435
  /** The ordered surface over this session's event log. */
5296
5436
  get surface() {
5297
5437
  return this.surfaceManager;
@@ -5342,10 +5482,12 @@ var Session = class Session {
5342
5482
  * @param seed - optional borrowed replay or fork events.
5343
5483
  * @param header - optional borrowed storage metadata.
5344
5484
  * @param inheritedEventCount - exact fork-inherited prefix length for a seeded header.
5485
+ * @param projections - pure interpreters for plugin-owned message changes.
5345
5486
  * @returns a detached session.
5487
+ * @throws when a seed event requires a missing message interpreter or fails validation.
5346
5488
  */
5347
- static create(id, seed, header, inheritedEventCount) {
5348
- return new Session(id, seed, header, "snapshot", inheritedEventCount);
5489
+ static create(id, seed, header, inheritedEventCount, projections) {
5490
+ return new Session(id, seed, header, "snapshot", inheritedEventCount, projections);
5349
5491
  }
5350
5492
  /**
5351
5493
  * Restore a detached session by adopting an independently owned or deeply frozen seed.
@@ -5358,12 +5500,15 @@ var Session = class Session {
5358
5500
  * @param header - independently owned storage metadata.
5359
5501
  * @param inheritedEventCount - exact fork-inherited prefix length decoded from storage.
5360
5502
  * @param eventState - aliasing state carried from the operation that produced the seed.
5503
+ * @param projections - pure interpreters for plugin-owned message changes.
5361
5504
  * @returns a restored detached session.
5505
+ * @throws when a seed event requires a missing message interpreter or fails validation.
5362
5506
  */
5363
- static fromRestore(id, seed, header, inheritedEventCount, eventState) {
5364
- return new Session(id, seed, header, eventState, inheritedEventCount);
5507
+ static fromRestore(id, seed, header, inheritedEventCount, eventState, projections) {
5508
+ return new Session(id, seed, header, eventState, inheritedEventCount, projections);
5365
5509
  }
5366
- constructor(id, seed, header, mode = "snapshot", suppliedInheritedEventCount) {
5510
+ constructor(id, seed, header, mode = "snapshot", suppliedInheritedEventCount, projections = []) {
5511
+ this.surfaceManager = new SurfaceManager(this.log, SessionLogOffset(0), projections);
5367
5512
  const restoredHeader = mode === "snapshot" ? void 0 : validateRestoredSessionHeader(id, header);
5368
5513
  if (seed !== void 0) for (const [index, source] of seed.entries()) {
5369
5514
  const snapshot = mode === "snapshot" ? snapshotJsonValue(source) : source;
@@ -5393,6 +5538,8 @@ var Session = class Session {
5393
5538
  eventsSnapshot;
5394
5539
  /**
5395
5540
  * Return the immutable event stored at one exact sequence number.
5541
+ * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited.
5542
+ * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md).
5396
5543
  * @param seq - event sequence number.
5397
5544
  * @returns the accepted event, or undefined when the log does not contain it.
5398
5545
  */
@@ -5403,6 +5550,8 @@ var Session = class Session {
5403
5550
  * Materialize an immutable snapshot of a half-open event sequence range.
5404
5551
  * A full current snapshot is reused until the next append; every previously
5405
5552
  * returned snapshot remains stable after later appends.
5553
+ * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited.
5554
+ * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md).
5406
5555
  * @param fromSeq - non-negative inclusive sequence number; defaults to the log start.
5407
5556
  * @param toSeqExclusive - non-negative exclusive sequence number; defaults to the current end.
5408
5557
  * @returns a frozen array of the selected deeply frozen events.
@@ -5416,6 +5565,8 @@ var Session = class Session {
5416
5565
  }
5417
5566
  /**
5418
5567
  * Return this Session's events after its fork-inherited prefix.
5568
+ * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited.
5569
+ * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md).
5419
5570
  * @returns a fresh array containing child-owned events in log order.
5420
5571
  */
5421
5572
  ownEvents() {
@@ -5549,7 +5700,7 @@ var Session = class Session {
5549
5700
  derived = [];
5550
5701
  /** Surface position (nodes projected) the cache has reached. */
5551
5702
  derivedNodes = 0;
5552
- /** {@link SurfaceManager.replaceGeneration} the cache was built under. */
5703
+ /** {@link SurfaceManager.contentGeneration} the cache was built under. */
5553
5704
  derivedGeneration = 0;
5554
5705
  /**
5555
5706
  * Derive the LLM message history by walking the ordered sequences of
@@ -5558,21 +5709,21 @@ var Session = class Session {
5558
5709
  * append records its `surfaceOp`, so a raw event with no marker (a chunk, a
5559
5710
  * turn boundary) is correctly absent, and a compaction `replace` deletes the
5560
5711
  * shadowed nodes from the derivation. The projection rules are
5561
- * {@link deriveEventMessage}, folded per node.
5712
+ * {@link deriveEventMessage}, with logged message projections applied
5713
+ * without changing node membership or message identity.
5562
5714
  *
5563
- * CACHED: each surface node is projected exactly once, when first seen — a
5564
- * call costs O(new nodes), and a surface rewrite (a `replace`;
5565
- * {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is
5715
+ * CACHED: pure tail growth costs O(new nodes); a replacement or message projection
5716
+ * ({@link SessionSurface.contentGeneration}) rebuilds. The returned array is
5566
5717
  * a fresh snapshot per call (later appends never grow an array a caller
5567
5718
  * already holds); the `Message` objects in it are SHARED and **deep-frozen**.
5568
- * Their content reuses the already frozen durable event data, so the cache
5569
- * needs no second deep clone and consumers still cannot mutate the log.
5719
+ * Unchanged content reuses frozen event data; projected blocks are frozen
5720
+ * derived copies. Consumers cannot mutate the log through either form.
5570
5721
  * @returns a fresh array of the shared, frozen derived history.
5571
5722
  */
5572
5723
  deriveMessages() {
5573
5724
  const surface = this.surface;
5574
5725
  const nodes = surface.nodes;
5575
- const generation = surface.replaceGeneration;
5726
+ const generation = surface.contentGeneration;
5576
5727
  if (generation !== this.derivedGeneration) {
5577
5728
  this.derived = [];
5578
5729
  this.derivedNodes = 0;
@@ -5586,13 +5737,13 @@ var Session = class Session {
5586
5737
  return [...this.derived];
5587
5738
  }
5588
5739
  /**
5589
- * Instance face of the pure per-node `deriveEventMessage` export from
5590
- * `surface.ts`.
5740
+ * Project one event with all committed message projections applied.
5741
+ * The original durable event remains unchanged.
5591
5742
  * @param event - the event to project.
5592
5743
  * @returns the derived message, or null when the event produces none.
5593
5744
  */
5594
5745
  deriveEventMessage(event) {
5595
- return deriveEventMessage(event);
5746
+ return this.surfaceManager.deriveEventMessage(event);
5596
5747
  }
5597
5748
  };
5598
5749
  //#endregion
@@ -6183,7 +6334,7 @@ function validateInstalledCurrentSessionHeader(header) {
6183
6334
  */
6184
6335
  function validateInstalledCurrentSessionArtifact(artifact) {
6185
6336
  if (artifact.header.version !== 3) throw new Error(`installed Session format is v3, got v${artifact.header.version}`);
6186
- Session.fromRestore(SessionId(artifact.header.id), artifact.events, artifact.header, SessionLogOffset(artifact.inheritedEventCount), "detached");
6337
+ Session.fromRestore(SessionId(artifact.header.id), artifact.events, artifact.header, SessionLogOffset(artifact.inheritedEventCount), "detached", currentSessionMessageProjections);
6187
6338
  }
6188
6339
  //#endregion
6189
6340
  //#region ../session-format-v0-to-v1/src/dispositions.ts
@@ -7657,11 +7808,11 @@ function assertReleasedV1Header(header) {
7657
7808
  * @param record - exact event envelope.
7658
7809
  * @param seq - event position used for earlier-reference checks.
7659
7810
  * @param type - surface event type used in diagnostics.
7660
- * @param assistantSources - whether this generation admits empty Assistant chunk provenance.
7811
+ * @param assistantSources - whether this generation admits empty Assistant chunk references.
7661
7812
  */
7662
7813
  function assertReleasedSurfaceMetadata(record, seq, type, assistantSources) {
7663
7814
  const sources = record["sourceEventSeqs"];
7664
- if (type === "assistant/message" && sources !== void 0 && assistantSources === "forbid-assistant") throw new SessionFormatError(`assistant/message ${seq} retains obsolete chunk provenance`);
7815
+ if (type === "assistant/message" && sources !== void 0 && assistantSources === "forbid-assistant") throw new SessionFormatError(`assistant/message ${seq} retains obsolete chunk references`);
7665
7816
  if (sources !== void 0) {
7666
7817
  if (!Array.isArray(sources)) throw new SessionFormatError(`${type} ${seq} sourceEventSeqs must be an array`);
7667
7818
  const seen = /* @__PURE__ */ new Set();
@@ -7977,6 +8128,7 @@ function decodeSeqRanges$1(value, maxEntries) {
7977
8128
  }
7978
8129
  //#endregion
7979
8130
  //#region ../session-format-v0-to-v1/src/migration.ts
8131
+ const LEGACY_ASSISTANT_SOURCE_KEY = ["pro", "venance"].join("");
7980
8132
  /** Identity format edge that promotes released v0 into released v1. */
7981
8133
  const sessionFormatV0ToV1 = defineSessionFormatMigration({
7982
8134
  name: "@deepseek-ai/dsh-session-format-v0-to-v1",
@@ -8300,9 +8452,12 @@ function normalizeLegacyMessage(event, sessionId, messageIds) {
8300
8452
  }
8301
8453
  };
8302
8454
  case "assistant/message": {
8303
- if (Object.hasOwn(data, "message") || !Object.hasOwn(data, "content") || !Object.hasOwn(data, "provenance")) return event;
8304
- const { content, provenance, ...eventData } = data;
8305
- const source = releasedV0Record(provenance, `assistant/message ${event.seq} provenance`);
8455
+ if (Object.hasOwn(data, "message") || !Object.hasOwn(data, "content") || !Object.hasOwn(data, LEGACY_ASSISTANT_SOURCE_KEY)) return event;
8456
+ const content = data["content"];
8457
+ const eventData = { ...data };
8458
+ delete eventData["content"];
8459
+ const source = releasedV0Record(eventData[LEGACY_ASSISTANT_SOURCE_KEY], `assistant/message ${event.seq} legacy source`);
8460
+ Reflect.deleteProperty(eventData, LEGACY_ASSISTANT_SOURCE_KEY);
8306
8461
  return {
8307
8462
  ...event,
8308
8463
  data: {
@@ -8878,7 +9033,7 @@ const releasedV2SessionFormatCodec = Object.freeze({
8878
9033
  return encodeHeader(header, inheritedEventCount);
8879
9034
  },
8880
9035
  encodeEvent(event) {
8881
- return encodeProvenance(event);
9036
+ return encodeSourceEventRanges(event);
8882
9037
  }
8883
9038
  });
8884
9039
  function decodePhysicalHeader(value) {
@@ -8986,7 +9141,7 @@ function encodeHeader(header, inheritedEventCount) {
8986
9141
  ...header.agentPreset === void 0 ? {} : { agentPreset: header.agentPreset }
8987
9142
  };
8988
9143
  }
8989
- function encodeProvenance(event) {
9144
+ function encodeSourceEventRanges(event) {
8990
9145
  if (event.sourceEventSeqs === void 0) return event;
8991
9146
  return {
8992
9147
  ...event,
@@ -9229,7 +9384,7 @@ function transformMessage(state, event, context) {
9229
9384
  emitSource(state, messageEvent(event, attemptGroup(turn, step)), context);
9230
9385
  return;
9231
9386
  }
9232
- if (pending === void 0 || !matchesChunkSources(pending.group, sources)) throw refusal(`assistant/message ${event.seq} chunk provenance is not one complete ordered attempt`);
9387
+ if (pending === void 0 || !matchesChunkSources(pending.group, sources)) throw refusal(`assistant/message ${event.seq} chunk references are not one complete ordered attempt`);
9233
9388
  assertAttemptCut(state, pending.group, event.seq);
9234
9389
  pending.group.terminal = true;
9235
9390
  flushBuffered(state, pending, context);
@@ -10053,7 +10208,7 @@ function relationshipEvent(event) {
10053
10208
  //#endregion
10054
10209
  //#region ../session-format-v2-to-v3/src/codec.ts
10055
10210
  /** V3 framing with hard structural admission and recoverable canonical event validation. */
10056
- /** V3 codec validates structural rows before recovery and logical envelopes after provenance decoding. */
10211
+ /** V3 codec validates structural rows before recovery and logical envelopes after source-event range decoding. */
10057
10212
  const releasedV3SessionFormatCodec = Object.freeze({
10058
10213
  version: 3,
10059
10214
  decodeHeader(value) {
@@ -10119,7 +10274,7 @@ const releasedV3SessionFormatCodec = Object.freeze({
10119
10274
  });
10120
10275
  /**
10121
10276
  * Validate owned V3 admission rules before a scanner or codec can discard a recoverable tail.
10122
- * This checks only identified structural payloads; physical provenance still belongs to decoding.
10277
+ * This checks only identified structural payloads; physical source-event ranges still belong to decoding.
10123
10278
  * @param row - parsed physical row, before envelope or compressed-range decoding.
10124
10279
  */
10125
10280
  function assertV3RowAdmission(row) {
@@ -11289,7 +11444,7 @@ async function verifyCurrentGeneration(path, compression, expectedId, expectedEv
11289
11444
  });
11290
11445
  if (generation.meta.id !== expectedId) throw new Error(`current session generation contains id "${generation.meta.id}", expected "${expectedId}"`);
11291
11446
  if (generation.events.length !== expectedEventCount) throw new Error(`current session generation contains ${generation.events.length} events, expected ${expectedEventCount}`);
11292
- Session.fromRestore(generation.meta.id, generation.events, generation.meta, generation.inheritedEventCount, "detached");
11447
+ Session.fromRestore(generation.meta.id, generation.events, generation.meta, generation.inheritedEventCount, "detached", currentSessionMessageProjections);
11293
11448
  assertCurrentAssistantStreams(generation.events);
11294
11449
  return {
11295
11450
  identity: snapshot.identity,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-session-persistence-jsonl",
3
3
  "description": "JSONL durable session persistence backend for the DeepSeek Harness",
4
- "version": "0.1.5-rc.1",
4
+ "version": "0.1.6-alpha.1",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -28,23 +28,23 @@
28
28
  ],
29
29
  "license": "MIT",
30
30
  "peerDependencies": {
31
- "@deepseek-ai/dsh-session": "^0.1.5-rc.1",
32
- "@deepseek-ai/dsh-session-persistence": "^0.1.5-rc.1",
31
+ "@deepseek-ai/dsh-session": "^0.1.6-alpha.1",
32
+ "@deepseek-ai/dsh-session-persistence": "^0.1.6-alpha.1",
33
33
  "@deepseek-ai/cordis": "^4.0.2"
34
34
  },
35
35
  "dependencies": {
36
36
  "koffi": "^3.1.0",
37
- "@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
38
- "@deepseek-ai/dsh-session-format": "^0.1.5-rc.1",
39
- "@deepseek-ai/dsh-session-format-catalog": "^0.1.5-rc.1",
37
+ "@deepseek-ai/dsh-llm": "^0.1.6-alpha.1",
38
+ "@deepseek-ai/dsh-session-format": "^0.1.6-alpha.1",
39
+ "@deepseek-ai/dsh-session-format-catalog": "^0.1.6-alpha.1",
40
+ "@deepseek-ai/dsh-session-format-v2-to-v3": "^0.1.6-alpha.1",
40
41
  "@deepseek-ai/node-addon-system": "^0.1.2",
41
- "@deepseek-ai/schemastery": "^3.18.2",
42
- "@deepseek-ai/dsh-session-format-v2-to-v3": "^0.1.5-rc.1"
42
+ "@deepseek-ai/schemastery": "^3.18.2"
43
43
  },
44
44
  "devDependencies": {
45
- "@deepseek-ai/dsh-session": "^0.1.5-rc.1",
46
- "@deepseek-ai/dsh-session-format-v0-to-v1": "^0.1.5-rc.1",
47
- "@deepseek-ai/dsh-session-persistence": "^0.1.5-rc.1",
45
+ "@deepseek-ai/dsh-session": "^0.1.6-alpha.1",
46
+ "@deepseek-ai/dsh-session-format-v0-to-v1": "^0.1.6-alpha.1",
47
+ "@deepseek-ai/dsh-session-persistence": "^0.1.6-alpha.1",
48
48
  "@deepseek-ai/cordis": "^4.0.2"
49
49
  }
50
50
  }