dsh-log-contract 0.3.10 → 0.3.12

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/lib/compat.js ADDED
@@ -0,0 +1,282 @@
1
+ /**
2
+ * dsh-log-contract · lib/compat.js —— 兼容层(任务 1.3 · 装即坏修复;2026-09-14 语义对齐重写)
3
+ *
4
+ * 背景:本包原先从 `@deepseek-ai/dsh-session` 直接导入 `decodeStorageRecord` 与 `isJsonValue`。
5
+ * 官方 0.1.5 树已**不再从包根导出**这两个符号(0.1.5-rc.1 `lib/index.js` 的 26 个导出里没有它们),
6
+ * 而本包是 plugin 的依赖 ⇒ 升级后"装即坏"(模块加载期报 does not provide an export named ...)。
7
+ *
8
+ * 本文件把这两个符号**本地化**,并把语义从"近似"改为**逐条移植**(原实现是宽松近似:
9
+ * `decodeStorageRecord` 对损坏 chunk 行返回 `[value]` 而非抛错,导致 R2 永不触发;
10
+ * `isJsonValue` 不拒 `-0`、不拒稀疏数组、不查原型——与官方 lossless-JSON 边界不一致)。
11
+ *
12
+ * 移植源(一手,2026-09-14 实测):
13
+ * - `decodeStorageRecord` / `validateRow` / `expandRow`:
14
+ * `@deepseek-ai/dsh-session@0.1.0-rc.7` `lib/index.js:922-1035`(该版本是最后一个把
15
+ * `decodeStorageRecord` 从包根导出的版本;与 App 2.0.9 内置的
16
+ * `dsh-session/lib/types/chunk-rows.js`(= 0.1.2-rc.1,sha256 前 16 位 `5724c4f798ed07e7`)
17
+ * 校验规则一致)。**fail-loud**:损坏的 chunk 行必须抛错——它是损坏存储,静默当普通事件
18
+ * 处理会丢掉整段 run。
19
+ * - `isJsonValue`:`@deepseek-ai/dsh-session@0.1.0-rc.7` `lib/index.js:74-205`
20
+ * `walkJsonValue(value, false)`(lossless-JSON 边界:拒绝稀疏数组、非有限数、`-0`、
21
+ * 循环引用、非普通原型、symbol/不可枚举键)。
22
+ *
23
+ * 仍从官方导入的符号(升级后保留):`foldSurface` / `isSurfaceEligibleType` /
24
+ * `KNOWN_SESSION_EVENT_TYPES`(见 `./vocab.js` / `./checks.js`)。
25
+ */
26
+
27
+ /** Whether a value is a JSON-visible record (object, not null, not array)(rc.7 `isRecord`)。 */
28
+ function isRecord(value) {
29
+ return typeof value === 'object' && value !== null;
30
+ }
31
+
32
+ /** Exact-key check: `value` has every key in `keys` and nothing else(rc.7 `hasExactKeys`)。 */
33
+ function hasExactKeys(value, keys) {
34
+ return Object.keys(value).length === keys.length && keys.every((k) => Object.hasOwn(value, k));
35
+ }
36
+
37
+ //#region isJsonValue —— rc.7 walkJsonValue 的移植(不含 detach)
38
+ /** Whether a value is an object whose prototype is an intrinsic (plain) object prototype, across realms. */
39
+ function isIntrinsicObjectPrototype(prototype) {
40
+ return prototype === null || Object.getPrototypeOf(prototype) === null;
41
+ }
42
+ /** Whether an object is a plain or null-prototype record from any JavaScript realm. */
43
+ function hasPlainObjectPrototype(value) {
44
+ const prototype = Object.getPrototypeOf(value);
45
+ return prototype === null || (typeof prototype === 'object' && isIntrinsicObjectPrototype(prototype));
46
+ }
47
+ /** Whether an array is a plain array from any JavaScript realm (rejects subclasses/exotics). */
48
+ function hasPlainArrayPrototype(value) {
49
+ const prototype = Object.getPrototypeOf(value);
50
+ if (prototype === Array.prototype) return true;
51
+ return typeof prototype === 'object' && prototype !== null && Object.getPrototypeOf(prototype) === Object.prototype && Object.getPrototypeOf(Object.getPrototypeOf(prototype)) === null;
52
+ }
53
+ /** Return every JSON-visible object key, or reject own data JSON would discard. */
54
+ function enumerableStringKeys(value) {
55
+ const keys = Reflect.ownKeys(value);
56
+ if (keys.some((key) => typeof key !== 'string' || !Object.prototype.propertyIsEnumerable.call(value, key))) return undefined;
57
+ return keys;
58
+ }
59
+
60
+ /**
61
+ * Validate lossless JSON iteratively(rc.7 `walkJsonValue(value, false)`)。
62
+ * @param {unknown} value
63
+ * @returns {boolean}
64
+ */
65
+ export function isJsonValue(value) {
66
+ const ancestors = new Set();
67
+ const tasks = [{ kind: 'visit', value }];
68
+ for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
69
+ if (task.kind === 'leave') {
70
+ ancestors.delete(task.source);
71
+ continue;
72
+ }
73
+ if (task.kind === 'array-item') {
74
+ if (!Object.prototype.hasOwnProperty.call(task.source, task.index)) return false;
75
+ tasks.push({ kind: 'visit', value: task.source[task.index] });
76
+ continue;
77
+ }
78
+ if (task.kind === 'object-property') {
79
+ tasks.push({ kind: 'visit', value: task.source[task.key] });
80
+ continue;
81
+ }
82
+ const current = task.value;
83
+ if (current === null) continue;
84
+ if (typeof current === 'boolean' || typeof current === 'string') continue;
85
+ if (typeof current === 'number') {
86
+ if (!Number.isFinite(current) || Object.is(current, -0)) return false;
87
+ continue;
88
+ }
89
+ if (typeof current !== 'object') return false;
90
+ if (ancestors.has(current)) return false;
91
+ if (Array.isArray(current)) {
92
+ if (!hasPlainArrayPrototype(current)) return false;
93
+ const length = current.length;
94
+ if (Reflect.ownKeys(current).length !== length + 1) return false;
95
+ ancestors.add(current);
96
+ tasks.push({ kind: 'leave', source: current });
97
+ for (let index = length - 1; index >= 0; index--) tasks.push({ kind: 'array-item', source: current, index });
98
+ continue;
99
+ }
100
+ if (!hasPlainObjectPrototype(current)) return false;
101
+ const keys = enumerableStringKeys(current);
102
+ if (keys === undefined) return false;
103
+ ancestors.add(current);
104
+ tasks.push({ kind: 'leave', source: current });
105
+ for (let index = keys.length - 1; index >= 0; index--) {
106
+ const key = keys[index];
107
+ /* v8 ignore next -- the loop is bounded by the captured key count. */
108
+ if (key === undefined) return false;
109
+ tasks.push({ kind: 'object-property', source: current, key });
110
+ }
111
+ }
112
+ return true;
113
+ }
114
+ //#endregion
115
+
116
+ //#region decodeStorageRecord —— rc.7 validateRow/expandRow 的移植
117
+ /** Throw the uniform malformed-row diagnostic(rc.7 `malformed`)。 */
118
+ function malformed(tag, why) {
119
+ throw new Error(`malformed ${tag} storage row: ${why}`);
120
+ }
121
+
122
+ /** Validate the shared run-data fields and the payload/dt arity; returns the member payload(rc.7 `validateRunData`)。 */
123
+ function validateRunData(tag, data, payloadKey) {
124
+ if (typeof data.turn !== 'number' || typeof data.step !== 'number' || typeof data.index !== 'number') malformed(tag, 'turn/step/index must be numbers');
125
+ const payload = data[payloadKey];
126
+ if (!Array.isArray(payload) || payload.length === 0 || payload.some((entry) => typeof entry !== 'string')) malformed(tag, `${payloadKey} must be a non-empty string array`);
127
+ const dt = data.dt;
128
+ if (!Array.isArray(dt) || dt.some((gap) => !Number.isSafeInteger(gap))) malformed(tag, 'dt must be an array of safe integers');
129
+ if (dt.length !== payload.length - 1) malformed(tag, `dt length ${dt.length} does not match ${payload.length} members`);
130
+ return payload;
131
+ }
132
+
133
+ /** Validate a row-tagged parsed value's envelope and data, throwing on any malformation(rc.7 `validateRow`)。 */
134
+ function validateRow(value, tag) {
135
+ if (!hasExactKeys(value, ['type', 'seq0', 'time0', 'data'])) malformed(tag, 'envelope must be exactly {type, seq0, time0, data}');
136
+ if (!Number.isSafeInteger(value.seq0) || value.seq0 < 0) malformed(tag, 'seq0 must be a non-negative safe integer');
137
+ if (!Number.isSafeInteger(value.time0)) malformed(tag, 'time0 must be a safe integer');
138
+ const data = value.data;
139
+ if (!isRecord(data)) malformed(tag, 'data must be an object');
140
+ let payload;
141
+ if (tag === 'tool-call-chunks') {
142
+ const withName = hasExactKeys(data, ['turn', 'step', 'index', 'id', 'name', 'dt', 'args']);
143
+ if (!withName && !hasExactKeys(data, ['turn', 'step', 'index', 'id', 'dt', 'args'])) malformed(tag, 'data must be exactly {turn, step, index, id, name?, dt, args}');
144
+ if (typeof data.id !== 'string' || (withName && typeof data.name !== 'string')) malformed(tag, 'id (and name when present) must be strings');
145
+ payload = validateRunData(tag, data, 'args');
146
+ } else {
147
+ if (!hasExactKeys(data, ['turn', 'step', 'index', 'dt', 'texts'])) malformed(tag, 'data must be exactly {turn, step, index, dt, texts}');
148
+ payload = validateRunData(tag, data, 'texts');
149
+ }
150
+ if (!Number.isSafeInteger(value.seq0 + payload.length - 1)) malformed(tag, 'member seqs must stay safe integers');
151
+ let time = value.time0;
152
+ for (const gap of data.dt) {
153
+ time += gap;
154
+ if (!Number.isSafeInteger(time)) malformed(tag, 'member times must stay safe integers');
155
+ }
156
+ return value;
157
+ }
158
+
159
+ /** Expand a validated row back into its exact original events, in order(rc.7 `expandRow`)。 */
160
+ function expandRow(row) {
161
+ const members = row.type === 'tool-call-chunks' ? row.data.args : row.data.texts;
162
+ const events = [];
163
+ let time = row.time0;
164
+ for (let k = 0; k < members.length; k++) {
165
+ if (k > 0) time += row.data.dt[k - 1];
166
+ let chunk;
167
+ switch (row.type) {
168
+ case 'text-chunks':
169
+ chunk = { type: 'text-delta', index: row.data.index, text: members[k] };
170
+ break;
171
+ case 'reasoning-chunks':
172
+ chunk = { type: 'reasoning-delta', index: row.data.index, text: members[k] };
173
+ break;
174
+ case 'tool-call-chunks':
175
+ chunk = {
176
+ type: 'tool-call-delta',
177
+ index: row.data.index,
178
+ id: row.data.id,
179
+ ...Object.hasOwn(row.data, 'name') ? { name: row.data.name } : {},
180
+ argumentsDelta: members[k],
181
+ };
182
+ break;
183
+ /* v8 ignore next 4 -- validateRow only returns the three row tags */
184
+ default:
185
+ throw new Error(`chunk-rows received unsupported row ${String(row)}`);
186
+ }
187
+ events.push({ type: 'assistant/chunk', seq: row.seq0 + k, time, data: { turn: row.data.turn, step: row.data.step, chunk } });
188
+ }
189
+ return events;
190
+ }
191
+
192
+ /**
193
+ * 存储行解码:chunk 行校验后展开为完整事件序列,其他行原样返回(rc.7 `decodeStorageRecord`)。
194
+ * 损坏的 chunk 行**抛错**(fail-loud)——调用方(log-reader)据此落 `row.error` → R2。
195
+ * @param {unknown} value 已 JSON.parse 的一行
196
+ * @returns {unknown[]} 事件数组
197
+ */
198
+ export function decodeStorageRecord(value) {
199
+ if (!isRecord(value)) return [value];
200
+ const tag = value.type;
201
+ if (tag !== 'text-chunks' && tag !== 'reasoning-chunks' && tag !== 'tool-call-chunks') return [value];
202
+ return expandRow(validateRow(value, tag));
203
+ }
204
+ //#endregion
205
+
206
+ //#region decodeSeqRanges —— v3 storage-form 区间编码解码(0.1.5 `lib/types/seq-ranges.js` 逐条移植)
207
+ // 为什么本地化:`decodeSeqRanges` 只在 0.1.5+ 从包根导出;`0.1.0-rc.7` 实测 `undefined`
208
+ // (见 vocab.js 的双宿主声明)。本包 peer 允许两个版本 ⇒ 必须在两种宿主上都能解码。
209
+ //
210
+ // 语义(官方 `seq-ranges.js:34-73`):`sourceEventSeqs` 的 JSON 存储形态是"数字 + 闭区间对"
211
+ // 混合数组,例 `[3, [174,176], 180, …]` ⇒ 内存形态 `[3,174,175,176,180,…]`。
212
+ // v3 持久化层对连续段用区间对压缩(App 2.0.9 真实 v3 会话实测:1 个 `user/message` replace
213
+ // 的 `sourceEventSeqs` 2251 个存储项展开为 2259 个 seq)。**不解码 = 拿物理未展开的序列
214
+ // 喂 S5/S6 与官方 foldSurface ⇒ 真实健康会话被误判 S5/S6/S8 + `--resume` broken。**
215
+
216
+ /** 非负安全整数(rc.7/0.1.5 `assertSeq`)。 */
217
+ function assertSeq(value) {
218
+ if (!Number.isSafeInteger(value) || value < 0) throw new TypeError('sourceEventSeqs must contain non-negative safe integers');
219
+ }
220
+ /** 带 Session 序号 brand 的断言(官方 `SessionSeq`;额外拒绝 `-0`)。 */
221
+ function sessionSeq(value) {
222
+ if (!Number.isSafeInteger(value) || value < 0 || Object.is(value, -0)) {
223
+ throw new TypeError(`SessionSeq must be a non-negative safe integer, got ${String(value)}`);
224
+ }
225
+ return value;
226
+ }
227
+
228
+ /**
229
+ * 展开 JSON 存储形态的 `sourceEventSeqs`(官方 `decodeSeqRanges` 逐条移植)。
230
+ * 任一形态非法即抛 `TypeError`(fail-loud:调用方据此报 S6/E4,不静默当稠密序列)。
231
+ * @param {unknown} value 已 JSON.parse 的 `sourceEventSeqs`
232
+ * @param {number} [maxEntries] 该事件允许的最大成员数
233
+ * @returns {number[]} 内存稠密序列
234
+ */
235
+ export function decodeSeqRanges(value, maxEntries = Number.MAX_SAFE_INTEGER) {
236
+ if (!Array.isArray(value)) throw new TypeError('sourceEventSeqs must be an array');
237
+ const decoded = [];
238
+ let hasRange = false;
239
+ for (const entry of value) {
240
+ if (typeof entry === 'number') {
241
+ assertSeq(entry);
242
+ if (decoded.length >= maxEntries) throw new TypeError('sourceEventSeqs exceeds its event sequence');
243
+ decoded.push(sessionSeq(entry));
244
+ continue;
245
+ }
246
+ if (!Array.isArray(entry) || entry.length !== 2) {
247
+ throw new TypeError('sourceEventSeqs range entries must be [start, end] pairs');
248
+ }
249
+ const start = entry[0];
250
+ const end = entry[1];
251
+ assertSeq(start);
252
+ assertSeq(end);
253
+ if (end < start) throw new TypeError('sourceEventSeqs ranges require start <= end');
254
+ if (end - start + 1 > maxEntries - decoded.length) {
255
+ throw new TypeError('sourceEventSeqs range exceeds its event sequence');
256
+ }
257
+ for (let seq = start; seq <= end; seq += 1) decoded.push(sessionSeq(seq));
258
+ hasRange = true;
259
+ }
260
+ if (hasRange && !decoded.every((v, i) => i === 0 || v > decoded[i - 1])) {
261
+ throw new TypeError('sourceEventSeqs ranges must be strictly increasing');
262
+ }
263
+ return decoded;
264
+ }
265
+
266
+ /**
267
+ * 把一个已解码事件的 storage-form `sourceEventSeqs` 归一成内存稠密序列。
268
+ * - 无 `sourceEventSeqs` / 非对象 → 原样返回(同一引用);
269
+ * - 解码成功 → 返回**新对象**(不修改入参;调用方须使用返回值);
270
+ * - 解码失败 → 原样返回,保留存储形态交由 S6/E4 报违规(读取层不崩、不吞错)。
271
+ */
272
+ export function normalizeEventSeqRanges(event) {
273
+ if (!isRecord(event) || event.sourceEventSeqs === undefined) return event;
274
+ let expanded;
275
+ try {
276
+ expanded = decodeSeqRanges(event.sourceEventSeqs);
277
+ } catch {
278
+ return event;
279
+ }
280
+ return { ...event, sourceEventSeqs: expanded };
281
+ }
282
+ //#endregion
package/lib/contracts.js CHANGED
@@ -21,6 +21,7 @@ export const LAYER = {
21
21
  PLUGIN: 'plugin', // 插件语义层:marker 隐藏语义
22
22
  CONCURRENCY: 'concurrency', // 并发/写入者假设
23
23
  FRAMING: 'framing', // zstd 帧结构
24
+ MIGRATION: 'migration', // 迁移预检层:官方 v0/v1/v2 → 当前格式的升级路径会不会拒(**独立维度**)
24
25
  };
25
26
 
26
27
  export const SEVERITY = { ERROR: 'error', WARNING: 'warning', INFO: 'info' };
@@ -35,6 +36,7 @@ export const SEVERITY = { ERROR: 'error', WARNING: 'warning', INFO: 'info' };
35
36
  * - P 插件 marker 语义层
36
37
  * - C 并发 / 写入者假设
37
38
  * - Z zstd 帧结构
39
+ * - G 迁移预检(migration gate,**独立维度**:官方迁移会不会拒;不进 ok/verdict)
38
40
  */
39
41
  export const CONTRACT_RULES = [
40
42
  // ── H · header ──────────────────────────────────────────────────────────
@@ -51,8 +53,8 @@ export const CONTRACT_RULES = [
51
53
  title: 'header 版本与必填字段',
52
54
  layer: LAYER.PERSISTENCE,
53
55
  severity: SEVERITY.ERROR,
54
- source: '@deepseek-ai/dsh-session lib/index.js:1110-1125',
55
- description: 'header.version 必须为 0;id 为字符串;createdAt 为非负安全整数;cwd 若存在必须为绝对路径;origin 只能为 "subagent"。',
56
+ source: '@deepseek-ai/dsh-session lib/index.js:1110-1125;已知格式版本 0/1/2/3(App 2.0.9 内置 v0→v1→v2→v3 迁移,SESSION_FORMAT_VERSION=3)',
57
+ description: 'header.version 必须为已知受支持版本(0/1/2/3);未知版本报 H2。id 为字符串;createdAt 为非负安全整数;cwd 若存在必须为绝对路径;origin 只能为 "subagent"。',
56
58
  },
57
59
 
58
60
  // ── R · 存储行 ──────────────────────────────────────────────────────────
@@ -209,8 +211,8 @@ export const CONTRACT_RULES = [
209
211
  title: '整日志 foldSurface 可重放',
210
212
  layer: LAYER.PERSISTENCE,
211
213
  severity: SEVERITY.ERROR,
212
- source: '@deepseek-ai/dsh-session lib/index.js:444-455 (foldSurface);复盘"官方 foldSurface 不抛 = 通过"',
213
- description: '终验:把全部事件按序喂给官方 foldSurface,不抛 = 持久化层通过。S1–S7 任何一条违反都会在此暴露。',
214
+ source: '@deepseek-ai/dsh-session lib/index.js:444-455 (foldSurface, v3);v0/v1/v2 用本地等价实现 lib/legacy-fold.js(rc.7 lib/index.js:229-455 逐条移植)',
215
+ description: '终验:按被检文件 header.version 选折叠器(v3 → 官方 foldSurface;v0/v1/v2 → 本地 legacyFoldSurface),不抛 = 持久化层通过。S1–S7 任何一条违反都会在此暴露。注意 0.1.5 的官方 foldSurface 是 v3 语义(replace 用 startSeq/endSeq、assistant/message 禁 sourceEventSeqs),对旧格式文件会误报,不可借用。',
214
216
  },
215
217
 
216
218
  {
@@ -253,6 +255,22 @@ export const CONTRACT_RULES = [
253
255
  source: '官方 dsh-agent-loop lib/index.js:620(turn/end = {turn, reason:{kind}});1f4d986e malformed turn/end 事故(2026-09-02,修复线 check-turn-end-reason.mjs)',
254
256
  description: '官方 validation 强制 turn/end 的 data.reason.kind 存在(kind ∈ completed|max-tokens|blocked|aborted|error|interrupted)。缺失 = malformed → 官方 SessionPersistenceCorruptionError → 会话加载失败。1f4d986e:retrace 情形③信封 turn/end 漏 reason → 每次编辑后加载失败(已修 0.4.18)。',
255
257
  },
258
+ {
259
+ id: 'E7',
260
+ title: 'ignorable 未知 type 合法性(带被忽略标记的未知事件须有消费者)',
261
+ layer: LAYER.PERSISTENCE,
262
+ severity: SEVERITY.WARNING,
263
+ source: '反向挑刺 2026-09-09 T2(E3 ignorable 无合法性校验 = 后门)',
264
+ description: '未知 type + ignorable:true 被读路径接纳但无人消费 = 静默垃圾。排除已知消费者白名单(retrace/marker、retrace/goal-marker、message-editor/ 前缀等 retrace 客户端消费的插件 marker)后,其余 ignorable 未知事件报 warning。',
265
+ },
266
+ {
267
+ id: 'Z3',
268
+ title: '空会话文件(有 header 无事件)显式报出',
269
+ layer: LAYER.FRAMING,
270
+ severity: SEVERITY.WARNING,
271
+ source: '反向挑刺 2026-09-09 T3(36 条规则全来自有内容事故,空态无覆盖)',
272
+ description: '有 header 但零事件 = 异常空会话(新建即空或写入未落盘)。空态不在任何有内容规则的覆盖下,显式 warning 供人判断。',
273
+ },
256
274
  {
257
275
  id: 'P3',
258
276
  title: 'tool/call ↔ tool/result 配对完整性(考古 B1)',
@@ -342,6 +360,28 @@ export const CONTRACT_RULES = [
342
360
  source: 'OpenAI 兼容端点对 tool 消息顺序的严格校验;DSH 序列化器将混合 user 消息展开为 text 在前、tool-result 在后',
343
361
  description: '当仍有未满足的 assistant tool-call 时出现 user 文本消息,会产生 [assistant(tool_calls), user(text), tool] 序列,严格端点同样拒绝。',
344
362
  },
363
+
364
+ // ── G · 迁移预检(migration precheck;**独立维度**,不是"可加载"判定)──────────
365
+ // 存在理由:官方把 v0/v1/v2 会话升到当前格式时会做一轮**迁移校验**,其规则比本工具的
366
+ // "读取/折叠"规则集更严(官方原文见各条 source)。本工具判"可加载"≠"可升级"。
367
+ // 这些规则只描述"官方迁移会不会拒",因此 severity 固定 warning、layer 固定 migration:
368
+ // 它们**不参与** ok / loadable / resumable / compactable,只喂 `migrationVerdict()`。
369
+ {
370
+ id: 'G1',
371
+ title: '迁移预检:v0 源文件的 subagent/descriptor.data.version 必须为 3',
372
+ layer: LAYER.MIGRATION,
373
+ severity: SEVERITY.WARNING,
374
+ source: '@deepseek-ai/dsh-session-format-v0-to-v1@0.1.5-rc.2 lib/index.js:1584-1586(assertReleasedEventPayload):data.version !== 3 且源版本 === 0 → SessionFormatUnsupportedMigrationError("uses unsupported descriptor version N");源版本 1/2 时官方提前 return(容忍)',
375
+ description: '文件版本 0 且事件类型为 subagent/descriptor 且 data.version !== 3 → 官方 v0→v1 迁移直接拒绝(消息形如 `subagent/descriptor <seq> uses unsupported descriptor version 2`);源版本 1/2 不受此条约束。',
376
+ },
377
+ {
378
+ id: 'G2',
379
+ title: '迁移预检:v0 源文件不得含词表外的历史事件类型(含 ignorable)',
380
+ layer: LAYER.MIGRATION,
381
+ severity: SEVERITY.WARNING,
382
+ source: '@deepseek-ai/dsh-session-format-v0-to-v1@0.1.5-rc.2 lib/index.js:1580-1583(assertReleasedEventPayload):RELEASED_V0_EVENT_DISPOSITIONS 里没有该 type → "format v0 contains unknown historical event type … migration refuses unknown historical events even when ignorable"',
383
+ description: '文件版本 0 且事件 type 不在官方 v0 dispositions(本包 vendored 为 V0_EVENT_TYPES)内 → 官方迁移拒绝,**即使该事件带 ignorable:true**。E3 的 ignorable 豁免是**读取路径**语义(不改),本条只在迁移预检维度表达。',
384
+ },
345
385
  ];
346
386
 
347
387
  /** 按 id 取规则。 */
package/lib/index.js CHANGED
@@ -5,9 +5,10 @@
5
5
  * 公开 API:离线体检 + 写前校验 + 修复 + 契约目录。
6
6
  */
7
7
  export { loadSessionLog, tailSeq, readSessionHeader } from './log-reader.js';
8
- export { validateSessionLog, resumeVerdict } from './validate.js';
8
+ export { validateSessionLog, resumeVerdict, migrationVerdict, assessmentScope } from './validate.js';
9
9
  export { createPreWriter, preWriterFromLog } from './prewrite.js';
10
10
  export { repairSession, strictScanText, removeMarkersText, neutralizeMarkersText, clipCrossStepSourcesText, dropFailedTurnsText, trimLastMessagesText, trimLastMessagesByBudget, estimateTokensText, compactLastMessagesText, rebuildZstdText, tailRenumberText, neutralizeOrphanText, extractTurnText, keepRangesText } from './repair.js';
11
11
  export { CONTRACT_RULES, LAYER, SEVERITY, ruleById } from './contracts.js';
12
- export { tokenMeterViolations, tokenMeterSourceViolations, stepKeyViolations, nullTurnStepViolations, turnEndReasonViolations, physicalOrderViolations, inboxReplayViolations } from './checks.js';
12
+ export { HOST_MAX_FILE_VERSION, hostPackageVersion, hostCapability, isAssessableFileVersion } from './vocab.js';
13
+ export { tokenMeterViolations, tokenMeterSourceViolations, stepKeyViolations, nullTurnStepViolations, turnEndReasonViolations, ignorableTypeViolations, physicalOrderViolations, inboxReplayViolations } from './checks.js';
13
14
  export { auditToolCalls, extractText, extractToolOutputs, indexToolCalls, toolCommandOf } from './archaeology.js';
@@ -0,0 +1,177 @@
1
+ /**
2
+ * dsh-log-contract · lib/legacy-fold.js —— **v0/v1/v2 旧格式的 surface 终验器**
3
+ *
4
+ * 为什么需要这个文件(一手证据,2026-09-14):
5
+ *
6
+ * 官方 `@deepseek-ai/dsh-session` 的 `foldSurface` 在 0.1.5-rc.1 里换成了 **v3 语义**,
7
+ * 它**不是** rc.7(v0 语义)的超集,而是一个**不同格式**的校验器:
8
+ * - replace 操作数字段改名:rc.7 `{op,start,end}` → 0.1.5 `{op,startSeq,endSeq}`
9
+ * (0.1.5 `lib/index.js:279`:`isReplaceOp` 要求 startSeq/endSeq ⇒ 旧 marker 直接
10
+ * “carries an invalid replace surfaceOp”);
11
+ * - provenance 收紧:0.1.5 `lib/index.js:285` 起对 `assistant/message` **一律**禁止
12
+ * `sourceEventSeqs`(“embeds its source stream and cannot carry sourceEventSeqs”);
13
+ * rc.7 允许(空数组仅限 assistant/message)。
14
+ *
15
+ * App 2.0.9 的真实文件版本分布证明旧格式仍在线上:App 内置
16
+ * `dsh-session-format-v0-to-v1` / `v1-to-v2` / `v2-to-v3` 三个迁移包,
17
+ * `SESSION_FORMAT_VERSION = 3`;且 `v2-to-v3/lib/index.js:361-371` 明示 **v2 仍是
18
+ * `{start,end}`**、由迁移改名为 `startSeq/endSeq`。
19
+ *
20
+ * ⇒ 结论:**用运行时的 v3 `foldSurface` 去终验 v0/v1/v2 文件必然误报**;旧格式必须用
21
+ * 本文件这份等价实现(rc.7 `foldSurface` 的逐条移植,语义等价、同样 fail-loud)。
22
+ *
23
+ * 移植源:`@deepseek-ai/dsh-session@0.1.0-rc.7` `lib/index.js:229-455`
24
+ * (`isSurfaceEligibleType` / `isEventSeq` / `isReplaceOp` / `surfaceOpOf` /
25
+ * `assertProvenance` / `replacementRange` / `isDeepEqualJson` / `assertToolResultRewrite` /
26
+ * `planSurfaceEvent` / `applySurfacePlan` / `foldSurface`)。
27
+ *
28
+ * 与官方相同的点:**抛错即拒绝**(官方加载期 `SessionPersistenceCorruptionError` 同源
29
+ * 判据);事件按 `index` 作为期望 seq(`baseSeq = 0`),与官方 `foldSurface(events)` 一致。
30
+ */
31
+
32
+ /** v0/v1/v2 的 surface 候选类型(官方 rc.7 `SURFACE_EVENT_TYPES`,3 条)。 */
33
+ const SURFACE_EVENT_TYPES = new Set(['user/message', 'assistant/message', 'tool/result']);
34
+
35
+ /** Whether an event type can join the model-visible surface(rc.7 `isSurfaceEligibleType`)。 */
36
+ export function isLegacySurfaceEligibleType(type) {
37
+ return SURFACE_EVENT_TYPES.has(type);
38
+ }
39
+
40
+ /** Whether a runtime value is a non-negative safe event sequence(rc.7 `isEventSeq`)。 */
41
+ function isEventSeq(value) {
42
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
43
+ }
44
+
45
+ /** Whether a runtime value is the exact positional-replacement shape(rc.7 `isReplaceOp`:start/end)。 */
46
+ function isReplaceOp(value) {
47
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
48
+ const op = value;
49
+ return (
50
+ Object.keys(op).length === 3 &&
51
+ Object.hasOwn(op, 'op') && Object.hasOwn(op, 'start') && Object.hasOwn(op, 'end') &&
52
+ op.op === 'replace' && isEventSeq(op.start) && isEventSeq(op.end)
53
+ );
54
+ }
55
+
56
+ /** Validate event-local surface eligibility and return its operation(rc.7 `surfaceOpOf`)。 */
57
+ function surfaceOpOf(event) {
58
+ if (!SURFACE_EVENT_TYPES.has(event.type)) {
59
+ if (event.surfaceOp !== undefined) throw new Error(`session event "${event.type}" is not surface-eligible and cannot carry surfaceOp`);
60
+ if (event.sourceEventSeqs !== undefined) throw new Error(`session event "${event.type}" is not surface-eligible and cannot carry sourceEventSeqs`);
61
+ return undefined;
62
+ }
63
+ const op = event.surfaceOp;
64
+ if (op === undefined) throw new Error(`session event "${event.type}" is surface-eligible and requires a surfaceOp marker`);
65
+ if (op === 'append') return op;
66
+ if (op === null || typeof op !== 'object' || Array.isArray(op)) throw new Error(`session event "${event.type}" carries an invalid surfaceOp`);
67
+ if (!isReplaceOp(op)) throw new Error(`session event "${event.type}" carries an invalid replace surfaceOp`);
68
+ return op;
69
+ }
70
+
71
+ /** Validate cited source-event seqs against prior log entries and the replacement range(rc.7 `assertProvenance`)。 */
72
+ function assertProvenance(event, shadowedSeqs) {
73
+ const raw = event.sourceEventSeqs;
74
+ const sources = new Set();
75
+ if (raw !== undefined) {
76
+ if (!Array.isArray(raw)) throw new Error(`sourceEventSeqs on event at seq ${event.seq} must be an array when present`);
77
+ if (raw.length === 0 && event.type !== 'assistant/message') throw new Error('sourceEventSeqs must not be empty except on assistant/message');
78
+ let nonEarlierSource;
79
+ for (const source of raw) {
80
+ if (!isEventSeq(source)) throw new Error(`session event "${event.type}" sourceEventSeqs must densely contain non-negative safe integers`);
81
+ sources.add(source);
82
+ if (nonEarlierSource === undefined && source >= event.seq) nonEarlierSource = source;
83
+ }
84
+ if (sources.size !== raw.length) throw new Error('sourceEventSeqs must not contain duplicates');
85
+ if (nonEarlierSource !== undefined) throw new Error(`sourceEventSeqs must reference earlier events: ${nonEarlierSource} >= current seq ${event.seq}`);
86
+ }
87
+ const missing = shadowedSeqs.filter((seq) => !sources.has(seq));
88
+ if (missing.length > 0) throw new Error(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`);
89
+ }
90
+
91
+ /** Locate one replacement range without mutating the current fold state(rc.7 `replacementRange`)。 */
92
+ function replacementRange(state, op) {
93
+ const startIdx = state.nodes.indexOf(op.start);
94
+ if (startIdx === -1) throw new Error(`surface replace: start seq ${op.start} not found in surface`);
95
+ const endIdx = state.nodes.indexOf(op.end);
96
+ if (endIdx === -1) throw new Error(`surface replace: end seq ${op.end} not found in surface`);
97
+ if (startIdx > endIdx) throw new Error(`surface replace: start seq ${op.start} (index ${startIdx}) is after end seq ${op.end} (index ${endIdx})`);
98
+ return { startIdx, endIdx, shadowedSeqs: state.nodes.slice(startIdx, endIdx + 1) };
99
+ }
100
+
101
+ /** Deep structural equality over the session-event JSON value domain(rc.7 `isDeepEqualJson`)。 */
102
+ function isDeepEqualJson(a, b) {
103
+ if (a === b) return true;
104
+ if (Array.isArray(a) || Array.isArray(b)) {
105
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
106
+ return a.every((item, i) => isDeepEqualJson(item, b[i]));
107
+ }
108
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
109
+ const aKeys = Object.keys(a);
110
+ if (aKeys.length !== Object.keys(b).length) return false;
111
+ return aKeys.every((key) => Object.hasOwn(b, key) && isDeepEqualJson(a[key], b[key]));
112
+ }
113
+
114
+ /** Restrict a tool-result replacement to one current result's content(rc.7 `assertToolResultRewrite`)。 */
115
+ function assertToolResultRewrite(event, shadowedSeqs, events, baseSeq) {
116
+ if (event.type !== 'tool/result') return;
117
+ if (shadowedSeqs.length !== 1) throw new Error('tool/result surface replacement must rewrite exactly one current node');
118
+ for (const originalSeq of shadowedSeqs) {
119
+ const original = events[originalSeq - baseSeq];
120
+ if (original?.type !== 'tool/result') throw new Error('tool/result surface replacement must target a current tool/result');
121
+ const originalRest = { ...original.data };
122
+ const replacementRest = { ...event.data };
123
+ const originalResult = original.data.message.content[0];
124
+ const replacementResult = event.data.message.content[0];
125
+ originalRest.message = { ...original.data.message, content: [{ ...originalResult, content: null }] };
126
+ replacementRest.message = { ...event.data.message, content: [{ ...replacementResult, content: null }] };
127
+ if (!isDeepEqualJson(originalRest, replacementRest)) throw new Error('tool/result surface replacement may change only content');
128
+ }
129
+ }
130
+
131
+ /** Validate one event at its replay boundary and prepare its atomic fold transition(rc.7 `planSurfaceEvent`)。 */
132
+ function planSurfaceEvent(state, event, expectedSeq, events, baseSeq) {
133
+ if (event.seq !== expectedSeq) throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`);
134
+ const surfaceOp = surfaceOpOf(event);
135
+ if (surfaceOp === undefined) return undefined;
136
+ if (surfaceOp === 'append') {
137
+ assertProvenance(event, []);
138
+ return { kind: 'append', seq: event.seq };
139
+ }
140
+ const range = replacementRange(state, surfaceOp);
141
+ assertProvenance(event, range.shadowedSeqs);
142
+ assertToolResultRewrite(event, range.shadowedSeqs, events, baseSeq);
143
+ return { kind: 'replace', seq: event.seq, start: surfaceOp.start, end: surfaceOp.end, ...range };
144
+ }
145
+
146
+ /** Commit one previously validated surface transition(rc.7 `applySurfacePlan`)。 */
147
+ function applySurfacePlan(state, plan) {
148
+ if (plan?.kind === 'append') state.nodes.push(plan.seq);
149
+ else if (plan?.kind === 'replace') {
150
+ state.nodes.splice(plan.startIdx, plan.endIdx - plan.startIdx + 1, plan.seq);
151
+ state.replaceGeneration += 1;
152
+ }
153
+ if (plan?.kind !== 'replace') return undefined;
154
+ return { seq: plan.seq, start: plan.start, end: plan.end, shadowedSeqs: plan.shadowedSeqs };
155
+ }
156
+
157
+ /** Apply one event and return replacement metadata only when one occurred(rc.7 `applySurfaceEvent`)。 */
158
+ function applySurfaceEvent(state, event, expectedSeq, events, baseSeq) {
159
+ return applySurfacePlan(state, planSurfaceEvent(state, event, expectedSeq, events, baseSeq));
160
+ }
161
+
162
+ /**
163
+ * Replay a complete session log through the **v0/v1/v2** canonical surface fold.
164
+ *
165
+ * @param events - session events in contiguous seq order(seq 从 0 起,与官方 `foldSurface` 同约定)。
166
+ * @returns {{ nodes: number[], replacements: Array<{seq:number,start:number,end:number,shadowedSeqs:number[]}> }}
167
+ * @throws when an event violates surface metadata, source-event references, range, or tool-result rewrite rules.
168
+ */
169
+ export function legacyFoldSurface(events) {
170
+ const state = { nodes: [], replaceGeneration: 0 };
171
+ const replacements = [];
172
+ for (const [index, event] of events.entries()) {
173
+ const replacement = applySurfaceEvent(state, event, index, events, 0);
174
+ if (replacement !== undefined) replacements.push(replacement);
175
+ }
176
+ return { nodes: [...state.nodes], replacements };
177
+ }
package/lib/log-reader.js CHANGED
@@ -7,14 +7,14 @@
7
7
  * 契约来源:
8
8
  * - 帧扫描/撕裂尾帧判定:复用本项目审计方法论(scan-seq-gaps.mjs),
9
9
  * 帧头布局对齐 zstd 规范(magic 0xFD2FB528、descriptor、block 头)。
10
- * - 行解码:`@deepseek-ai/dsh-session` 的 `decodeStorageRecord`
10
+ * - 行解码:本地兼容层 `./compat.js` 的 `decodeStorageRecord`(原官方导出于 0.1.5 移除)
11
11
  * (lib/index.js:1029,validateRow :922 / expandRow :973)。
12
12
  * - 损坏语义:R2 —— chunk 行损坏 = 整段 run 丢失且加载失败(dsh-session
13
13
  * lib/index.js:1022-1024 注释明示 fail-loud,无跳过逃生舱)。
14
14
  */
15
15
  import fs from 'node:fs';
16
16
  import { zstdDecompressSync } from 'node:zlib';
17
- import { decodeStorageRecord } from '@deepseek-ai/dsh-session';
17
+ import { decodeStorageRecord, normalizeEventSeqRanges } from './compat.js';
18
18
 
19
19
  const ZSTD_MAGIC = 0xfd2fb528;
20
20
 
@@ -209,10 +209,14 @@ export function loadSessionLog(path) {
209
209
  rows.push({ lineNo, value, decoded: null, error: err });
210
210
  continue;
211
211
  }
212
- for (const event of decoded) {
212
+ // v3 storage-form `sourceEventSeqs` 区间编码 → 内存稠密序列(C1)。
213
+ // 必须在**读取层唯一入口**做:下游 S5/S6 与官方 foldSurface 都按稠密整数序列判定,
214
+ // 拿到含 `[start,end]` 对的未展开形态会误判真实健康会话(S5/S6/S8 + --resume broken)。
215
+ const normalized = decoded.map(normalizeEventSeqRanges);
216
+ for (const event of normalized) {
213
217
  events.push({ seq: event.seq, event, lineNo });
214
218
  }
215
- rows.push({ lineNo, value, decoded, error: null });
219
+ rows.push({ lineNo, value, decoded: normalized, error: null });
216
220
  }
217
221
 
218
222
  // 按 seq 排序(文件顺序即日志顺序,此处防御性排序以便下游契约检查)