dsh-log-contract 0.2.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/bin/dsh-log-contract.mjs +6 -3
- package/lib/index.js +1 -1
- package/lib/repair.js +194 -16
- package/package.json +1 -1
package/bin/dsh-log-contract.mjs
CHANGED
|
@@ -161,13 +161,16 @@ function cmdContracts() {
|
|
|
161
161
|
function cmdFix(args) {
|
|
162
162
|
const json = args.includes('--json');
|
|
163
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;
|
|
164
167
|
const apply = args.includes('--apply');
|
|
165
168
|
const backupDirIdx = args.indexOf('--backup-dir');
|
|
166
169
|
const backupDir = backupDirIdx >= 0 && args[backupDirIdx + 1] ? args[backupDirIdx + 1] : undefined;
|
|
167
170
|
const file = args.find((a) => !a.startsWith('-'));
|
|
168
171
|
if (!file) fail(USAGE);
|
|
169
172
|
|
|
170
|
-
const result = repairSession(file, { removeMarkers, apply, backupDir });
|
|
173
|
+
const result = repairSession(file, { removeMarkers, dropFailedTurns, trimLast, apply, backupDir });
|
|
171
174
|
if (json) {
|
|
172
175
|
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
173
176
|
process.exit(result.ok ? 0 : 1);
|
|
@@ -176,7 +179,7 @@ function cmdFix(args) {
|
|
|
176
179
|
process.stdout.write(`\n🔧 dsh-log-contract fix —— ${file}\n`);
|
|
177
180
|
process.stdout.write(` 诊断:${result.issues.length === 0 ? '无问题' : result.issues.map((i) => `[${i.kind}] ${i.detail}`).join('\n ')}\n`);
|
|
178
181
|
if (result.applied) {
|
|
179
|
-
process.stdout.write(` 已应用修复:移除 ${result.removed}
|
|
182
|
+
process.stdout.write(` 已应用修复:移除 ${result.removed} 项,重编号 ${result.renumbered} 行\n`);
|
|
180
183
|
process.stdout.write(` 备份:${result.backupPath}\n`);
|
|
181
184
|
process.stdout.write(` 修复后体检:error ${result.check.summary?.bySeverity?.error ?? '?'} | surface ${result.check.summary?.surfaceNodes ?? '?'} 节点\n`);
|
|
182
185
|
} else if (apply && !result.ok) {
|
|
@@ -184,7 +187,7 @@ function cmdFix(args) {
|
|
|
184
187
|
} else if (apply) {
|
|
185
188
|
process.stdout.write(' (--apply 且无问题——无内容可修)\n');
|
|
186
189
|
} else {
|
|
187
|
-
process.stdout.write(` (干跑模式:${result.removed}
|
|
190
|
+
process.stdout.write(` (干跑模式:${result.removed} 项可移除、${result.renumbered} 行待重编号;加 --apply 落盘,--remove-markers / --drop-failed-turns / --trim-last N 启用于对应修复)\n`);
|
|
188
191
|
}
|
|
189
192
|
process.stdout.write('\n');
|
|
190
193
|
process.exit(result.ok ? 0 : 1);
|
package/lib/index.js
CHANGED
|
@@ -7,5 +7,5 @@
|
|
|
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, rebuildZstdText } from './repair.js';
|
|
10
|
+
export { repairSession, strictScanText, removeMarkersText, dropFailedTurnsText, trimLastMessagesText, rebuildZstdText } from './repair.js';
|
|
11
11
|
export { CONTRACT_RULES, LAYER, SEVERITY, ruleById } from './contracts.js';
|
package/lib/repair.js
CHANGED
|
@@ -208,6 +208,168 @@ export function removeMarkersText(text) {
|
|
|
208
208
|
return { text: fixed, removed, renumbered, markerSeqs };
|
|
209
209
|
}
|
|
210
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
|
+
|
|
211
373
|
/**
|
|
212
374
|
* 按官方 writer 帧格式重建 .jsonl.zstd(帧1=header,帧2=其余,均带 checksum)。
|
|
213
375
|
* @param {string} text - JSONL 全文(以 "\n" 结尾)。
|
|
@@ -232,7 +394,10 @@ export function rebuildZstdText(text) {
|
|
|
232
394
|
/**
|
|
233
395
|
* 对单个会话日志执行诊断 +(可选)修复。
|
|
234
396
|
* @param {string} file - .jsonl 或 .jsonl.zstd 路径。
|
|
235
|
-
* @param {{
|
|
397
|
+
* @param {{
|
|
398
|
+
* removeMarkers?: boolean, dropFailedTurns?: boolean, trimLast?: number,
|
|
399
|
+
* apply?: boolean, backupDir?: string
|
|
400
|
+
* }} opts
|
|
236
401
|
* @returns {{
|
|
237
402
|
* file, ok, issues: Array<{kind:string, detail:string}>,
|
|
238
403
|
* removed, renumbered, backupPath, applied,
|
|
@@ -270,25 +435,38 @@ export function repairSession(file, opts = {}) {
|
|
|
270
435
|
}
|
|
271
436
|
let removed = 0;
|
|
272
437
|
let renumbered = 0;
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
renumbered = r.renumbered;
|
|
438
|
+
const applyFix = (label, r, detail) => {
|
|
439
|
+
removed += r.removed;
|
|
440
|
+
renumbered += r.renumbered;
|
|
277
441
|
if (r.removed > 0) {
|
|
278
|
-
issues.push({ kind:
|
|
442
|
+
issues.push({ kind: label, detail });
|
|
279
443
|
plain = r.text;
|
|
280
|
-
// 修复后复检
|
|
281
|
-
const scan2 = strictScanText(plain);
|
|
282
|
-
if (scan2.failures.length > 0) issues.push({ kind: 'post-scan', detail: `修复后仍有 ${scan2.failures.length} 处 seq 不连续` });
|
|
283
|
-
const check2 = validateSessionLog(loadSessionLogFromText(plain));
|
|
284
|
-
if (!check2.ok) {
|
|
285
|
-
const errs2 = check2.violations.filter((v) => v.severity === 'error');
|
|
286
|
-
issues.push({ kind: 'post-contract', detail: `修复后仍有 ${errs2.length} 个 error 级违规` });
|
|
287
|
-
}
|
|
288
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) ?? ''})` });
|
|
289
468
|
}
|
|
290
|
-
const
|
|
291
|
-
const ok = scan.failures.length === 0 && check.ok && !postBroken;
|
|
469
|
+
const ok = scanFinal.failures.length === 0 && checkFinal.ok;
|
|
292
470
|
let backupPath = null;
|
|
293
471
|
let applied = false;
|
|
294
472
|
if (opts.apply && ok) {
|
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.2.
|
|
4
|
+
"version": "0.2.1",
|
|
5
5
|
"packageManager": "pnpm@11.7.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|