dsh-log-contract 0.3.3 → 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,12 @@ 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] [--neutralize] [--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)
31
32
  --neutralize 原地中和 turn-null marker(type→retrace/marker +
32
33
  ignorable:true,删 surfaceOp/sourceEventSeqs,seq/行数不变)
33
34
  —— token-meter 不再刷屏,会话驻留也安全(2026-08-30 事故)
@@ -172,6 +173,7 @@ function cmdFix(args) {
172
173
  const json = args.includes('--json');
173
174
  const removeMarkers = args.includes('--remove-markers');
174
175
  const neutralize = args.includes('--neutralize');
176
+ const clipCrossStep = args.includes('--clip-crossstep');
175
177
  const dropFailedTurns = args.includes('--drop-failed-turns');
176
178
  const trimIdx = args.indexOf('--trim-last');
177
179
  const trimLast = trimIdx >= 0 && args[trimIdx + 1] ? Number(args[trimIdx + 1]) : undefined;
@@ -183,7 +185,7 @@ function cmdFix(args) {
183
185
  const file = args.find((a) => !a.startsWith('-'));
184
186
  if (!file) fail(USAGE);
185
187
 
186
- const result = repairSession(file, { removeMarkers, neutralize, dropFailedTurns, trimLast, compactLast, apply, backupDir });
188
+ const result = repairSession(file, { removeMarkers, neutralize, clipCrossStep, dropFailedTurns, trimLast, compactLast, apply, backupDir });
187
189
  if (json) {
188
190
  process.stdout.write(JSON.stringify(result, null, 2) + '\n');
189
191
  process.exit(result.ok ? 0 : 1);
@@ -192,7 +194,7 @@ function cmdFix(args) {
192
194
  process.stdout.write(`\n🔧 dsh-log-contract fix —— ${file}\n`);
193
195
  process.stdout.write(` 诊断:${result.issues.length === 0 ? '无问题' : result.issues.map((i) => `[${i.kind}] ${i.detail}`).join('\n ')}\n`);
194
196
  if (result.applied) {
195
- process.stdout.write(` 已应用修复:移除 ${result.removed} 项,重编号 ${result.renumbered} 行,中和 ${result.neutralized} 个 turn-null markerseq ${(result.neutralizedSeqs ?? []).join(',')})\n`);
197
+ process.stdout.write(` 已应用修复:移除 ${result.removed} 项,重编号 ${result.renumbered} 行,中和 ${result.neutralized} 个 turn-null marker,裁剪 ${result.clipped} 个跨 step 引用(seq ${(result.neutralizedSeqs ?? []).join(',')})\n`);
196
198
  process.stdout.write(` 备份:${result.backupPath}\n`);
197
199
  process.stdout.write(` 修复后体检:error ${result.check.summary?.bySeverity?.error ?? '?'} | surface ${result.check.summary?.surfaceNodes ?? '?'} 节点\n`);
198
200
  } else if (apply && !result.ok) {
@@ -200,7 +202,7 @@ function cmdFix(args) {
200
202
  } else if (apply) {
201
203
  process.stdout.write(' (--apply 且无问题——无内容可修)\n');
202
204
  } else {
203
- process.stdout.write(` (干跑模式:${result.removed} 项可移除、${result.renumbered} 行待重编号、${result.neutralized} 个 turn-null marker 可中和;加 --apply 落盘,--remove-markers / --neutralize / --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`);
204
206
  }
205
207
  process.stdout.write('\n');
206
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, neutralizeMarkersText, 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,63 @@ 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
+
122
179
  /**
123
180
  * 原地中和 turn-null marker(2026-08-30 事故升级:单 marker 会让 token-meter
124
181
  * 监听器在每条事件上抛错刷屏、压垮 host 事件循环 → 全会话连锁锁定)。
@@ -599,11 +656,12 @@ export function rebuildZstdText(text) {
599
656
  * @param {string} file - .jsonl 或 .jsonl.zstd 路径。
600
657
  * @param {{
601
658
  * removeMarkers?: boolean, dropFailedTurns?: boolean, trimLast?: number, compactLast?: number,
602
- * neutralize?: boolean, apply?: boolean, backupDir?: string
659
+ * neutralize?: boolean, clipCrossStep?: boolean, apply?: boolean, backupDir?: string
603
660
  * }} opts
604
661
  * @returns {{
605
662
  * file, ok, issues: Array<{kind:string, detail:string}>,
606
- * removed, renumbered, neutralized, neutralizedSeqs: Array<number>, backupPath, applied,
663
+ * removed, renumbered, neutralized, neutralizedSeqs: Array<number>,
664
+ * clipped, clippedSeqs: Array<number>, backupPath, applied,
607
665
  * check: { ok, summary }
608
666
  * }}
609
667
  */
@@ -665,6 +723,17 @@ export function repairSession(file, opts = {}) {
665
723
  plain = r.text;
666
724
  }
667
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
+ }
668
737
  if (typeof opts.trimLast === 'number') {
669
738
  const r = trimLastMessagesText(plain, opts.trimLast);
670
739
  applyFix('trim', r, `裁剪到最近 ${r.kept} 条消息(丢弃 ${r.removed} 行,重编号 ${r.renumbered} 行)`);
@@ -709,6 +778,8 @@ export function repairSession(file, opts = {}) {
709
778
  renumbered,
710
779
  neutralized,
711
780
  neutralizedSeqs,
781
+ clipped,
782
+ clippedSeqs,
712
783
  backupPath,
713
784
  applied,
714
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.3",
4
+ "version": "0.3.4",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "bin": {