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.
@@ -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} marker,重编号 ${result.renumbered} 行\n`);
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} 个 marker 可移除、${result.renumbered} 行待重编号;加 --apply 落盘,--remove-markers 启用于移除)\n`);
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 {{ removeMarkers?: boolean, apply?: boolean, backupDir?: string }} opts
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
- if (opts.removeMarkers) {
274
- const r = removeMarkersText(plain);
275
- removed = r.removed;
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: 'markers', detail: `移除 ${r.removed} 个 retrace/message-editor marker(重编号 ${r.renumbered} 行)` });
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 postBroken = issues.some((i) => i.kind.startsWith('post-'));
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.0",
4
+ "version": "0.2.1",
5
5
  "packageManager": "pnpm@11.7.0",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",