dsh-log-contract 0.3.13 → 0.3.15
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.md +46 -17
- package/README.zh.md +48 -15
- package/bin/dsh-log-contract.mjs +77 -9
- package/docs/CONTRACTS.md +72 -22
- package/lib/archaeology.js +2 -2
- package/lib/checks.js +74 -24
- package/lib/contracts.js +84 -16
- package/lib/host-probes.js +147 -0
- package/lib/index.js +1 -0
- package/lib/log-reader.js +17 -4
- package/lib/prewrite.js +59 -8
- package/lib/repair.js +24 -7
- package/lib/validate.js +56 -5
- package/lib/version-support.js +148 -0
- package/lib/vocab.js +31 -3
- package/package.json +3 -3
package/lib/contracts.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* DSH 会话日志契约规则目录(spec)。
|
|
5
5
|
*
|
|
6
6
|
* 规则集来源:
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
7
|
+
* - 早期契约审计发现(59 条 / F1–F7 / N1–N6 / R1–R3)
|
|
8
|
+
* - 会话修复事故复盘(三层契约:持久化 / 客户端引擎 / 插件语义)
|
|
9
9
|
* - `@deepseek-ai/dsh-session@0.1.0-rc.7` 官方源码逐行核对(见每条 `source`)
|
|
10
10
|
*
|
|
11
11
|
* 每条规则只描述"契约是什么";具体判定逻辑在 `lib/validate.js`(离线体检)
|
|
@@ -38,6 +38,29 @@ export const SEVERITY = { ERROR: 'error', WARNING: 'warning', INFO: 'info' };
|
|
|
38
38
|
* - Z zstd 帧结构
|
|
39
39
|
* - G 迁移预检(migration gate,**独立维度**:官方迁移会不会拒;不进 ok/verdict)
|
|
40
40
|
*/
|
|
41
|
+
/**
|
|
42
|
+
* R-F/R-D(2026-09-14 独立复核)——**机读的漂移/未复核标注**。
|
|
43
|
+
*
|
|
44
|
+
* 复核结论:规则的 `source` 大量停在 `@deepseek-ai/dsh-session@0.1.0-rc.7` 的行号上,
|
|
45
|
+
* 且其中 4 条(T2/P1/P2/C1)的**判定前提**在真宿主 0.1.5 上已不成立——此前**没有任何机制
|
|
46
|
+
* 能发现**(`source` 只是人读字符串)。这里把它们变成机读字段,并由 `check` 抬头点名。
|
|
47
|
+
*/
|
|
48
|
+
export const SOURCE_DRIFT = Object.freeze({
|
|
49
|
+
/** 出处停在 rc.7 行号、未在 0.1.5 上复核(复核报告 §1 已给清单)。 */
|
|
50
|
+
drifted: Object.freeze(['T2', 'P1', 'P2', 'C1', 'I1', 'G3', 'R2', 'R3', 'E5', 'E6']),
|
|
51
|
+
/**
|
|
52
|
+
* **判定前提在 0.1.5 上不成立/无法判定**(复核 §1):
|
|
53
|
+
* T2 token-meter 已改从 `event.data.stream` 重建(无 sourceEventSeqs/无 belongs to another step);
|
|
54
|
+
* P1/P2 载体已换成 `user/message + data.id`(无 data.editor);C1 前提"无会话级排他锁"被
|
|
55
|
+
* `session.lock` flock 租约证伪;T3/T4 半步需真机实验。⇒ 这些规则的结论**不可单独采信**。
|
|
56
|
+
*/
|
|
57
|
+
premiseStale: Object.freeze(['T2', 'P1', 'P2', 'C1']),
|
|
58
|
+
/** 复核明确"无法判定"、需要真机实验的条目(列出来是为了不假装覆盖)。 */
|
|
59
|
+
undecidable: Object.freeze(['T3', 'T4']),
|
|
60
|
+
note: '出处/前提漂移是**静默失真**:R-D 行为探针(lib/host-probes.js)负责机器可判的那部分,'
|
|
61
|
+
+ '其余在此显式列出,结论抬头必须带漂移清单与未复核清单。',
|
|
62
|
+
});
|
|
63
|
+
|
|
41
64
|
export const CONTRACT_RULES = [
|
|
42
65
|
// ── H · header ──────────────────────────────────────────────────────────
|
|
43
66
|
{
|
|
@@ -68,6 +91,7 @@ export const CONTRACT_RULES = [
|
|
|
68
91
|
},
|
|
69
92
|
{
|
|
70
93
|
id: 'R2',
|
|
94
|
+
sourceVerified: false,
|
|
71
95
|
title: 'chunk 行必须满足精确信封形状',
|
|
72
96
|
layer: LAYER.PERSISTENCE,
|
|
73
97
|
severity: SEVERITY.ERROR,
|
|
@@ -76,6 +100,7 @@ export const CONTRACT_RULES = [
|
|
|
76
100
|
},
|
|
77
101
|
{
|
|
78
102
|
id: 'R3',
|
|
103
|
+
sourceVerified: false,
|
|
79
104
|
title: 'chunk 行展开后成员 seq/time 安全',
|
|
80
105
|
layer: LAYER.PERSISTENCE,
|
|
81
106
|
severity: SEVERITY.ERROR,
|
|
@@ -105,15 +130,16 @@ export const CONTRACT_RULES = [
|
|
|
105
130
|
title: '文件物理序 seq 单调(多写入者交织现场特征)',
|
|
106
131
|
layer: LAYER.PERSISTENCE,
|
|
107
132
|
severity: SEVERITY.ERROR,
|
|
108
|
-
source: '2026-08-28
|
|
133
|
+
source: '2026-08-28 实锤(某真实会话):文件物理序出现回退(大→小→更大);单进程 appendCore 断言 seq==cursor+i 且按 id 串行化不可能写出',
|
|
109
134
|
description: '按文件物理行序要求展开后事件 seq 严格单调递增。E2 在排序后检查(loadSessionLog 会 sort),物理序倒退被掩盖;S9 在排序前按行序检查,非单调 = 多写入者/旧光标回放交织的直接现场证据,加载会被拒。',
|
|
110
135
|
},
|
|
111
136
|
{
|
|
112
137
|
id: 'I1',
|
|
138
|
+
sourceVerified: false,
|
|
113
139
|
title: 'inbox seed 相对重放(fork 边界孤儿 spliced)',
|
|
114
140
|
layer: LAYER.ENGINE,
|
|
115
141
|
severity: SEVERITY.ERROR,
|
|
116
|
-
source: '@deepseek-ai/dsh-agent lib/types/inbox.js:155-178 (apply/validate);2026-08-28
|
|
142
|
+
source: '@deepseek-ai/dsh-agent lib/types/inbox.js:155-178 (apply/validate);2026-08-28 实锤(某两个真实会话):fork 边界 removedCount=1 孤儿',
|
|
117
143
|
description: '从 header.seedLength 起重放 agent/inbox/spliced,next-turn/next-step 双队列;start+removedCount 不得超过队列长、不得产生重复 pending id。fork 时"移除父待处理提示词"的 splice 假设父会话 inbox,子会话 seed 相对空 inbox 上非法 → resume 被拒(invalid persisted inbox splice)。',
|
|
118
144
|
},
|
|
119
145
|
{
|
|
@@ -134,14 +160,16 @@ export const CONTRACT_RULES = [
|
|
|
134
160
|
},
|
|
135
161
|
{
|
|
136
162
|
id: 'E5',
|
|
163
|
+
sourceVerified: false,
|
|
137
164
|
title: '禁用遗留词汇',
|
|
138
165
|
layer: LAYER.PERSISTENCE,
|
|
139
166
|
severity: SEVERITY.ERROR,
|
|
140
167
|
source: '@deepseek-ai/dsh-session lib/index.js:1273-1277 (assertSupportedRequestHeader)',
|
|
141
|
-
description: 'request/header-delta 与 reason=fallback 的 request/header
|
|
168
|
+
description: 'request/header-delta 与 reason=fallback 的 request/header 是已删除的遗留格式。注意(R-F 订正复核 §1 E5):宿主 Session.append/appendLines 不看 type ⇒ 写入会成功、下一次读取才炸(依据 dsh-session@0.1.5-rc.1 lib/index.js:1170-1210 / persistence-jsonl:3046-3073),不是写入即被拒。',
|
|
142
169
|
},
|
|
143
170
|
{
|
|
144
171
|
id: 'E6',
|
|
172
|
+
sourceVerified: false,
|
|
145
173
|
title: '消息类事件消息形状',
|
|
146
174
|
layer: LAYER.PERSISTENCE,
|
|
147
175
|
severity: SEVERITY.ERROR,
|
|
@@ -225,50 +253,83 @@ export const CONTRACT_RULES = [
|
|
|
225
253
|
},
|
|
226
254
|
{
|
|
227
255
|
id: 'T2',
|
|
256
|
+
premiseStale: true,
|
|
257
|
+
sourceVerified: false,
|
|
228
258
|
title: 'token-meter 源引用:assistant/message 的 sourceEventSeqs 引用的 chunk 必须同 turn/step',
|
|
229
259
|
layer: LAYER.ENGINE,
|
|
230
260
|
severity: SEVERITY.ERROR,
|
|
231
261
|
source: '@deepseek-ai/dsh-token-meter lib/index.js:634-650 (_estimateProviderAssistant,:645 belongs to another step)',
|
|
232
|
-
description: 'token meter 重建 provider 输出时,逐条检查 assistant/message 的 sourceEventSeqs:指向 assistant/chunk 的引用必须与消息同 turn/step,且 seq 更早、不重复;跨 step 引用 → 官方抛 belongs to another step → 每次事件追加都重抛(consumedEvents 不前进)→ 刷屏压垮 host(2026-08-30
|
|
262
|
+
description: 'token meter 重建 provider 输出时,逐条检查 assistant/message 的 sourceEventSeqs:指向 assistant/chunk 的引用必须与消息同 turn/step,且 seq 更早、不重复;跨 step 引用 → 官方抛 belongs to another step → 每次事件追加都重抛(consumedEvents 不前进)→ 刷屏压垮 host(2026-08-30 实测:某真实会话的 chunk 源引用跨 step 7/8/9)。T1 只查 step 配对不查源引用,此条补盲区;修复用 fix --clip-crossstep。',
|
|
233
263
|
},
|
|
234
264
|
{
|
|
235
265
|
id: 'T3',
|
|
236
266
|
title: 'step 节点 key 唯一(同 turn 内 step/start 的 step 号不得复用)',
|
|
237
267
|
layer: LAYER.ENGINE,
|
|
238
268
|
severity: SEVERITY.ERROR,
|
|
239
|
-
source: '复盘 2026-09-02
|
|
240
|
-
description: '客户端渲染消息列表从事件流构建节点,节点 key = data.turn:data.step。同 turn 内两个 step/start 的 step 号相同 → key 冲突 → React 渲染死循环 →
|
|
269
|
+
source: '复盘 2026-09-02 渲染层白屏事故:客户端渲染节点 key = turn:step,冲突 → React 渲染死循环(由离线自查脚本判出)',
|
|
270
|
+
description: '客户端渲染消息列表从事件流构建节点,节点 key = data.turn:data.step。同 turn 内两个 step/start 的 step 号相同 → key 冲突 → React 渲染死循环 → 白屏/不展示(实测:同一 turn 内两个 step/start 复用 step 95/1,一个来自正常轮、一个来自编辑块;后续全量扫描又发现多个同型冲突)。修复:同 turn 内 step 递增、整块重编号(含块内 chunk/tool/assistant)。',
|
|
241
271
|
},
|
|
242
272
|
{
|
|
243
273
|
id: 'T4',
|
|
244
274
|
title: 'step/消息本体 turn 缺失(null/undefined)→ 渲染死循环',
|
|
245
275
|
layer: LAYER.ENGINE,
|
|
246
276
|
severity: SEVERITY.ERROR,
|
|
247
|
-
source: '复盘
|
|
248
|
-
description: '客户端渲染状态机对 turn=null 的 step/start|step/end|assistant/message 无法归属任何 turn → 渲染死循环 →
|
|
277
|
+
source: '复盘 2026-09-01 D8 事故:retrace 0.4.17 编辑块 turn:null(离线自查脚本判致命)',
|
|
278
|
+
description: '客户端渲染状态机对 turn=null 的 step/start|step/end|assistant/message 无法归属任何 turn → 渲染死循环 → 白屏「载入历史」(实测:编辑块的 step/start + marker + step/end 连续若干行 turn 全为 null)。user/message 天然无 turn 不查;chunk 坐标可缺失不查。step/消息本体必须带真实 turn 号。',
|
|
249
279
|
},
|
|
250
280
|
{
|
|
251
281
|
id: 'T5',
|
|
252
282
|
title: 'turn/end 必须带 data.reason.kind',
|
|
253
283
|
layer: LAYER.ENGINE,
|
|
254
284
|
severity: SEVERITY.ERROR,
|
|
255
|
-
source: '官方 dsh-agent-loop lib/index.js:620(turn/end = {turn, reason:{kind}});
|
|
256
|
-
description: '官方 validation 强制 turn/end 的 data.reason.kind 存在(kind ∈ completed|max-tokens|blocked|aborted|error|interrupted)。缺失 = malformed → 官方 SessionPersistenceCorruptionError →
|
|
285
|
+
source: '官方 dsh-agent-loop lib/index.js:620(turn/end = {turn, reason:{kind}});malformed turn/end 事故(2026-09-02,由离线自查脚本判出)',
|
|
286
|
+
description: '官方 validation 强制 turn/end 的 data.reason.kind 存在(kind ∈ completed|max-tokens|blocked|aborted|error|interrupted)。缺失 = malformed → 官方 SessionPersistenceCorruptionError → 会话加载失败。实测:retrace 情形③信封 turn/end 漏 reason → 每次编辑后加载失败(已修 0.4.18)。',
|
|
257
287
|
},
|
|
258
288
|
{
|
|
259
289
|
id: 'E7',
|
|
260
290
|
title: 'ignorable 未知 type 合法性(带被忽略标记的未知事件须有消费者)',
|
|
261
291
|
layer: LAYER.PERSISTENCE,
|
|
262
292
|
severity: SEVERITY.WARNING,
|
|
263
|
-
source: '
|
|
293
|
+
source: '对抗性复查 2026-09-09 T2(E3 ignorable 无合法性校验 = 后门)',
|
|
264
294
|
description: '未知 type + ignorable:true 被读路径接纳但无人消费 = 静默垃圾。排除已知消费者白名单(retrace/marker、retrace/goal-marker、message-editor/ 前缀等 retrace 客户端消费的插件 marker)后,其余 ignorable 未知事件报 warning。',
|
|
265
295
|
},
|
|
296
|
+
{
|
|
297
|
+
id: 'E8',
|
|
298
|
+
title: '事件信封键白名单(多余键:seed/restore 路径会拒)',
|
|
299
|
+
layer: LAYER.PERSISTENCE,
|
|
300
|
+
// R-G 候选规则 + R-D 探针订正:宿主 `assertSessionEventEnvelope`(dsh-session@0.1.5-rc.1
|
|
301
|
+
// lib/index.js:849-861)确实拒多余键,但其**唯一调用点是 Session 构造器的 seed 路径**
|
|
302
|
+
// (:1063-1068);JSONL **load 路径**(`adoptSessionEvent`)实测**容忍**(探针 p5 钉住)。
|
|
303
|
+
// 故本规则按 **warning** 报(不影响"可加载",但该日志作为 seed/restore 输入会被拒)。
|
|
304
|
+
severity: SEVERITY.WARNING,
|
|
305
|
+
source: '@deepseek-ai/dsh-session@0.1.5-rc.1 lib/index.js:849-861(assertSessionEventEnvelope)+ :1063-1068(唯一调用点=seed 路径);load 路径容忍见行为探针 p5',
|
|
306
|
+
description: '事件对象只允许 7 个信封键(type/seq/time/data/surfaceOp/sourceEventSeqs/ignorable)。独立复核变异 05 指出"宿主拒、旧契约 0 违规";R-D 行为探针进一步订正口径:**load 路径容忍、seed/restore 路径拒** ⇒ warning。',
|
|
307
|
+
candidate: true,
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
id: 'E9',
|
|
311
|
+
title: 'system/message 必须带 plugin source',
|
|
312
|
+
layer: LAYER.PERSISTENCE,
|
|
313
|
+
severity: SEVERITY.ERROR,
|
|
314
|
+
source: '@deepseek-ai/dsh-session@0.1.5-rc.1 lib/index.js:942-944("must have plugin source");角色表 :917-926',
|
|
315
|
+
description: 'v3 新增的 system/message:role 必须为 system,source.kind 必须为 plugin 且 plugin 非空。实测(变异 03):source.kind=\'user\' 宿主拒、旧契约 0 违规 ⇒ 漏检。',
|
|
316
|
+
candidate: true,
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
id: 'E10',
|
|
320
|
+
title: 'request/header 的 data.header 字段约束',
|
|
321
|
+
layer: LAYER.PERSISTENCE,
|
|
322
|
+
severity: SEVERITY.ERROR,
|
|
323
|
+
source: '@deepseek-ai/dsh-session@0.1.5-rc.1 lib/index.js:231-248(validateSessionEventData:omit header.system / omit empty tools / omit empty adapterDefaults)',
|
|
324
|
+
description: 'request/header 必须省略 header.system(系统提示改走 system/message)、空 tools、空 adapterDefaults。实测(变异 12):带 header.system 的写入宿主拒、旧契约 0 违规 ⇒ 漏检。',
|
|
325
|
+
candidate: true,
|
|
326
|
+
},
|
|
266
327
|
{
|
|
267
328
|
id: 'Z3',
|
|
268
329
|
title: '空会话文件(有 header 无事件)显式报出',
|
|
269
330
|
layer: LAYER.FRAMING,
|
|
270
331
|
severity: SEVERITY.WARNING,
|
|
271
|
-
source: '
|
|
332
|
+
source: '对抗性复查 2026-09-09 T3(36 条规则全来自有内容事故,空态无覆盖)',
|
|
272
333
|
description: '有 header 但零事件 = 异常空会话(新建即空或写入未落盘)。空态不在任何有内容规则的覆盖下,显式 warning 供人判断。',
|
|
273
334
|
},
|
|
274
335
|
{
|
|
@@ -276,7 +337,7 @@ export const CONTRACT_RULES = [
|
|
|
276
337
|
title: 'tool/call ↔ tool/result 配对完整性(考古 B1)',
|
|
277
338
|
layer: LAYER.PLUGIN,
|
|
278
339
|
severity: SEVERITY.WARNING,
|
|
279
|
-
source: '
|
|
340
|
+
source: '考古方法 §2/§4.2(callId 配对,不可用"上一个 call"推断)',
|
|
280
341
|
description: '每个 tool/call 的 data.callId 必须能在 tool/result 的 data.message.source.callId 中找到配对;孤儿 call(无 result)告警——中断/失败轮次可能产生孤儿(合法但要审计),考古提取将缺该输出。',
|
|
281
342
|
},
|
|
282
343
|
{
|
|
@@ -284,7 +345,7 @@ export const CONTRACT_RULES = [
|
|
|
284
345
|
title: 'tool/result 输出结构可解析(考古 B2)',
|
|
285
346
|
layer: LAYER.PLUGIN,
|
|
286
347
|
severity: SEVERITY.WARNING,
|
|
287
|
-
source: '
|
|
348
|
+
source: '考古方法 §2/§4.2(content 递归 text 结构)',
|
|
288
349
|
description: 'tool/result 的 data.message.content 必须可递归解析(list[dict{type:text,text}] 或等价);不可解析片段 = 考古提取将漏数据。空 content(失败/无输出)合法。',
|
|
289
350
|
},
|
|
290
351
|
// ── M · 客户端引擎层 ────────────────────────────────────────────────────
|
|
@@ -300,6 +361,8 @@ export const CONTRACT_RULES = [
|
|
|
300
361
|
// ── P · 插件 marker 语义层 ──────────────────────────────────────────────
|
|
301
362
|
{
|
|
302
363
|
id: 'P1',
|
|
364
|
+
premiseStale: true,
|
|
365
|
+
sourceVerified: false,
|
|
303
366
|
title: 'marker id 前缀必须被识别',
|
|
304
367
|
layer: LAYER.PLUGIN,
|
|
305
368
|
severity: SEVERITY.WARNING,
|
|
@@ -308,6 +371,8 @@ export const CONTRACT_RULES = [
|
|
|
308
371
|
},
|
|
309
372
|
{
|
|
310
373
|
id: 'P2',
|
|
374
|
+
premiseStale: true,
|
|
375
|
+
sourceVerified: false,
|
|
311
376
|
title: 'marker 自身 seq 不得出现在自身 shadowed 集',
|
|
312
377
|
layer: LAYER.PLUGIN,
|
|
313
378
|
severity: SEVERITY.ERROR,
|
|
@@ -318,6 +383,8 @@ export const CONTRACT_RULES = [
|
|
|
318
383
|
// ── C · 并发 / 写入者假设 ───────────────────────────────────────────────
|
|
319
384
|
{
|
|
320
385
|
id: 'C1',
|
|
386
|
+
premiseStale: true,
|
|
387
|
+
sourceVerified: false,
|
|
321
388
|
title: 'seq 缺口/倒退提示多写入者',
|
|
322
389
|
layer: LAYER.CONCURRENCY,
|
|
323
390
|
severity: SEVERITY.WARNING,
|
|
@@ -384,6 +451,7 @@ export const CONTRACT_RULES = [
|
|
|
384
451
|
},
|
|
385
452
|
{
|
|
386
453
|
id: 'G3',
|
|
454
|
+
sourceVerified: false,
|
|
387
455
|
title: '迁移预检:v0 源 session/title 系列的 messageSeqs 必须引用更早的人类 user/message',
|
|
388
456
|
layer: LAYER.MIGRATION,
|
|
389
457
|
severity: SEVERITY.WARNING,
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-log-contract · lib/host-probes.js —— **行为探针**(漂移检测的正解)
|
|
3
|
+
* (2026-09-14 独立复核 §4 + 裁定 R-D)
|
|
4
|
+
*
|
|
5
|
+
* 为什么需要它(复核原话):本包"承重墙建错了地方"——**委托宿主运行时函数**的判据基本正确,
|
|
6
|
+
* **作者手写镜像/自建模型**的判据成片出错,而 `hostPackageVersion()`/`hostCapability()`
|
|
7
|
+
* **只打印不门禁**、规则的 `source` 是**不可机读的字符串** ⇒ 本次 5 类失准**没有任何机制能发现**。
|
|
8
|
+
*
|
|
9
|
+
* 本模块的回答:**不靠人重读源码,靠宿主运行时自身当 oracle**。做法是自造**微型合成日志**
|
|
10
|
+
* 喂给宿主导出的 `foldSurface` / `adoptSessionEvent`(可选 `Session.create`),断言"宿主应当
|
|
11
|
+
* 这么判",再把**本包规则的假设**与宿主实际行为逐条比对:
|
|
12
|
+
* - 一致 → 该规则组 **VERIFIED**(在**当前宿主**上);
|
|
13
|
+
* - 不一致 → 该规则组标 **UNVERIFIED**,结论抬头显式点名,并提示**勿据此跑 `fix --apply`**。
|
|
14
|
+
*
|
|
15
|
+
* 探针只做**只读调用**(不写盘、不联网、不改任何文件);任何异常都被收敛成"探针失败/跳过",
|
|
16
|
+
* 不让漂移检测把工具本身弄崩。
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
adoptSessionEvent, foldSurface, isSurfaceEligibleType, KNOWN_SESSION_EVENT_TYPES, SESSION_FORMAT_VERSION,
|
|
20
|
+
} from '@deepseek-ai/dsh-session';
|
|
21
|
+
import { hostPackageVersion } from './vocab.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 探针定义。每条:
|
|
25
|
+
* `rules` = 该探针**背书**的规则 id(不一致时这些规则被标 UNVERIFIED);
|
|
26
|
+
* `expect` = 我们对宿主行为的期望(同时就是规则的假设);
|
|
27
|
+
* `run` = 用宿主真代码判定实际行为(返回 true=与期望一致)。
|
|
28
|
+
* 说明:断言只看"宿主收/拒"这一层语义(可用 `adoptSessionEvent` 与 `foldSurface` 观察),
|
|
29
|
+
* 不复制本包任何判定逻辑——否则又变成"自己给自己打分"。
|
|
30
|
+
*/
|
|
31
|
+
export const PROBES = [
|
|
32
|
+
{
|
|
33
|
+
id: 'p1-replace-v3-fields',
|
|
34
|
+
title: 'v3 replace 只认 {op,startSeq,endSeq}(旧 {start,end} 必须拒)',
|
|
35
|
+
rules: ['S1', 'S4'],
|
|
36
|
+
expect: 'v3 形状通过、rc.7 旧形状被拒',
|
|
37
|
+
run: () => {
|
|
38
|
+
const mk = (op) => ({ type: 'user/message', seq: 1, time: 1, surfaceOp: op, sourceEventSeqs: [0], data: { id: 'u1', role: 'user', source: { kind: 'user' }, content: [] } });
|
|
39
|
+
const base = [{ type: 'user/message', seq: 0, time: 0, surfaceOp: 'append', data: { id: 'u0', role: 'user', source: { kind: 'user' }, content: [] } }];
|
|
40
|
+
const modern = rejects(() => foldSurface([...base, mk({ op: 'replace', startSeq: 0, endSeq: 0 })]));
|
|
41
|
+
const legacy = rejects(() => foldSurface([...base, mk({ op: 'replace', start: 0, end: 0 })]));
|
|
42
|
+
return modern === false && legacy === true;
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 'p2-assistant-message-provenance',
|
|
47
|
+
title: 'assistant/message 不得携带 sourceEventSeqs(v3 起)',
|
|
48
|
+
rules: ['S6', 'E4', 'S8'],
|
|
49
|
+
expect: '带 provenance 的 assistant/message 被拒',
|
|
50
|
+
run: () => rejects(() => adoptSessionEvent({
|
|
51
|
+
type: 'assistant/message', seq: 1, time: 1, surfaceOp: 'append', sourceEventSeqs: [0],
|
|
52
|
+
data: { turn: 0, step: 1, message: { id: 'a1', role: 'assistant', source: { kind: 'model', provider: 'p', model: 'm' }, content: [] } },
|
|
53
|
+
})) === true,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'p3-system-message-plugin-source',
|
|
57
|
+
title: 'system/message 是 surface 类型且必须 plugin source(kind=user 必须拒)',
|
|
58
|
+
rules: ['E6', 'E9'],
|
|
59
|
+
expect: 'plugin source 通过;user source 被拒',
|
|
60
|
+
run: () => {
|
|
61
|
+
const eligible = isSurfaceEligibleType('system/message') === true;
|
|
62
|
+
const withPlugin = rejects(() => adoptSessionEvent({
|
|
63
|
+
type: 'system/message', seq: 0, time: 1, surfaceOp: 'append',
|
|
64
|
+
data: { turn: 0, step: 0, message: { id: 's0', role: 'system', source: { kind: 'plugin', plugin: 'p' }, content: [] } },
|
|
65
|
+
}));
|
|
66
|
+
const withUser = rejects(() => adoptSessionEvent({
|
|
67
|
+
type: 'system/message', seq: 0, time: 1, surfaceOp: 'append',
|
|
68
|
+
data: { turn: 0, step: 0, message: { id: 's0', role: 'system', source: { kind: 'user' }, content: [] } },
|
|
69
|
+
}));
|
|
70
|
+
return eligible && withPlugin === false && withUser === true;
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: 'p4-unknown-ignorable-opaque-metadata',
|
|
75
|
+
title: '未知 + ignorable 的 opaque 元数据被刻意容忍(S2 不得报 error)',
|
|
76
|
+
rules: ['S2'],
|
|
77
|
+
expect: '未知 ignorable + surfaceOp 被接受',
|
|
78
|
+
run: () => rejects(() => adoptSessionEvent({
|
|
79
|
+
type: 'future/thing', seq: 0, time: 1, ignorable: true, surfaceOp: 'append', data: { k: 1 },
|
|
80
|
+
})) === false,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: 'p5-envelope-extra-keys-load-tolerant',
|
|
84
|
+
title: '信封多余键:load 路径(adoptSessionEvent)**容忍**,只在 seed 路径拒(E8 的口径)',
|
|
85
|
+
rules: ['E8'],
|
|
86
|
+
expect: 'adoptSessionEvent 不拒(宿主 load 路径容忍)',
|
|
87
|
+
// 这条探针**修正了一条规则的过度声称**(R-D 的价值实证):
|
|
88
|
+
// · 宿主 `assertSessionEventEnvelope`(dsh-session@0.1.5-rc.1 lib/index.js:849-861)确实拒多余键,
|
|
89
|
+
// 但它的**唯一调用点**是 Session 构造器的 **seed 路径**(:1063-1068)——不是 JSONL load 路径;
|
|
90
|
+
// · load 路径(`adoptSessionEvent`)实测**容忍**多余键。
|
|
91
|
+
// ⇒ E8 从 error 降为 **warning**、口径限定为"seed/restore 会拒"(原判"写入即拒"是过度声称)。
|
|
92
|
+
run: () => rejects(() => adoptSessionEvent({
|
|
93
|
+
type: 'user/message', seq: 0, time: 1, surfaceOp: 'append', extra: 1,
|
|
94
|
+
data: { id: 'u0', role: 'user', source: { kind: 'user' }, content: [] },
|
|
95
|
+
})) === false,
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: 'p6-request-header-system',
|
|
99
|
+
title: 'request/header 必须省略 header.system(E10)',
|
|
100
|
+
rules: ['E10'],
|
|
101
|
+
expect: '带 header.system 的 request/header 被拒',
|
|
102
|
+
run: () => rejects(() => adoptSessionEvent({
|
|
103
|
+
type: 'request/header', seq: 0, time: 1, surfaceOp: 'append', data: { header: { system: 'x' } },
|
|
104
|
+
})) === true,
|
|
105
|
+
},
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
/** 宿主是否 **拒** 这次调用(true = 抛错/拒绝)。 */
|
|
109
|
+
|
|
110
|
+
function rejects(fn) {
|
|
111
|
+
try {
|
|
112
|
+
fn();
|
|
113
|
+
return false;
|
|
114
|
+
} catch {
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 跑全部探针。
|
|
121
|
+
* @returns {{hostPackage:string, sessionFormatVersion:number, knownTypes:number,
|
|
122
|
+
* verified:boolean, probes:Array<{id,title,rules,expect,ok,error?}>, unverifiedRules:string[]}}
|
|
123
|
+
*/
|
|
124
|
+
export function runHostProbes(probes = PROBES) {
|
|
125
|
+
const results = [];
|
|
126
|
+
const unverified = new Set();
|
|
127
|
+
for (const probe of probes) {
|
|
128
|
+
let ok = false;
|
|
129
|
+
let error;
|
|
130
|
+
try {
|
|
131
|
+
ok = probe.run() === true;
|
|
132
|
+
} catch (err) {
|
|
133
|
+
ok = false;
|
|
134
|
+
error = String(err?.message ?? err).slice(0, 200);
|
|
135
|
+
}
|
|
136
|
+
results.push({ id: probe.id, title: probe.title, rules: probe.rules, expect: probe.expect, ok, ...(error ? { error } : {}) });
|
|
137
|
+
if (!ok) for (const r of probe.rules) unverified.add(r);
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
hostPackage: hostPackageVersion(),
|
|
141
|
+
sessionFormatVersion: SESSION_FORMAT_VERSION,
|
|
142
|
+
knownTypes: KNOWN_SESSION_EVENT_TYPES.size,
|
|
143
|
+
verified: unverified.size === 0,
|
|
144
|
+
probes: results,
|
|
145
|
+
unverifiedRules: [...unverified].sort(),
|
|
146
|
+
};
|
|
147
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -10,5 +10,6 @@ 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
12
|
export { HOST_MAX_FILE_VERSION, hostPackageVersion, hostCapability, isAssessableFileVersion } from './vocab.js';
|
|
13
|
+
export { TESTED_BASELINE, compareVersions, detectSupport, supportOfLog } from './version-support.js';
|
|
13
14
|
export { tokenMeterViolations, tokenMeterSourceViolations, stepKeyViolations, nullTurnStepViolations, turnEndReasonViolations, ignorableTypeViolations, physicalOrderViolations, inboxReplayViolations } from './checks.js';
|
|
14
15
|
export { auditToolCalls, extractText, extractToolOutputs, indexToolCalls, toolCommandOf } from './archaeology.js';
|
package/lib/log-reader.js
CHANGED
|
@@ -76,9 +76,21 @@ export function scanZstdFrames(buf) {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
/** 把 zstd 多帧拼成完整明文;任一帧解码失败即抛错(N5 单帧全损语义)。 */
|
|
79
|
-
export function decompressZstd(buf) {
|
|
79
|
+
export function decompressZstd(buf, { allowTorn = true } = {}) {
|
|
80
80
|
const { frames, torn } = scanZstdFrames(buf);
|
|
81
|
-
|
|
81
|
+
// ── R-B(2026-09-14 独立复核,误报 Z2)────────────────────────────────────
|
|
82
|
+
// 撕裂尾帧**不是**损坏:宿主 `dsh-session-persistence-jsonl@0.1.5-rc.1`
|
|
83
|
+
// · `readZstdPrefix`(lib/index.js:2791-2849)——"Decode complete frames and retain
|
|
84
|
+
// complete JSONL records from a torn final frame",恢复并返回 `tornTruncateTo`(:2847);
|
|
85
|
+
// · 下次写入 `:226-228` 调 `truncateTornTail` 自动截断。
|
|
86
|
+
// 旧实现(本函数)对 torn 直接抛错 ⇒ `loadSessionLog` 返回 header=null/events=[]
|
|
87
|
+
// ⇒ `validate.js` 级联出 Z1(warning)+Z2(error)+H1(error)、verdict=broken —— 把宿主
|
|
88
|
+
// **本来能修**的"边写边读"健康态报成"会话坏了"。现在对齐宿主:解出全部**完整帧**,
|
|
89
|
+
// 尾帧未写完的部分丢弃,由调用方用 `frameInfo.torn` 报 Z1(warning)。
|
|
90
|
+
// `frames.length === 0`(连一个完整帧都没有,例如头帧就断了)**仍然报错** —— 宿主
|
|
91
|
+
// `readZstdPrefix:2793` 对这种情况同样抛 "empty or header-less Zstandard session log"。
|
|
92
|
+
if (frames.length === 0) throw new Error('空文件 / 无完整 zstd 帧(incomplete or header-less session log)');
|
|
93
|
+
if (torn && !allowTorn) throw new Error('zstd 尾帧撕裂(incomplete tail frame)——strict 模式(allowTorn:false)');
|
|
82
94
|
// 逐帧解码后一次性 Buffer.concat:避免每帧一次 concat 的 O(n²) 拷贝
|
|
83
95
|
const parts = new Array(frames.length);
|
|
84
96
|
for (let i = 0; i < frames.length; i++) {
|
|
@@ -86,6 +98,7 @@ export function decompressZstd(buf) {
|
|
|
86
98
|
try {
|
|
87
99
|
parts[i] = zstdDecompressSync(buf.subarray(s, e));
|
|
88
100
|
} catch (err) {
|
|
101
|
+
// 完整帧解码失败才是真损坏(Z2):磁盘 bitrot / 传输截断 / 保留位非法
|
|
89
102
|
throw new Error(`zstd 帧解码失败 [${s},${e}):${err.message}`);
|
|
90
103
|
}
|
|
91
104
|
}
|
|
@@ -230,8 +243,8 @@ export function loadSessionLog(path) {
|
|
|
230
243
|
*
|
|
231
244
|
* 帧布局:帧 1 = header 行(单行 JSON,~几百字节),帧 2+ = 事件流。
|
|
232
245
|
* 短码推导(工作区 createdAt 序号)需要扫全部会话但只需要 header——
|
|
233
|
-
* 读文件前缀 64KiB 足够覆盖完整帧 1
|
|
234
|
-
*
|
|
246
|
+
* 读文件前缀 64KiB 足够覆盖完整帧 1,避免全量读大文件(大会话压缩后可达
|
|
247
|
+
* 数百 MiB)。失败返回 null(调用方降级)。
|
|
235
248
|
*
|
|
236
249
|
* @param {string} path 会话日志文件路径(.jsonl.zstd 或明文 .jsonl)。
|
|
237
250
|
* @returns {object|null} header 对象;无法解析返回 null。
|
package/lib/prewrite.js
CHANGED
|
@@ -18,12 +18,41 @@
|
|
|
18
18
|
* 保证"体检看到的问题 = 写入前拦下的问题"。
|
|
19
19
|
*/
|
|
20
20
|
import { envelopeViolations, engineViolations, finalFold, isSafeInt, nullTurnStepViolations, pluginViolations, replaySurface, stepKeyViolations, tokenMeterViolations, turnEndReasonViolations, violation, wireViolations } from './checks.js';
|
|
21
|
-
import { resolveFormatVersion } from './vocab.js';
|
|
21
|
+
import { resolveFormatVersion, LEGACY_FORMAT_MAX_VERSION } from './vocab.js';
|
|
22
22
|
import { normalizeEventSeqRanges } from './compat.js';
|
|
23
23
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
/**
|
|
25
|
+
* retrace 历史 marker 的 id 前缀(旧载体:`assistant/message` replace + `data.editor`)。
|
|
26
|
+
* 与 retrace 侧 `MARKER_ID_PREFIX`(`retrace`/`message-editor`——后者是插件改名前的旧前缀)对齐。
|
|
27
|
+
*/
|
|
28
|
+
const LEGACY_MARKER_ID_PREFIXES = ['retrace', 'message-editor'];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* **可识别的历史 retrace 载体**(R-C,2026-09-14 独立复核:白名单收窄)。
|
|
32
|
+
*
|
|
33
|
+
* 旧实现只判"任意 `data.editor` 存在" ⇒ **任何**写 `assistant/message` replace + `data.editor`
|
|
34
|
+
* 的第三方插件都能领 T1 豁免(复核判定:"设计粗糙的豁口")。收窄为四条同时成立:
|
|
35
|
+
* ① 类型/操作:`assistant/message` + replace;
|
|
36
|
+
* ② 载体标记:`data.editor !== undefined`;
|
|
37
|
+
* ③ **身份**:`data.message.id` 带 retrace 历史 marker 前缀(`retrace-*` / `message-editor-*`);
|
|
38
|
+
* ④ **形状**:`editor.targetSeq === (op.start ?? op.startSeq)`(retrace 自己的写前断言钉住的形状)。
|
|
39
|
+
* 任一不满足 → 不是可识别的 retrace 历史 marker,不降级(保持 error)。
|
|
40
|
+
*
|
|
41
|
+
* @returns {{prefix:string,id:string,targetSeq:number,seq:unknown}|null}
|
|
42
|
+
*/
|
|
43
|
+
export function legacyMarkerKindOf(event) {
|
|
44
|
+
if (!event || event.type !== 'assistant/message') return null;
|
|
45
|
+
const op = event.surfaceOp;
|
|
46
|
+
if (!op || typeof op !== 'object' || op.op !== 'replace') return null;
|
|
47
|
+
const editor = event.data?.editor;
|
|
48
|
+
if (editor === undefined || editor === null || typeof editor !== 'object') return null;
|
|
49
|
+
const id = event.data?.message?.id;
|
|
50
|
+
if (typeof id !== 'string') return null;
|
|
51
|
+
const prefix = LEGACY_MARKER_ID_PREFIXES.find((p) => id.startsWith(`${p}-`));
|
|
52
|
+
if (!prefix) return null;
|
|
53
|
+
const start = op.start ?? op.startSeq;
|
|
54
|
+
if (editor.targetSeq !== start) return null;
|
|
55
|
+
return { prefix, id, targetSeq: editor.targetSeq, seq: event.seq };
|
|
27
56
|
}
|
|
28
57
|
|
|
29
58
|
/** 把一个"拟写事件"规整为带 seq 的事件;seq 未携带时按追加位置赋值。 */
|
|
@@ -106,15 +135,31 @@ export function createPreWriter(input = {}) {
|
|
|
106
135
|
// assistant/message 配对失败保持 error。历史已有事件的 T1 归属离线体检
|
|
107
136
|
// (check),不在这里重复拦截(否则历史 marker 会让后续编辑全部被拒)。
|
|
108
137
|
const lastCandidate = candidateEvents[candidateEvents.length - 1];
|
|
138
|
+
const legacyKind = legacyMarkerKindOf(lastCandidate);
|
|
139
|
+
// R-C 版本门(2026-09-14 独立复核):降级**只对 ≤v2 文件**。
|
|
140
|
+
// 依据:新载体(`user/message` + `data.id`,retrace 0.4.26)根本不带 `data.editor`,在 v3 上
|
|
141
|
+
// 降级**救不回任何写入**(v3 禁 assistant/message 带 provenance ⇒ S8 兜住) —— 留在 v3 上
|
|
142
|
+
// 只会掩盖 T1 的真实原因。v0/v1/v2 才是有价值的作用域(回放/重写历史形态 marker)。
|
|
143
|
+
const legacyDowngradeAllowed = formatVersion <= LEGACY_FORMAT_MAX_VERSION;
|
|
109
144
|
for (const t1 of tokenMeterViolations(candidateEvents.map((event) => ({ event })))) {
|
|
110
145
|
if (t1.id !== 'T1' || t1.seq !== lastCandidate?.seq) continue;
|
|
111
|
-
if (
|
|
112
|
-
|
|
146
|
+
if (legacyKind && legacyDowngradeAllowed) {
|
|
147
|
+
// 降级**可见**(R-C):违规里带 markerKind/id/targetSeq,并由结果字段 `legacyMarkerDebt`
|
|
148
|
+
// 显式带出;入口(CLI)打印"压缩前需一次性清理",不再"记了没人看"。
|
|
149
|
+
violations.push({
|
|
150
|
+
...t1,
|
|
151
|
+
severity: 'warning',
|
|
152
|
+
markerKind: legacyKind.prefix,
|
|
153
|
+
markerId: legacyKind.id,
|
|
154
|
+
targetSeq: legacyKind.targetSeq,
|
|
155
|
+
message: `${t1.message}(已知历史 retrace marker 设计债:${legacyKind.prefix} 载体 id=${legacyKind.id} targetSeq=${legacyKind.targetSeq};`
|
|
156
|
+
+ `仅对 v≤${LEGACY_FORMAT_MAX_VERSION} 文件降级。压缩前需一次性清理:fix --neutralize-legacy-markers)`,
|
|
157
|
+
});
|
|
113
158
|
} else {
|
|
114
159
|
violations.push(t1);
|
|
115
160
|
}
|
|
116
161
|
}
|
|
117
|
-
// T3/T4 —— 渲染层(2026-09-02
|
|
162
|
+
// T3/T4 —— 渲染层(2026-09-02 渲染层白屏事故)。**error 级拒绝**:
|
|
118
163
|
// - T4:拟写事件(step/start|step/end|assistant/message)turn 缺失 → 客户端
|
|
119
164
|
// 渲染死循环白屏(D8),写入前直接拦下(防再犯:任何写 turn:null 的 marker);
|
|
120
165
|
// - T3:拟写事件引入 step 节点 key 冲突(同 turn 同 step 的 step/start 重复)→
|
|
@@ -127,17 +172,23 @@ export function createPreWriter(input = {}) {
|
|
|
127
172
|
if (v.seq !== lastCandidate?.seq) continue;
|
|
128
173
|
violations.push(v);
|
|
129
174
|
}
|
|
130
|
-
// T5 —— 拟写 turn/end 缺 reason.kind → error 拒绝(
|
|
175
|
+
// T5 —— 拟写 turn/end 缺 reason.kind → error 拒绝(malformed turn/end 防再犯)
|
|
131
176
|
for (const v of turnEndReasonViolations(candidateEvents.map((event) => ({ event })))) {
|
|
132
177
|
if (v.seq !== lastCandidate?.seq) continue;
|
|
133
178
|
violations.push(v);
|
|
134
179
|
}
|
|
135
180
|
const bySeverity = { error: 0, warning: 0, info: 0 };
|
|
136
181
|
for (const v of violations) bySeverity[v.severity] = (bySeverity[v.severity] ?? 0) + 1;
|
|
182
|
+
// R-C「让降级可见」:把"本次写入沿用了历史 marker 形态(债)"作为**结构化字段**带出,
|
|
183
|
+
// 入口据此打印"压缩前需一次性清理"。降级不再只是 violations 里的一句 warning。
|
|
184
|
+
const legacyDebt = legacyKind && legacyDowngradeAllowed && bySeverity.error === 0
|
|
185
|
+
? { kind: legacyKind.prefix, id: legacyKind.id, targetSeq: legacyKind.targetSeq, seq: lastCandidate?.seq ?? null, formatVersion }
|
|
186
|
+
: null;
|
|
137
187
|
return {
|
|
138
188
|
ok: bySeverity.error === 0,
|
|
139
189
|
violations,
|
|
140
190
|
bySeverity,
|
|
191
|
+
legacyMarkerDebt: legacyDebt,
|
|
141
192
|
surface: folded.surface ?? { nodes: replay.nodes, replacements: [] },
|
|
142
193
|
nextSeq: candidateEvents.length ? candidateEvents[candidateEvents.length - 1].seq + 1 : baseSeq,
|
|
143
194
|
};
|
package/lib/repair.js
CHANGED
|
@@ -123,8 +123,8 @@ export function strictScanText(text) {
|
|
|
123
123
|
* 裁剪 assistant/message 的跨 step sourceEventSeqs(2026-08-30 第二类事故)。
|
|
124
124
|
*
|
|
125
125
|
* 现象:DSH 的 resend/regenerate 在 agent 仍开着 step 时被触发,会把旧 step 的
|
|
126
|
-
* assistant/chunk 全部引用进新 assistant/message 的 sourceEventSeqs
|
|
127
|
-
*
|
|
126
|
+
* assistant/chunk 全部引用进新 assistant/message 的 sourceEventSeqs(实测:
|
|
127
|
+
* sourceEventSeqs 覆盖 turn 54 的 step 7/8/9 三段)。token-meter
|
|
128
128
|
* 要求每个 source chunk 与消息同 turn/step(dsh-token-meter lib/index.js:645,
|
|
129
129
|
* `belongs to another step`)→ 同样刷屏压垮 host。
|
|
130
130
|
*
|
|
@@ -191,7 +191,7 @@ export function clipCrossStepSourcesText(text) {
|
|
|
191
191
|
* @param {string} text - JSONL 全文(含 header 行)。
|
|
192
192
|
* @returns {{ text: string, neutralized: number, seqs: Array<number> }}
|
|
193
193
|
*/
|
|
194
|
-
export function neutralizeMarkersText(text) {
|
|
194
|
+
export function neutralizeMarkersText(text, { onlyLegacy = false } = {}) {
|
|
195
195
|
const parts = text.split('\n');
|
|
196
196
|
const seqs = [];
|
|
197
197
|
let neutralized = 0;
|
|
@@ -209,6 +209,11 @@ export function neutralizeMarkersText(text) {
|
|
|
209
209
|
if (v.data?.turn != null || v.data?.step != null) continue;
|
|
210
210
|
const id = v.data?.message?.id;
|
|
211
211
|
if (typeof id !== 'string' || !MARKER_PREFIXES.some((p) => id.startsWith(`${p}-`))) continue;
|
|
212
|
+
// R-C 一次性根治路径(`--neutralize-legacy-markers`):只动**历史载体**
|
|
213
|
+
// (`assistant/message` + `data.editor`);新载体是 `user/message` + `data.id`,
|
|
214
|
+
// 本来就不在这个分支里(类型不符),这里额外的判据是"必须有 editor",
|
|
215
|
+
// 便于把"清历史债"与"泛化中和"区分开、也让报告口径可核对。
|
|
216
|
+
if (onlyLegacy && v.data?.editor === undefined) continue;
|
|
212
217
|
const seq = v.seq;
|
|
213
218
|
delete v.surfaceOp;
|
|
214
219
|
delete v.sourceEventSeqs;
|
|
@@ -1153,7 +1158,10 @@ export function repairSession(file, opts = {}) {
|
|
|
1153
1158
|
let plain;
|
|
1154
1159
|
if (isZstd) {
|
|
1155
1160
|
try {
|
|
1156
|
-
|
|
1161
|
+
// R-B 边界:**写路径不吃撕裂尾帧**。`check`(只读)按宿主语义恢复撕裂尾帧;但
|
|
1162
|
+
// `fix` 会回写文件,若尾帧是**活动会话正在写入**的部分,回写会把它截掉 ——
|
|
1163
|
+
// 与宿主"由持有租约的会话自己截断"(:226-228)不同责。故这里显式 strict。
|
|
1164
|
+
plain = decompressZstd(buf, { allowTorn: false }).toString('utf8');
|
|
1157
1165
|
} catch (err) {
|
|
1158
1166
|
return { file, ok: false, issues: [{ kind: 'zstd-decode', detail: String(err.message ?? err) }], removed: 0, renumbered: 0, applied: false };
|
|
1159
1167
|
}
|
|
@@ -1194,7 +1202,16 @@ export function repairSession(file, opts = {}) {
|
|
|
1194
1202
|
applyFix('markers', r, `移除 ${r.removed} 个 retrace/message-editor marker(重编号 ${r.renumbered} 行)`);
|
|
1195
1203
|
}
|
|
1196
1204
|
let neutralized = 0;
|
|
1197
|
-
|
|
1205
|
+
let neutralizedSeqs = [];
|
|
1206
|
+
if (opts.neutralizeLegacyMarkers) {
|
|
1207
|
+
const r = neutralizeMarkersText(plain, { onlyLegacy: true });
|
|
1208
|
+
if (r.neutralized > 0) {
|
|
1209
|
+
plain = r.text;
|
|
1210
|
+
issues.push({ kind: 'neutralize-legacy-markers', detail: `一次性中和 ${r.neutralized} 个**历史载体** retrace marker(assistant/message + data.editor;type→retrace/marker + ignorable:true,删除 surfaceOp/sourceEventSeqs,seq/行数不变)→ 历史 token-meter 配对债根治(此后写前校验不再需要那条 ≤v2 降级白名单)` });
|
|
1211
|
+
}
|
|
1212
|
+
neutralized = r.neutralized;
|
|
1213
|
+
neutralizedSeqs = r.seqs;
|
|
1214
|
+
}
|
|
1198
1215
|
if (opts.neutralize) {
|
|
1199
1216
|
const r = neutralizeMarkersText(plain);
|
|
1200
1217
|
neutralized = r.neutralized;
|
|
@@ -1228,10 +1245,10 @@ export function repairSession(file, opts = {}) {
|
|
|
1228
1245
|
issues.push({ kind: 'trim-budget', detail: `估算 ${r.estimatedTokens} tokens ≤ 预算 ${opts.trimBudget}——无需裁剪(${r.kept} 条消息全保留)` });
|
|
1229
1246
|
}
|
|
1230
1247
|
}
|
|
1231
|
-
// L4 新原语(2026-08-30
|
|
1248
|
+
// L4 新原语(2026-08-30 收编外部验证工具)
|
|
1232
1249
|
if (typeof opts.tailRenumberDelta === 'number') {
|
|
1233
1250
|
// 起点自动推导:从首个可平移的 seq 起(即所有事件都平移)。
|
|
1234
|
-
// fix-tail 原工具是 <startLine> <delta>
|
|
1251
|
+
// fix-tail 原工具是 <startLine> <delta> 双参;L4 简化为单参 delta
|
|
1235
1252
|
// (尾部全部平移)。若需部分平移,传 --tail-renumber 前先 --keep-ranges。
|
|
1236
1253
|
const r = tailRenumberText(plain, 0, opts.tailRenumberDelta);
|
|
1237
1254
|
if (r.error) {
|