dsh-log-contract 0.3.2 → 0.3.4

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.
@@ -23,11 +23,15 @@ 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]
26
+ dsh-log-contract fix <session-log> [--remove-markers] [--neutralize] [--clip-crossstep] [--apply] [--backup-dir DIR] [--json]
27
27
  诊断 + 修复(2026-08 事故固化方案)。先做严格 seq 连续扫描 + 契约体检
28
28
  (含 W1/W2 wire 级悬空 tool 检查),再按需修复:
29
29
  --remove-markers 移除 retrace/message-editor marker 并全量重编号
30
30
  (用于大范围遮蔽历史 / marker 漏盖 tool/result)
31
+ --clip-crossstep 裁剪 assistant/message 的跨 step sourceEventSeqs(token-meter 不再抛 belongs to another step)
32
+ --neutralize 原地中和 turn-null marker(type→retrace/marker +
33
+ ignorable:true,删 surfaceOp/sourceEventSeqs,seq/行数不变)
34
+ —— token-meter 不再刷屏,会话驻留也安全(2026-08-30 事故)
31
35
  --apply 备份后落盘(.zstd 走官方帧格式重建:帧1=header、
32
36
  帧2=其余、带 checksum、单个结尾换行)
33
37
  不传 --apply 为干跑(只报告)。
@@ -168,6 +172,8 @@ function cmdContracts() {
168
172
  function cmdFix(args) {
169
173
  const json = args.includes('--json');
170
174
  const removeMarkers = args.includes('--remove-markers');
175
+ const neutralize = args.includes('--neutralize');
176
+ const clipCrossStep = args.includes('--clip-crossstep');
171
177
  const dropFailedTurns = args.includes('--drop-failed-turns');
172
178
  const trimIdx = args.indexOf('--trim-last');
173
179
  const trimLast = trimIdx >= 0 && args[trimIdx + 1] ? Number(args[trimIdx + 1]) : undefined;
@@ -179,7 +185,7 @@ function cmdFix(args) {
179
185
  const file = args.find((a) => !a.startsWith('-'));
180
186
  if (!file) fail(USAGE);
181
187
 
182
- const result = repairSession(file, { removeMarkers, dropFailedTurns, trimLast, compactLast, apply, backupDir });
188
+ const result = repairSession(file, { removeMarkers, neutralize, clipCrossStep, dropFailedTurns, trimLast, compactLast, apply, backupDir });
183
189
  if (json) {
184
190
  process.stdout.write(JSON.stringify(result, null, 2) + '\n');
185
191
  process.exit(result.ok ? 0 : 1);
@@ -188,7 +194,7 @@ function cmdFix(args) {
188
194
  process.stdout.write(`\n🔧 dsh-log-contract fix —— ${file}\n`);
189
195
  process.stdout.write(` 诊断:${result.issues.length === 0 ? '无问题' : result.issues.map((i) => `[${i.kind}] ${i.detail}`).join('\n ')}\n`);
190
196
  if (result.applied) {
191
- process.stdout.write(` 已应用修复:移除 ${result.removed} 项,重编号 ${result.renumbered} 行\n`);
197
+ process.stdout.write(` 已应用修复:移除 ${result.removed} 项,重编号 ${result.renumbered} 行,中和 ${result.neutralized} 个 turn-null marker,裁剪 ${result.clipped} 个跨 step 引用(seq ${(result.neutralizedSeqs ?? []).join(',')})\n`);
192
198
  process.stdout.write(` 备份:${result.backupPath}\n`);
193
199
  process.stdout.write(` 修复后体检:error ${result.check.summary?.bySeverity?.error ?? '?'} | surface ${result.check.summary?.surfaceNodes ?? '?'} 节点\n`);
194
200
  } else if (apply && !result.ok) {
@@ -196,7 +202,7 @@ function cmdFix(args) {
196
202
  } else if (apply) {
197
203
  process.stdout.write(' (--apply 且无问题——无内容可修)\n');
198
204
  } else {
199
- process.stdout.write(` (干跑模式:${result.removed} 项可移除、${result.renumbered} 行待重编号;加 --apply 落盘,--remove-markers / --drop-failed-turns / --trim-last N 启用于对应修复)\n`);
205
+ process.stdout.write(` (干跑模式:${result.removed} 项可移除、${result.renumbered} 行待重编号、${result.neutralized} 个 turn-null marker 可中和、${result.clipped} 个跨 step 引用可裁剪;加 --apply 落盘,--remove-markers / --neutralize / --clip-crossstep / --drop-failed-turns / --trim-last N 启用于对应修复)\n`);
200
206
  }
201
207
  process.stdout.write('\n');
202
208
  process.exit(result.ok ? 0 : 1);
package/lib/index.js CHANGED
@@ -7,7 +7,7 @@
7
7
  export { loadSessionLog, tailSeq } 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, compactLastMessagesText, rebuildZstdText } from './repair.js';
10
+ export { repairSession, strictScanText, removeMarkersText, neutralizeMarkersText, clipCrossStepSourcesText, dropFailedTurnsText, trimLastMessagesText, compactLastMessagesText, rebuildZstdText } from './repair.js';
11
11
  export { CONTRACT_RULES, LAYER, SEVERITY, ruleById } from './contracts.js';
12
12
  export { tokenMeterViolations } from './checks.js';
13
13
  export { auditToolCalls, extractText, extractToolOutputs, indexToolCalls, toolCommandOf } from './archaeology.js';
package/lib/repair.js CHANGED
@@ -119,6 +119,107 @@ export function strictScanText(text) {
119
119
  return { failures, count };
120
120
  }
121
121
 
122
+ /**
123
+ * 裁剪 assistant/message 的跨 step sourceEventSeqs(2026-08-30 第二类事故)。
124
+ *
125
+ * 现象:DSH 的 resend/regenerate 在 agent 仍开着 step 时被触发,会把旧 step 的
126
+ * assistant/chunk 全部引用进新 assistant/message 的 sourceEventSeqs(526f1835
127
+ * seq 936047:sourceEventSeqs 覆盖 turn 54 的 step 7/8/9 三段)。token-meter
128
+ * 要求每个 source chunk 与消息同 turn/step(dsh-token-meter lib/index.js:645,
129
+ * `belongs to another step`)→ 同样刷屏压垮 host。
130
+ *
131
+ * 修复:把 sourceEventSeqs 裁剪为只含「与消息同 turn/step 的 assistant/chunk」
132
+ * (丢弃跨 step 的 chunk 与边界事件)。token-meter 检查全过(所有 src 同 step),
133
+ * token 计量仍准(保留的就是本 step 实际输出)。不动 seq/行数/其他字段。
134
+ *
135
+ * @param {string} text - JSONL 全文(含 header 行)。
136
+ * @returns {{ text: string, clipped: number, seqs: Array<number> }}
137
+ */
138
+ export function clipCrossStepSourcesText(text) {
139
+ const parts = text.split('\n');
140
+ // 用 decodeLine 展开每行,建立「展开后事件 seq → 事件」映射(chunk 行会展开为
141
+ // 多个 assistant/chunk,其 seq 是 seq0+偏移——必须用展开后的 seq 才能对上
142
+ // sourceEventSeqs 引用的值)。同时记录每个展开后事件属于哪一行,便于回写。
143
+ const bySeq = new Map(); // 展开后 seq → { event, lineIndex }
144
+ for (let i = 0; i < parts.length; i++) {
145
+ const raw = parts[i];
146
+ if (!raw.trim()) continue;
147
+ const decoded = decodeLine(raw);
148
+ if (decoded === null) continue;
149
+ for (const ev of decoded) {
150
+ if (typeof ev.seq === 'number') bySeq.set(ev.seq, { event: ev, lineIndex: i });
151
+ }
152
+ }
153
+ const clipped = [];
154
+ let clippedCount = 0;
155
+ for (const { event, lineIndex } of bySeq.values()) {
156
+ if (event.type !== 'assistant/message' || !Array.isArray(event.sourceEventSeqs) || event.sourceEventSeqs.length === 0) continue;
157
+ const { turn, step } = event.data ?? {};
158
+ if (turn == null || step == null) continue; // turn-null marker 由 neutralize 处理
159
+ let dirty = false;
160
+ const kept = event.sourceEventSeqs.filter((s) => {
161
+ const src = bySeq.get(s)?.event;
162
+ if (!src || src.type !== 'assistant/chunk') return true; // 保留非 chunk 引用(边界事件)
163
+ if (src.data?.turn === turn && src.data?.step === step) return true;
164
+ dirty = true;
165
+ return false;
166
+ });
167
+ if (dirty) {
168
+ // 回写:只改事件所属的行。若该行是 chunk 行(多个展开事件共享一行),
169
+ // 此事件必是独立 assistant/message 行,直接改那一行。
170
+ event.sourceEventSeqs = kept;
171
+ parts[lineIndex] = JSON.stringify(event);
172
+ clipped.push(event.seq);
173
+ clippedCount++;
174
+ }
175
+ }
176
+ return { text: parts.join('\n'), clipped: clippedCount, seqs: clipped };
177
+ }
178
+
179
+ /**
180
+ * 原地中和 turn-null marker(2026-08-30 事故升级:单 marker 会让 token-meter
181
+ * 监听器在每条事件上抛错刷屏、压垮 host 事件循环 → 全会话连锁锁定)。
182
+ *
183
+ * 机制(自检单 §3.3):把 turn-null `assistant/message` replace marker 改写为
184
+ * `retrace/marker` + `ignorable: true`(dsh-session lib/index.js:1203/1209 认可
185
+ * envelope 级 ignorable),**删除 surfaceOp/sourceEventSeqs,不动行数/seq/时间**。
186
+ * 改写后该事件不再属于 SURFACE_EVENT_TYPES(dsh-session:219-223)→ foldSurface
187
+ * 跳过、token-meter 不做 step 配对(不再抛错刷屏);会话驻留时也安全(seq 不变,
188
+ * 退出 flush 不撞车)。代价:被遮蔽的旧消息回到 surface(编辑外观回退为原文)。
189
+ *
190
+ * @param {string} text - JSONL 全文(含 header 行)。
191
+ * @returns {{ text: string, neutralized: number, seqs: Array<number> }}
192
+ */
193
+ export function neutralizeMarkersText(text) {
194
+ const parts = text.split('\n');
195
+ const seqs = [];
196
+ let neutralized = 0;
197
+ for (let i = 0; i < parts.length; i++) {
198
+ const raw = parts[i];
199
+ if (!raw.trim()) continue;
200
+ let v;
201
+ try {
202
+ v = JSON.parse(raw);
203
+ } catch {
204
+ continue;
205
+ }
206
+ if (!v || typeof v !== 'object' || v.type !== 'assistant/message') continue;
207
+ // 只中和 turn-null 的 retrace marker(data.turn/step 为 null 且 id 带 marker 前缀)
208
+ if (v.data?.turn != null || v.data?.step != null) continue;
209
+ const id = v.data?.message?.id;
210
+ if (typeof id !== 'string' || !MARKER_PREFIXES.some((p) => id.startsWith(`${p}-`))) continue;
211
+ const seq = v.seq;
212
+ delete v.surfaceOp;
213
+ delete v.sourceEventSeqs;
214
+ v.type = 'retrace/marker';
215
+ v.ignorable = true;
216
+ parts[i] = JSON.stringify(v);
217
+ seqs.push(seq);
218
+ neutralized++;
219
+ }
220
+ return { text: parts.join('\n'), neutralized, seqs };
221
+ }
222
+
122
223
  /**
123
224
  * 移除 retrace/message-editor marker 并全量重编号。
124
225
  * @param {string} text - JSONL 全文。
@@ -555,11 +656,12 @@ export function rebuildZstdText(text) {
555
656
  * @param {string} file - .jsonl 或 .jsonl.zstd 路径。
556
657
  * @param {{
557
658
  * removeMarkers?: boolean, dropFailedTurns?: boolean, trimLast?: number, compactLast?: number,
558
- * apply?: boolean, backupDir?: string
659
+ * neutralize?: boolean, clipCrossStep?: boolean, apply?: boolean, backupDir?: string
559
660
  * }} opts
560
661
  * @returns {{
561
662
  * file, ok, issues: Array<{kind:string, detail:string}>,
562
- * removed, renumbered, backupPath, applied,
663
+ * removed, renumbered, neutralized, neutralizedSeqs: Array<number>,
664
+ * clipped, clippedSeqs: Array<number>, backupPath, applied,
563
665
  * check: { ok, summary }
564
666
  * }}
565
667
  */
@@ -610,6 +712,28 @@ export function repairSession(file, opts = {}) {
610
712
  const r = removeMarkersText(plain);
611
713
  applyFix('markers', r, `移除 ${r.removed} 个 retrace/message-editor marker(重编号 ${r.renumbered} 行)`);
612
714
  }
715
+ let neutralized = 0;
716
+ const neutralizedSeqs = [];
717
+ if (opts.neutralize) {
718
+ const r = neutralizeMarkersText(plain);
719
+ neutralized = r.neutralized;
720
+ neutralizedSeqs.push(...r.seqs);
721
+ if (r.neutralized > 0) {
722
+ issues.push({ kind: 'neutralize', detail: `原地中和 ${r.neutralized} 个 turn-null marker(type→retrace/marker + ignorable:true,删除 surfaceOp/sourceEventSeqs,seq/行数不变)→ token-meter 不再刷屏` });
723
+ plain = r.text;
724
+ }
725
+ }
726
+ let clipped = 0;
727
+ const clippedSeqs = [];
728
+ if (opts.clipCrossStep) {
729
+ const r = clipCrossStepSourcesText(plain);
730
+ clipped = r.clipped;
731
+ clippedSeqs.push(...r.seqs);
732
+ if (r.clipped > 0) {
733
+ issues.push({ kind: 'clip-crossstep', detail: `裁剪 ${r.clipped} 个 assistant/message 的跨 step sourceEventSeqs(保留同 step chunk,token-meter 不再抛 belongs to another step)` });
734
+ plain = r.text;
735
+ }
736
+ }
613
737
  if (typeof opts.trimLast === 'number') {
614
738
  const r = trimLastMessagesText(plain, opts.trimLast);
615
739
  applyFix('trim', r, `裁剪到最近 ${r.kept} 条消息(丢弃 ${r.removed} 行,重编号 ${r.renumbered} 行)`);
@@ -652,6 +776,10 @@ export function repairSession(file, opts = {}) {
652
776
  issues,
653
777
  removed,
654
778
  renumbered,
779
+ neutralized,
780
+ neutralizedSeqs,
781
+ clipped,
782
+ clippedSeqs,
655
783
  backupPath,
656
784
  applied,
657
785
  check,
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.3.2",
4
+ "version": "0.3.4",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "bin": {