dsh-log-contract 0.1.0 → 0.2.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.md CHANGED
@@ -66,6 +66,28 @@ dsh-log-contract check ~/.dsh/sessions/<id>.jsonl.zstd --json # 机器可读
66
66
 
67
67
  退出码:0 = 通过(无 error 级违规);1 = 存在 error 级违规。
68
68
 
69
+ `check` 自 0.2.0 起新增 **W1/W2 wire 级检查**:按 surface 顺序展开模型请求消息流,
70
+ 捕获"悬空 tool 消息"(tool 结果没有前置 assistant tool_calls)与"user 文本插在
71
+ tool_calls 与其结果之间"——这类问题 DeepSeek 曾容忍,但 MiMo 等严格端点会直接
72
+ `INVALID_REQUEST`(2026-08-27 实锤)。
73
+
74
+ ### 1.5. ★ 修复(2026-08 事故固化方案)
75
+
76
+ ```bash
77
+ # 干跑(只报告):严格 seq 连续扫描 + 全契约体检(含 W1/W2)+ 可移除 marker 数
78
+ dsh-log-contract fix ~/.dsh/sessions/<id>.jsonl.zstd --remove-markers
79
+
80
+ # 应用:备份后落盘(.zstd 走官方帧格式重建:帧1=header、帧2=其余、checksum、单个结尾换行)
81
+ dsh-log-contract fix ~/.dsh/sessions/<id>.jsonl.zstd --remove-markers --apply
82
+ ```
83
+
84
+ - `--remove-markers`:移除 retrace/message-editor marker 并全量重编号
85
+ (seq/seq0/sourceEventSeqs/surfaceOp 同步)——用于大范围 marker 遮蔽历史、
86
+ marker 漏盖 tool/result 导致的悬空 tool。
87
+ - 手术安全协议:改前备份、改后全量复检(strictScan + check + foldSurface)、
88
+ marker 只能遮蔽其之前的节点、marker 绝不能改成 append(M1 客户端崩溃)。
89
+ - ⚠️ 若会话已被运行中的应用驻留内存,修复文件后需**重启应用**(强杀避免脏状态刷回)。
90
+
69
91
  ### 2. ★ 写前校验(本次事故的直接解药)
70
92
 
71
93
  `edit-file` 为 JSON,两种形状:
@@ -144,7 +166,7 @@ node scripts/check-local-fossils.mjs # 扫描 ../ 下 backup-session-*.jsonl.z
144
166
  | 工具 | 象限 | 状态 |
145
167
  |---|---|---|
146
168
  | [workbuddy-session-fork](https://github.com/yamingmou/workbuddy-session-fork) | 会话分叉 · 状态管理 | ✅ 已发布 v1.2.0 |
147
- | **dsh-log-contract**(本仓库) | 日志契约 · 接口稳定性 | 🆕 Phase 1 CLI 离线体检 + 写前校验 |
169
+ | **dsh-log-contract**(本仓库) | 日志契约 · 接口稳定性 | Phase 1(check/prewrite)+ Phase 1.5(fix)0.2.0 |
148
170
  | dsh-turn-guard(规划中) | 中断回合 · 异常韧性 | 待立项 |
149
171
 
150
172
  三者共享同一份 DSH 日志事件契约认知(59 条审计发现 = spec,aborted/corrupt/seqgap 化石 = 测试集)。dsh-retrace(回溯时间线)可把本工具的违规标记渲染到时间线上;本工具是 retrace 投影源健康度的**前置保险**。
@@ -153,8 +175,8 @@ node scripts/check-local-fossils.mjs # 扫描 ../ 下 backup-session-*.jsonl.z
153
175
 
154
176
  ## Roadmap
155
177
 
156
- - [x] **Phase 1(本版 0.1.0)**:CLI 离线体检 + 写前校验 + 契约目录
157
- - [ ] Phase 1.5:违规报告的 `--fix` 建议(seq 缺口修复复用 `fix-seq-gap.mjs` 方法论)、CI 集成(`dsh-log-contract check` 作为 Harness 会话目录的定时守护)
178
+ - [x] **Phase 10.1.0)**:CLI 离线体检 + 写前校验 + 契约目录
179
+ - [x] **Phase 1.5(0.2.0)**:`fix` 子命令(严格 seq 扫描 + W1/W2 wire 检查 + 移除 marker 重编号 + 官方帧格式重建);CI 集成(`dsh-log-contract check` 作为 Harness 会话目录的定时守护)
158
180
  - [ ] Phase 2:运行时守护(订阅 session append 事件流实时校验,断裂即标记 `dsh/contract-violation` 事件,策略可配 告警/拦截)——DSH 插件形态
159
181
  - [ ] Phase 3:与 dsh-turn-guard / dsh-retrace 时间线联动
160
182
 
@@ -13,7 +13,7 @@
13
13
  * contracts 列出内置契约规则目录
14
14
  */
15
15
  import fs from 'node:fs';
16
- import { loadSessionLog, validateSessionLog, createPreWriter, CONTRACT_RULES, ruleById } from '../lib/index.js';
16
+ import { loadSessionLog, validateSessionLog, createPreWriter, repairSession, CONTRACT_RULES, ruleById } from '../lib/index.js';
17
17
 
18
18
  const USAGE = `dsh-log-contract —— 日志契约守护(DSH session log contract guard)
19
19
 
@@ -23,6 +23,16 @@ const USAGE = `dsh-log-contract —— 日志契约守护(DSH session log cont
23
23
  --json 输出机器可读 JSON 报告
24
24
  --max-details N 每条违规最多列 N 个缺失 seq(默认 8,--json 忽略)
25
25
 
26
+ dsh-log-contract fix <session-log> [--remove-markers] [--apply] [--backup-dir DIR] [--json]
27
+ 诊断 + 修复(2026-08 事故固化方案)。先做严格 seq 连续扫描 + 契约体检
28
+ (含 W1/W2 wire 级悬空 tool 检查),再按需修复:
29
+ --remove-markers 移除 retrace/message-editor marker 并全量重编号
30
+ (用于大范围遮蔽历史 / marker 漏盖 tool/result)
31
+ --apply 备份后落盘(.zstd 走官方帧格式重建:帧1=header、
32
+ 帧2=其余、带 checksum、单个结尾换行)
33
+ 不传 --apply 为干跑(只报告)。
34
+ 注意:若会话已被运行中的应用驻留,修复文件后需重启(强杀避免脏状态刷回)。
35
+
26
36
  dsh-log-contract prewrite <edit-file> --log <session-log> [--json]
27
37
  写前校验。edit-file 为 JSON,两种形状:
28
38
  { "append": { ...事件... } } 拟追加一个事件到日志尾部
@@ -148,6 +158,41 @@ function cmdContracts() {
148
158
  }
149
159
  }
150
160
 
161
+ function cmdFix(args) {
162
+ const json = args.includes('--json');
163
+ const removeMarkers = args.includes('--remove-markers');
164
+ const dropFailedTurns = args.includes('--drop-failed-turns');
165
+ const trimIdx = args.indexOf('--trim-last');
166
+ const trimLast = trimIdx >= 0 && args[trimIdx + 1] ? Number(args[trimIdx + 1]) : undefined;
167
+ const apply = args.includes('--apply');
168
+ const backupDirIdx = args.indexOf('--backup-dir');
169
+ const backupDir = backupDirIdx >= 0 && args[backupDirIdx + 1] ? args[backupDirIdx + 1] : undefined;
170
+ const file = args.find((a) => !a.startsWith('-'));
171
+ if (!file) fail(USAGE);
172
+
173
+ const result = repairSession(file, { removeMarkers, dropFailedTurns, trimLast, apply, backupDir });
174
+ if (json) {
175
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
176
+ process.exit(result.ok ? 0 : 1);
177
+ }
178
+
179
+ process.stdout.write(`\n🔧 dsh-log-contract fix —— ${file}\n`);
180
+ process.stdout.write(` 诊断:${result.issues.length === 0 ? '无问题' : result.issues.map((i) => `[${i.kind}] ${i.detail}`).join('\n ')}\n`);
181
+ if (result.applied) {
182
+ process.stdout.write(` 已应用修复:移除 ${result.removed} 项,重编号 ${result.renumbered} 行\n`);
183
+ process.stdout.write(` 备份:${result.backupPath}\n`);
184
+ process.stdout.write(` 修复后体检:error ${result.check.summary?.bySeverity?.error ?? '?'} | surface ${result.check.summary?.surfaceNodes ?? '?'} 节点\n`);
185
+ } else if (apply && !result.ok) {
186
+ process.stdout.write(' ❌ 存在 error 级问题,拒绝应用(改前基线必须绿;先修基线或检查输出)\n');
187
+ } else if (apply) {
188
+ process.stdout.write(' (--apply 且无问题——无内容可修)\n');
189
+ } else {
190
+ process.stdout.write(` (干跑模式:${result.removed} 项可移除、${result.renumbered} 行待重编号;加 --apply 落盘,--remove-markers / --drop-failed-turns / --trim-last N 启用于对应修复)\n`);
191
+ }
192
+ process.stdout.write('\n');
193
+ process.exit(result.ok ? 0 : 1);
194
+ }
195
+
151
196
  const args = process.argv.slice(2);
152
197
  const cmd = args[0];
153
198
  if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
@@ -161,5 +206,6 @@ if (cmd === '--version' || cmd === '-v') {
161
206
  }
162
207
  if (cmd === 'check') cmdCheck(args.slice(1));
163
208
  else if (cmd === 'prewrite') cmdPrewrite(args.slice(1));
209
+ else if (cmd === 'fix') cmdFix(args.slice(1));
164
210
  else if (cmd === 'contracts') cmdContracts();
165
211
  else fail(`未知子命令 "${cmd}"\n\n${USAGE}`);
package/lib/checks.js CHANGED
@@ -264,3 +264,82 @@ export function finalFold(events) {
264
264
  return { error: err };
265
265
  }
266
266
  }
267
+
268
+ /** 从事件推导 wire 消息(与 dsh-session deriveEventMessage 同语义)。 */
269
+ export function deriveWireMessage(event) {
270
+ if (event.type === 'user/message') {
271
+ return { role: 'user', content: event.data?.content ?? [] };
272
+ }
273
+ if (event.type === 'assistant/message') {
274
+ const m = event.data?.message;
275
+ if (!m || !Array.isArray(m.content) || m.content.length === 0) return null;
276
+ return { role: 'assistant', content: m.content };
277
+ }
278
+ if (event.type === 'tool/result') {
279
+ return { role: 'tool', content: event.data?.message?.content ?? [] };
280
+ }
281
+ return null;
282
+ }
283
+
284
+ /**
285
+ * Wire 级校验(W1/W2):按 surface 顺序展开模型请求消息流,
286
+ * 检查 tool 消息是否悬空(缺前置 assistant tool-call)以及 user 文本
287
+ * 是否插在 tool_calls 与结果之间。strict 端点(MiMo 等)直接 INVALID_REQUEST。
288
+ * @param events - 展开后的完整事件流(含 chunk 展开)。
289
+ * @returns 违规列表。
290
+ */
291
+ export function wireViolations(events) {
292
+ const out = [];
293
+ // 先折叠 surface(append 入列;replace 移除 [start..end] 并将 marker 自身入列)
294
+ const nodes = [];
295
+ const bySeq = new Map();
296
+ for (const { event, lineNo } of events) {
297
+ bySeq.set(event.seq, { event, lineNo });
298
+ if (!SURFACE_TYPES.has(event.type)) continue;
299
+ const op = event.surfaceOp;
300
+ if (op === 'append') {
301
+ nodes.push(event.seq);
302
+ } else if (op && isReplaceOp(op)) {
303
+ const s = nodes.indexOf(op.start);
304
+ const e = nodes.indexOf(op.end);
305
+ if (s !== -1 && e !== -1 && s <= e) nodes.splice(s, e - s + 1);
306
+ nodes.push(event.seq);
307
+ }
308
+ }
309
+ // 展开 wire 流
310
+ const wire = [];
311
+ for (const seq of nodes) {
312
+ const rec = bySeq.get(seq);
313
+ if (!rec) continue;
314
+ const m = deriveWireMessage(rec.event);
315
+ if (m === null) continue;
316
+ if (m.role === 'assistant') {
317
+ wire.push({ role: 'assistant', tc: m.content.filter((b) => b.type === 'tool-call').length, seq, lineNo: rec.lineNo });
318
+ } else if (m.role === 'user') {
319
+ const text = m.content.some((b) => b.type === 'text');
320
+ const trs = m.content.filter((b) => b.type === 'tool-result').length;
321
+ if (text) wire.push({ role: 'user', seq, lineNo: rec.lineNo });
322
+ for (let i = 0; i < trs; i++) wire.push({ role: 'tool', seq, lineNo: rec.lineNo });
323
+ } else {
324
+ wire.push({ role: m.role, seq, lineNo: rec.lineNo });
325
+ }
326
+ }
327
+ let pending = 0;
328
+ let pendingSeq = null;
329
+ for (const w of wire) {
330
+ if (w.role === 'assistant') {
331
+ pending = w.tc;
332
+ pendingSeq = pending > 0 ? w.seq : null;
333
+ } else if (w.role === 'tool') {
334
+ if (pending <= 0) {
335
+ out.push(violation('W1', { seq: w.seq, lineNo: w.lineNo }, `wire: tool 消息 @seq ${w.seq} 悬空——surface 中没有未满足的 assistant tool-call(严格端点会 INVALID_REQUEST)`));
336
+ } else {
337
+ pending--;
338
+ if (pending === 0) pendingSeq = null;
339
+ }
340
+ } else if (w.role === 'user' && pending > 0) {
341
+ out.push(violation('W2', { seq: w.seq, lineNo: w.lineNo }, `wire: user 文本 @seq ${w.seq} 插在 assistant tool_calls(@seq ${pendingSeq})与其 tool 结果之间——严格端点会拒绝该序列`));
342
+ }
343
+ }
344
+ return out;
345
+ }
package/lib/contracts.js CHANGED
@@ -252,6 +252,24 @@ export const CONTRACT_RULES = [
252
252
  source: '审计 N5:多帧单帧全损 → 整会话不可读',
253
253
  description: '任一帧解码失败(磁盘 bitrot / 传输截断 / 并发写撕裂)即整会话不可读;帧越多,单帧损坏下丢失概率线性上升。',
254
254
  },
255
+
256
+ // ── W · wire 消息流(模型请求序列)───────────────────────────────────────
257
+ {
258
+ id: 'W1',
259
+ title: 'wire 流:tool 消息必须跟在带 tool-call 的 assistant 消息之后',
260
+ layer: LAYER.ENGINE,
261
+ severity: SEVERITY.ERROR,
262
+ source: '2026-08-27 实锤:MiMo 等严格端点对悬空 tool 直接 INVALID_REQUEST(Messages with role "tool" must be a response to a preceding message with "tool_calls");marker 遮蔽 assistant(tool_calls) 而未盖住 tool/result、或中断回合重放重复 tool/result 写在 marker 之后都会产生',
263
+ description: '按 surface 折叠顺序展开 wire 消息流:每个 role=tool 消息必须消费一个仍未满足的 assistant tool-call;不足 = 悬空(provider 拒绝)。常见来源:marker 范围漏盖 tool/result、重放重复事件。',
264
+ },
265
+ {
266
+ id: 'W2',
267
+ title: 'wire 流:user 文本不得插在 tool_calls 与其 tool 结果之间',
268
+ layer: LAYER.ENGINE,
269
+ severity: SEVERITY.ERROR,
270
+ source: 'OpenAI 兼容端点对 tool 消息顺序的严格校验;DSH 序列化器将混合 user 消息展开为 text 在前、tool-result 在后',
271
+ description: '当仍有未满足的 assistant tool-call 时出现 user 文本消息,会产生 [assistant(tool_calls), user(text), tool] 序列,严格端点同样拒绝。',
272
+ },
255
273
  ];
256
274
 
257
275
  /** 按 id 取规则。 */
package/lib/index.js CHANGED
@@ -2,9 +2,10 @@
2
2
  * dsh-log-contract · lib/index.js
3
3
  *
4
4
  * 日志契约守护(DSH session log contract guard)。
5
- * 公开 API:离线体检 + 写前校验 + 契约目录。
5
+ * 公开 API:离线体检 + 写前校验 + 修复 + 契约目录。
6
6
  */
7
7
  export { loadSessionLog } from './log-reader.js';
8
8
  export { validateSessionLog } from './validate.js';
9
9
  export { createPreWriter, preWriterFromLog } from './prewrite.js';
10
+ export { repairSession, strictScanText, removeMarkersText, dropFailedTurnsText, trimLastMessagesText, rebuildZstdText } from './repair.js';
10
11
  export { CONTRACT_RULES, LAYER, SEVERITY, ruleById } from './contracts.js';
package/lib/repair.js ADDED
@@ -0,0 +1,526 @@
1
+ /**
2
+ * dsh-log-contract · lib/repair.js
3
+ *
4
+ * 会话日志修复(2026-08-26/27 事故后的固化方案)。
5
+ *
6
+ * 提供三类修复原语,全部经过两轮真实事故验证:
7
+ * 1. strictScanText —— 展开 chunk 行后做 seq==index 严格连续扫描(复刻
8
+ * dsh-session-persistence-jsonl 的 committed-region 校验;该检查此前
9
+ * 未被离线体检覆盖,加载失败是它第一次暴露)。
10
+ * 2. removeMarkersText —— 移除 retrace / message-editor 的 replace marker
11
+ * 并全量重编号(seq / seq0 / sourceEventSeqs / surfaceOp 范围同步)。
12
+ * 用于"大范围 marker 遮蔽历史"(旧版 editFromScratch 产物)与
13
+ * "marker 漏盖 tool/result 导致悬空 tool"。
14
+ * 3. rebuildZstdText —— 按官方 writer 的帧格式重建 .jsonl.zstd:
15
+ * 帧1 = header 行 + "\n"(单独压缩、带 checksum),帧2 = 其余行
16
+ * + 结尾 "\n"(单独压缩、带 checksum)。**必须精确保持结尾单个换行**
17
+ * ——双换行/无换行都会被读者判为"complete frame contains a torn
18
+ * JSONL record"(2026-08-27 实锤踩坑)。
19
+ *
20
+ * 安全协议(每一步手术都必须遵守):
21
+ * - 改前备份原文件(`backup-<name>-<ts>` 同级目录或 --backup-dir);
22
+ * - 改后全量校验:strictScan + dsh-log-contract check(含 W1/W2 wire
23
+ * 检查)+ foldSurface;
24
+ * - marker 只能遮蔽它之前的节点(replace start/end 必须已存在于 surface);
25
+ * - marker 绝不能改成 append(turn/step null → 客户端引擎崩溃 M1);
26
+ * - 运行中的 app 内存优先于文件——修复后若会话已驻留,需重启(强杀避免
27
+ * 脏状态刷回)。
28
+ */
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+ import { constants, zstdCompressSync, zstdDecompressSync } from 'node:zlib';
32
+ import { decompressZstd, loadSessionLog } from './log-reader.js';
33
+ import { validateSessionLog } from './validate.js';
34
+ import { CHUNK_ROW_TYPES, MARKER_PREFIXES } from './checks.js';
35
+
36
+ /** 复刻 dsh-session expandRow:chunk 行展开为完整事件(含 data/turn/step)。 */
37
+ function expandChunkRow(row) {
38
+ const members = row.type === 'tool-call-chunks' ? row.data.args : row.data.texts;
39
+ const events = [];
40
+ let time = row.time0;
41
+ for (let k = 0; k < members.length; k++) {
42
+ if (k > 0) time += row.data.dt[k - 1];
43
+ let chunk;
44
+ switch (row.type) {
45
+ case 'text-chunks':
46
+ chunk = { type: 'text-delta', index: row.data.index, text: members[k] };
47
+ break;
48
+ case 'reasoning-chunks':
49
+ chunk = { type: 'reasoning-delta', index: row.data.index, text: members[k] };
50
+ break;
51
+ case 'tool-call-chunks':
52
+ chunk = { type: 'tool-call-delta', index: row.data.index, id: row.data.id, ...(row.data.name ? { name: row.data.name } : {}), argumentsDelta: members[k] };
53
+ break;
54
+ default:
55
+ return [];
56
+ }
57
+ events.push({ type: 'assistant/chunk', seq: row.seq0 + k, time, data: { turn: row.data.turn, step: row.data.step, chunk } });
58
+ }
59
+ return events;
60
+ }
61
+
62
+ /** 按行解码一行 JSONL 为事件数组(chunk 行展开,损坏行返回 null)。 */
63
+ function decodeLine(raw) {
64
+ let v;
65
+ try {
66
+ v = JSON.parse(raw);
67
+ } catch {
68
+ return null;
69
+ }
70
+ if (!v || typeof v !== 'object') return null;
71
+ const tag = v.type;
72
+ if (CHUNK_ROW_TYPES.has(tag)) {
73
+ try {
74
+ return expandChunkRow(v);
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+ return [v];
80
+ }
81
+
82
+ function isRetraceMarker(event) {
83
+ if (event?.type !== 'assistant/message') return false;
84
+ if (!event.surfaceOp || typeof event.surfaceOp !== 'object' || event.surfaceOp.op !== 'replace') return false;
85
+ const id = event.data?.message?.id;
86
+ return typeof id === 'string' && MARKER_PREFIXES.some((p) => id.startsWith(`${p}-`));
87
+ }
88
+
89
+ /**
90
+ * 严格 seq 连续扫描(展开 chunk 行后 seq==index)。header 行(首行)跳过。
91
+ * @param {string} text - JSONL 全文(含 header 行)。
92
+ * @returns {{ failures: Array<{line:number, expected:number, got:number, type:string}>, count: number }}
93
+ */
94
+ export function strictScanText(text) {
95
+ const lines = text.split('\n');
96
+ const failures = [];
97
+ let count = 0;
98
+ for (let i = 1; i < lines.length; i++) { // 跳过 header(第 1 行)
99
+ const raw = lines[i].trim();
100
+ if (!raw) continue;
101
+ const decoded = decodeLine(raw);
102
+ if (decoded === null) {
103
+ failures.push({ line: i + 1, kind: 'unparsable' });
104
+ continue;
105
+ }
106
+ for (const ev of decoded) {
107
+ if (typeof ev.seq !== 'number') {
108
+ failures.push({ line: i + 1, expected: count, got: ev.seq, type: ev.type ?? '?' });
109
+ continue;
110
+ }
111
+ if (ev.seq !== count) {
112
+ failures.push({ line: i + 1, expected: count, got: ev.seq, type: ev.type });
113
+ count = ev.seq + 1;
114
+ continue;
115
+ }
116
+ count++;
117
+ }
118
+ }
119
+ return { failures, count };
120
+ }
121
+
122
+ /**
123
+ * 移除 retrace/message-editor marker 并全量重编号。
124
+ * @param {string} text - JSONL 全文。
125
+ * @returns {{ text: string, removed: number, renumbered: number, markerSeqs: number[] }}
126
+ */
127
+ export function removeMarkersText(text) {
128
+ const parts = text.split('\n');
129
+ const markerSeqs = [];
130
+ for (const raw of parts) {
131
+ if (!raw.trim()) continue;
132
+ const decoded = decodeLine(raw);
133
+ if (decoded && decoded.length === 1 && isRetraceMarker(decoded[0])) markerSeqs.push(decoded[0].seq);
134
+ }
135
+ markerSeqs.sort((a, b) => a - b);
136
+ if (markerSeqs.length === 0) {
137
+ return { text, removed: 0, renumbered: 0, markerSeqs };
138
+ }
139
+ const shiftFor = (seq) => {
140
+ let lo = 0;
141
+ let hi = markerSeqs.length;
142
+ while (lo < hi) {
143
+ const mid = (lo + hi) >> 1;
144
+ if (markerSeqs[mid] < seq) lo = mid + 1;
145
+ else hi = mid;
146
+ }
147
+ return lo;
148
+ };
149
+ const isMarkerSeq = (seq) => markerSeqs.includes(seq);
150
+ const out = [];
151
+ let removed = 0;
152
+ let renumbered = 0;
153
+ for (const raw of parts) {
154
+ if (!raw.trim()) {
155
+ out.push(raw); // 保留结尾空行(确保 join 后仍是单个结尾换行)
156
+ continue;
157
+ }
158
+ let v;
159
+ try {
160
+ v = JSON.parse(raw);
161
+ } catch {
162
+ out.push(raw);
163
+ continue;
164
+ }
165
+ if (v && typeof v === 'object' && v.type === 'assistant/message' && v.surfaceOp && v.surfaceOp.op === 'replace') {
166
+ const id = v.data?.message?.id;
167
+ if (typeof id === 'string' && MARKER_PREFIXES.some((p) => id.startsWith(`${p}-`))) {
168
+ removed++;
169
+ continue;
170
+ }
171
+ }
172
+ let touched = false;
173
+ if (typeof v.seq === 'number') {
174
+ const s = shiftFor(v.seq);
175
+ if (s) {
176
+ v.seq -= s;
177
+ touched = true;
178
+ }
179
+ }
180
+ if (CHUNK_ROW_TYPES.has(v.type) && typeof v.seq0 === 'number') {
181
+ const s = shiftFor(v.seq0);
182
+ if (s) {
183
+ v.seq0 -= s;
184
+ touched = true;
185
+ }
186
+ }
187
+ if (Array.isArray(v.sourceEventSeqs)) {
188
+ const next = v.sourceEventSeqs.filter((x) => !isMarkerSeq(x)).map((x) => x - shiftFor(x));
189
+ if (next.length !== v.sourceEventSeqs.length || next.some((x, k) => x !== v.sourceEventSeqs[k])) {
190
+ v.sourceEventSeqs = next;
191
+ touched = true;
192
+ }
193
+ }
194
+ if (v.surfaceOp && v.surfaceOp.op === 'replace') {
195
+ const ns = v.surfaceOp.start - shiftFor(v.surfaceOp.start);
196
+ const ne = v.surfaceOp.end - shiftFor(v.surfaceOp.end);
197
+ if (ns !== v.surfaceOp.start || ne !== v.surfaceOp.end) {
198
+ v.surfaceOp.start = ns;
199
+ v.surfaceOp.end = ne;
200
+ touched = true;
201
+ }
202
+ }
203
+ out.push(touched ? JSON.stringify(v) : raw);
204
+ if (touched) renumbered++;
205
+ }
206
+ let fixed = out.join('\n');
207
+ if (!fixed.endsWith('\n')) fixed += '\n';
208
+ return { text: fixed, removed, renumbered, markerSeqs };
209
+ }
210
+
211
+ /**
212
+ * 通用"删除事件 + 全量重编号"引擎:按谓词删行,seq/seq0/sourceEventSeqs/
213
+ * surfaceOp 范围同步平移。
214
+ * @param {string[]} parts - text.split('\n')。
215
+ * @param {(event: object) => boolean} dropPredicate - 命中即删除该事件行。
216
+ * @returns {{ text: string, removed: number, renumbered: number }}
217
+ */
218
+ function renumberWithDrops(parts, dropPredicate) {
219
+ const dropped = new Set();
220
+ for (const raw of parts) {
221
+ if (!raw.trim()) continue;
222
+ let v;
223
+ try {
224
+ v = JSON.parse(raw);
225
+ } catch {
226
+ continue;
227
+ }
228
+ if (typeof v.seq === 'number' && dropPredicate(v)) dropped.add(v.seq);
229
+ }
230
+ const sortedDrops = [...dropped].sort((a, b) => a - b);
231
+ const shiftFor = (seq) => {
232
+ let lo = 0;
233
+ let hi = sortedDrops.length;
234
+ while (lo < hi) {
235
+ const mid = (lo + hi) >> 1;
236
+ if (sortedDrops[mid] < seq) lo = mid + 1;
237
+ else hi = mid;
238
+ }
239
+ return lo;
240
+ };
241
+ const isDrop = (seq) => dropped.has(seq);
242
+ const out = [];
243
+ let removed = 0;
244
+ let renumbered = 0;
245
+ for (const raw of parts) {
246
+ if (!raw.trim()) {
247
+ out.push(raw);
248
+ continue;
249
+ }
250
+ let v;
251
+ try {
252
+ v = JSON.parse(raw);
253
+ } catch {
254
+ out.push(raw);
255
+ continue;
256
+ }
257
+ if (typeof v.seq === 'number' && dropPredicate(v)) {
258
+ removed++;
259
+ continue;
260
+ }
261
+ let touched = false;
262
+ if (typeof v.seq === 'number') {
263
+ const s = shiftFor(v.seq);
264
+ if (s) {
265
+ v.seq -= s;
266
+ touched = true;
267
+ }
268
+ }
269
+ if (CHUNK_ROW_TYPES.has(v.type) && typeof v.seq0 === 'number') {
270
+ const s = shiftFor(v.seq0);
271
+ if (s) {
272
+ v.seq0 -= s;
273
+ touched = true;
274
+ }
275
+ }
276
+ if (Array.isArray(v.sourceEventSeqs)) {
277
+ const next = v.sourceEventSeqs.filter((x) => !isDrop(x)).map((x) => x - shiftFor(x));
278
+ if (next.length !== v.sourceEventSeqs.length || next.some((x, k) => x !== v.sourceEventSeqs[k])) {
279
+ v.sourceEventSeqs = next;
280
+ touched = true;
281
+ }
282
+ }
283
+ if (v.surfaceOp && v.surfaceOp.op === 'replace') {
284
+ const ns = v.surfaceOp.start - shiftFor(v.surfaceOp.start);
285
+ const ne = v.surfaceOp.end - shiftFor(v.surfaceOp.end);
286
+ if (ns !== v.surfaceOp.start || ne !== v.surfaceOp.end) {
287
+ v.surfaceOp.start = ns;
288
+ v.surfaceOp.end = ne;
289
+ touched = true;
290
+ }
291
+ }
292
+ out.push(touched ? JSON.stringify(v) : raw);
293
+ if (touched) renumbered++;
294
+ }
295
+ let fixed = out.join('\n');
296
+ if (!fixed.endsWith('\n')) fixed += '\n';
297
+ return { text: fixed, removed, renumbered };
298
+ }
299
+
300
+ /**
301
+ * 删除"本轮运行失败"的轮次(turn/end 带 reason.kind==='error' 的完整轮次:
302
+ * [turnStart..turnEnd],含其中的 user 消息与失败产出),并全量重编号。
303
+ * 用于清掉界面上的失败报错气泡。
304
+ * @param {string} text - JSONL 全文。
305
+ * @returns {{ text: string, removed: number, renumbered: number, failedTurns: number }}
306
+ */
307
+ export function dropFailedTurnsText(text) {
308
+ const parts = text.split('\n');
309
+ const spans = [];
310
+ let curTurnStart = null;
311
+ for (const raw of parts) {
312
+ if (!raw.trim()) continue;
313
+ let v;
314
+ try {
315
+ v = JSON.parse(raw);
316
+ } catch {
317
+ continue;
318
+ }
319
+ if (v.type === 'turn/start') curTurnStart = v.seq;
320
+ else if (v.type === 'turn/end') {
321
+ if (v.data?.reason?.kind === 'error' && curTurnStart !== null) spans.push([curTurnStart, v.seq]);
322
+ curTurnStart = null;
323
+ }
324
+ }
325
+ if (spans.length === 0) return { text, removed: 0, renumbered: 0, failedTurns: 0 };
326
+ const inSpan = (seq) => spans.some(([s, e]) => seq >= s && seq <= e);
327
+ const r = renumberWithDrops(parts, (v) => inSpan(v.seq));
328
+ return { ...r, failedTurns: spans.length };
329
+ }
330
+
331
+ /**
332
+ * 裁剪到最近 keepMessages 条 append 消息(保留其所在 turn 的结构),
333
+ * 同时移除全部 retrace/message-editor marker,并全量重编号。
334
+ * 用于"完整历史超出模型 context 窗口"(MiMo 1M tokens 实锤)。
335
+ * @param {string} text - JSONL 全文。
336
+ * @param {number} keepMessages - 保留的 append 消息数。
337
+ * @returns {{ text: string, removed: number, renumbered: number, kept: number, cutoff: number }}
338
+ */
339
+ export function trimLastMessagesText(text, keepMessages) {
340
+ const parts = text.split('\n');
341
+ const msgSeqs = [];
342
+ let lastTurnStart = null;
343
+ for (const raw of parts) {
344
+ if (!raw.trim()) continue;
345
+ let v;
346
+ try {
347
+ v = JSON.parse(raw);
348
+ } catch {
349
+ continue;
350
+ }
351
+ if (v.type === 'user/message' || (v.type === 'assistant/message' && v.surfaceOp === 'append')) msgSeqs.push(v.seq);
352
+ }
353
+ const total = msgSeqs.length;
354
+ if (total <= keepMessages) return { text, removed: 0, renumbered: 0, kept: total, cutoff: 0 };
355
+ const cutoffMsg = msgSeqs[total - keepMessages];
356
+ // 往前取到包含 cutoffMsg 的那个 turn 的 turn/start,保住轮次结构
357
+ let cutoff = cutoffMsg;
358
+ for (const raw of parts) {
359
+ if (!raw.trim()) continue;
360
+ let v;
361
+ try {
362
+ v = JSON.parse(raw);
363
+ } catch {
364
+ continue;
365
+ }
366
+ if (v.type === 'turn/start' && v.seq < cutoffMsg) cutoff = v.seq;
367
+ }
368
+ const drop = (v) => (typeof v.seq === 'number' && v.seq < cutoff) || isRetraceMarker(v);
369
+ const r = renumberWithDrops(parts, drop);
370
+ return { ...r, kept: keepMessages, cutoff };
371
+ }
372
+
373
+ /**
374
+ * 按官方 writer 帧格式重建 .jsonl.zstd(帧1=header,帧2=其余,均带 checksum)。
375
+ * @param {string} text - JSONL 全文(以 "\n" 结尾)。
376
+ * @returns {Buffer}
377
+ */
378
+ export function rebuildZstdText(text) {
379
+ const nl = text.indexOf('\n');
380
+ if (nl === -1) throw new Error('repair: JSONL 缺 header 行');
381
+ const header = text.slice(0, nl + 1);
382
+ const body = text.slice(nl + 1);
383
+ if (!body.endsWith('\n')) throw new Error('repair: JSONL 必须以单个换行结尾');
384
+ const opts = { params: { [constants.ZSTD_c_checksumFlag]: 1 } };
385
+ const f1 = zstdCompressSync(Buffer.from(header), opts);
386
+ const f2 = zstdCompressSync(Buffer.from(body), opts);
387
+ const out = Buffer.concat([f1, f2]);
388
+ // roundtrip 验证
389
+ const back = Buffer.concat([zstdDecompressSync(f1), zstdDecompressSync(f2)]).toString('utf8');
390
+ if (back !== text) throw new Error('repair: zstd 重建 roundtrip 不一致');
391
+ return out;
392
+ }
393
+
394
+ /**
395
+ * 对单个会话日志执行诊断 +(可选)修复。
396
+ * @param {string} file - .jsonl 或 .jsonl.zstd 路径。
397
+ * @param {{
398
+ * removeMarkers?: boolean, dropFailedTurns?: boolean, trimLast?: number,
399
+ * apply?: boolean, backupDir?: string
400
+ * }} opts
401
+ * @returns {{
402
+ * file, ok, issues: Array<{kind:string, detail:string}>,
403
+ * removed, renumbered, backupPath, applied,
404
+ * check: { ok, summary }
405
+ * }}
406
+ */
407
+ export function repairSession(file, opts = {}) {
408
+ const buf = fs.readFileSync(file);
409
+ const isZstd = buf.length >= 4 && buf.readUInt32LE(0) === 4247762216;
410
+ const issues = [];
411
+ let plain;
412
+ if (isZstd) {
413
+ try {
414
+ plain = decompressZstd(buf).toString('utf8');
415
+ } catch (err) {
416
+ return { file, ok: false, issues: [{ kind: 'zstd-decode', detail: String(err.message ?? err) }], removed: 0, renumbered: 0, applied: false };
417
+ }
418
+ } else {
419
+ plain = buf.toString('utf8');
420
+ }
421
+ if (!plain.endsWith('\n')) {
422
+ issues.push({ kind: 'trailing-newline', detail: '文件未以单个换行结尾——读者会判为撕裂记录;将补回' });
423
+ plain += '\n';
424
+ }
425
+ const scan = strictScanText(plain);
426
+ if (scan.failures.length > 0) {
427
+ const f = scan.failures[0];
428
+ issues.push({ kind: 'seq-gap', detail: `line ${f.line}:期望 ${f.expected} 实际 ${f.got}(${f.type})——${scan.failures.length} 处` });
429
+ }
430
+ // 契约体检(含 W1/W2 wire 检查)
431
+ const check = validateSessionLog(loadSessionLogFromText(plain));
432
+ if (!check.ok) {
433
+ const errs = check.violations.filter((v) => v.severity === 'error');
434
+ issues.push({ kind: 'contract', detail: `${errs.length} 个 error 级违规(首条:${errs[0]?.id ?? '-'} ${errs[0]?.message?.slice(0, 90) ?? ''})` });
435
+ }
436
+ let removed = 0;
437
+ let renumbered = 0;
438
+ const applyFix = (label, r, detail) => {
439
+ removed += r.removed;
440
+ renumbered += r.renumbered;
441
+ if (r.removed > 0) {
442
+ issues.push({ kind: label, detail });
443
+ plain = r.text;
444
+ }
445
+ };
446
+ if (opts.dropFailedTurns) {
447
+ const r = dropFailedTurnsText(plain);
448
+ applyFix('failed-turns', r, `移除 ${r.failedTurns} 个失败轮次("本轮运行失败"报错气泡,重编号 ${r.renumbered} 行)`);
449
+ }
450
+ if (opts.removeMarkers) {
451
+ const r = removeMarkersText(plain);
452
+ applyFix('markers', r, `移除 ${r.removed} 个 retrace/message-editor marker(重编号 ${r.renumbered} 行)`);
453
+ }
454
+ if (typeof opts.trimLast === 'number') {
455
+ const r = trimLastMessagesText(plain, opts.trimLast);
456
+ applyFix('trim', r, `裁剪到最近 ${r.kept} 条消息(丢弃 ${r.removed} 行,重编号 ${r.renumbered} 行)`);
457
+ }
458
+ // 全部修复完成后做一次终检(中间态的临时违规不阻塞——后续修复可能已消除)
459
+ const scanFinal = strictScanText(plain);
460
+ const checkFinal = validateSessionLog(loadSessionLogFromText(plain));
461
+ if (scanFinal.failures.length > 0) {
462
+ const f = scanFinal.failures[0];
463
+ issues.push({ kind: 'final-scan', detail: `修复后仍不连续:line ${f.line} 期望 ${f.expected} 实际 ${f.got}——${scanFinal.failures.length} 处` });
464
+ }
465
+ if (!checkFinal.ok) {
466
+ const errs = checkFinal.violations.filter((v) => v.severity === 'error');
467
+ issues.push({ kind: 'final-contract', detail: `修复后仍有 ${errs.length} 个 error 级违规(首条:${errs[0]?.id ?? '-'} ${errs[0]?.message?.slice(0, 90) ?? ''})` });
468
+ }
469
+ const ok = scanFinal.failures.length === 0 && checkFinal.ok;
470
+ let backupPath = null;
471
+ let applied = false;
472
+ if (opts.apply && ok) {
473
+ const dir = opts.backupDir ?? path.dirname(file);
474
+ const ts = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19);
475
+ const base = path.basename(file).replace(/\.(jsonl\.zstd|jsonl)$/, '');
476
+ backupPath = path.join(dir, `backup-${base}-${ts}.jsonl.zstd`);
477
+ fs.copyFileSync(file, backupPath);
478
+ const out = isZstd ? rebuildZstdText(plain) : Buffer.from(plain, 'utf8');
479
+ fs.writeFileSync(file + '.tmp', out);
480
+ fs.renameSync(file + '.tmp', file);
481
+ applied = true;
482
+ }
483
+ return {
484
+ file,
485
+ ok,
486
+ issues,
487
+ removed,
488
+ renumbered,
489
+ backupPath,
490
+ applied,
491
+ check,
492
+ };
493
+ }
494
+
495
+ /** 从文本构造 loadSessionLog 同形状对象(复用校验器)。 */
496
+ function loadSessionLogFromText(text) {
497
+ const lines = text.split('\n');
498
+ let header = null;
499
+ try {
500
+ header = JSON.parse(lines[0]);
501
+ } catch {
502
+ header = null;
503
+ }
504
+ const rows = [];
505
+ const events = [];
506
+ for (let i = 1; i < lines.length; i++) {
507
+ const line = lines[i];
508
+ if (line.trim().length === 0) continue;
509
+ let value;
510
+ try {
511
+ value = JSON.parse(line);
512
+ } catch {
513
+ rows.push({ lineNo: i, value: null, decoded: null, error: new Error('unparsable') });
514
+ continue;
515
+ }
516
+ const decoded = decodeLine(line);
517
+ if (decoded === null) {
518
+ rows.push({ lineNo: i, value, decoded: null, error: new Error('decode failed') });
519
+ continue;
520
+ }
521
+ for (const event of decoded) events.push({ seq: event.seq, event, lineNo: i });
522
+ rows.push({ lineNo: i, value, decoded, error: null });
523
+ }
524
+ events.sort((a, b) => a.seq - b.seq);
525
+ return { header, headerLine: 0, rows, events, frameInfo: {} };
526
+ }
package/lib/validate.js CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  pluginViolations,
18
18
  replaySurface,
19
19
  violation,
20
+ wireViolations,
20
21
  } from './checks.js';
21
22
 
22
23
  /**
@@ -104,6 +105,9 @@ export function validateSessionLog(log, opts = {}) {
104
105
  violations.push(violation('S8', { lineNo: null }, `官方 foldSurface 重放失败:${folded.error.message} —— 会话加载会被拒(SessionPersistenceCorruptionError)`));
105
106
  }
106
107
 
108
+ // ── W · wire 消息流(严格端点拒绝的悬空 tool / 顺序破坏)──────────────
109
+ violations.push(...wireViolations(events));
110
+
107
111
  // ── M / P ──────────────────────────────────────────────────────────────
108
112
  for (const { event, lineNo } of events) {
109
113
  const loc = { seq: event.seq, lineNo, eventType: event.type };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-log-contract",
3
3
  "description": "日志契约守护 — DSH session log contract guard: offline health check (CLI) + pre-write validation for DeepSeek Harness session logs",
4
- "version": "0.1.0",
4
+ "version": "0.2.1",
5
5
  "packageManager": "pnpm@11.7.0",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",