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.
- package/bin/dsh-log-contract.mjs +10 -4
- package/lib/index.js +1 -1
- package/lib/repair.js +130 -2
- package/package.json +1 -1
package/bin/dsh-log-contract.mjs
CHANGED
|
@@ -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}
|
|
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}
|
|
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,
|
|
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.
|
|
4
|
+
"version": "0.3.4",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"bin": {
|