dsh-log-contract 0.2.1 → 0.2.2

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.
@@ -164,13 +164,15 @@ function cmdFix(args) {
164
164
  const dropFailedTurns = args.includes('--drop-failed-turns');
165
165
  const trimIdx = args.indexOf('--trim-last');
166
166
  const trimLast = trimIdx >= 0 && args[trimIdx + 1] ? Number(args[trimIdx + 1]) : undefined;
167
+ const compactIdx = args.indexOf('--compact-last');
168
+ const compactLast = compactIdx >= 0 && args[compactIdx + 1] ? Number(args[compactIdx + 1]) : undefined;
167
169
  const apply = args.includes('--apply');
168
170
  const backupDirIdx = args.indexOf('--backup-dir');
169
171
  const backupDir = backupDirIdx >= 0 && args[backupDirIdx + 1] ? args[backupDirIdx + 1] : undefined;
170
172
  const file = args.find((a) => !a.startsWith('-'));
171
173
  if (!file) fail(USAGE);
172
174
 
173
- const result = repairSession(file, { removeMarkers, dropFailedTurns, trimLast, apply, backupDir });
175
+ const result = repairSession(file, { removeMarkers, dropFailedTurns, trimLast, compactLast, apply, backupDir });
174
176
  if (json) {
175
177
  process.stdout.write(JSON.stringify(result, null, 2) + '\n');
176
178
  process.exit(result.ok ? 0 : 1);
package/lib/checks.js CHANGED
@@ -265,6 +265,53 @@ export function finalFold(events) {
265
265
  }
266
266
  }
267
267
 
268
+ /**
269
+ * T1 —— token-meter 配对(复刻 @deepseek-ai/dsh-token-meter 的 _foldEvent 状态机,
270
+ * 2026-08-28 事故根因 3 固化):
271
+ * - `step/start` 打开一个 step(记录 turn/step);
272
+ * - `step/end` 必须匹配当前打开的 step/start,否则抛错;
273
+ * - `assistant/message` 必须匹配当前打开的 step/start(turn/step 完全一致),否则抛错;
274
+ * - `user/message` / `tool/result` 不检查(token meter 不配对)。
275
+ *
276
+ * 违反 = token meter 折叠抛错 → 该会话 `/compact` 与压力测量永久失败。
277
+ * 已知命中:retrace 的 turn/step=null 编辑/撤回 marker(空 assistant/message replace)——
278
+ * foldSurface 认可其合法性(M1 只约束 append 形态),但 token meter 崩溃。这是
279
+ * M1 规则的盲区:M1 没约束"replace 也必须过 token meter"。
280
+ *
281
+ * @param events - 行序事件流(`{event, lineNo}`)。
282
+ * @returns T1 违规列表。
283
+ */
284
+ export function tokenMeterViolations(events) {
285
+ const out = [];
286
+ // 无任何 step/start 的日志:现代 DSH 每个 assistant 回合必有 step/start,
287
+ // 完全没有说明是极早期格式或简化日志——token-meter 的 step 配对兼容性未
288
+ // 定义,不做配对检查(避免对旧结构误报;真实事故会话都是现代结构)。
289
+ if (!events.some(({ event }) => event.type === 'step/start')) return out;
290
+ let stepStart = undefined;
291
+ for (const { event, lineNo } of events) {
292
+ const loc = { seq: event.seq, lineNo, eventType: event.type };
293
+ if (event.type === 'step/start') {
294
+ if (stepStart !== undefined) {
295
+ out.push(violation('T1', loc, `step/start at seq ${event.seq} arrived before turn ${stepStart.turn}/step ${stepStart.step} ended——token meter 折叠会抛错`));
296
+ }
297
+ stepStart = { turn: event.data?.turn, step: event.data?.step };
298
+ } else if (event.type === 'step/end') {
299
+ if (stepStart === undefined || stepStart.turn !== event.data?.turn || stepStart.step !== event.data?.step) {
300
+ out.push(violation('T1', loc, `step/end at seq ${event.seq} has no matching step/start event(turn=${String(event.data?.turn)}, step=${String(event.data?.step)})——token meter 折叠会抛错`));
301
+ }
302
+ stepStart = undefined;
303
+ } else if (event.type === 'assistant/message') {
304
+ const turn = event.data?.turn;
305
+ const step = event.data?.step;
306
+ if (stepStart === undefined || stepStart.turn !== turn || stepStart.step !== step) {
307
+ const open = stepStart === undefined ? '无打开的 step' : `打开的 step 为 turn ${stepStart.turn}/step ${stepStart.step}`;
308
+ out.push(violation('T1', loc, `assistant/message at seq ${event.seq} has no matching step/start event(turn=${String(turn)}, step=${String(step)};${open})——token meter 折叠会抛错,/compact 与压力测量永久失败(retrace 的 turn-null 编辑/撤回 marker 即命中此条)`));
309
+ }
310
+ }
311
+ }
312
+ return out;
313
+ }
314
+
268
315
  /** 从事件推导 wire 消息(与 dsh-session deriveEventMessage 同语义)。 */
269
316
  export function deriveWireMessage(event) {
270
317
  if (event.type === 'user/message') {
package/lib/contracts.js CHANGED
@@ -197,6 +197,14 @@ export const CONTRACT_RULES = [
197
197
  description: '终验:把全部事件按序喂给官方 foldSurface,不抛 = 持久化层通过。S1–S7 任何一条违反都会在此暴露。',
198
198
  },
199
199
 
200
+ {
201
+ id: 'T1',
202
+ title: 'token-meter 配对:assistant/message 与 step/end 必须匹配当前打开的 step/start',
203
+ layer: LAYER.ENGINE,
204
+ severity: SEVERITY.ERROR,
205
+ source: '@deepseek-ai/dsh-token-meter lib/index.js:566-625 (_foldEvent)',
206
+ description: 'token meter 折叠要求 assistant/message 与 step/end 与打开的 step/start(turn/step 完全一致)匹配;违反即 /compact 与压力测量永久失败。retrace 的 turn-null 编辑/撤回 marker(空 assistant/message replace)命中此条——foldSurface 认可其合法性但 token meter 崩溃(M1 只约束 append 形态的盲区),压缩前需清理。',
207
+ },
200
208
  // ── M · 客户端引擎层 ────────────────────────────────────────────────────
201
209
  {
202
210
  id: 'M1',
package/lib/index.js CHANGED
@@ -7,5 +7,6 @@
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, dropFailedTurnsText, trimLastMessagesText, rebuildZstdText } from './repair.js';
10
+ export { repairSession, strictScanText, removeMarkersText, dropFailedTurnsText, trimLastMessagesText, compactLastMessagesText, rebuildZstdText } from './repair.js';
11
11
  export { CONTRACT_RULES, LAYER, SEVERITY, ruleById } from './contracts.js';
12
+ export { tokenMeterViolations } from './checks.js';
package/lib/prewrite.js CHANGED
@@ -17,7 +17,12 @@
17
17
  * 所有判定复用 `lib/checks.js`(与离线体检同一套逻辑),
18
18
  * 保证"体检看到的问题 = 写入前拦下的问题"。
19
19
  */
20
- import { envelopeViolations, engineViolations, finalFold, isSafeInt, pluginViolations, replaySurface, violation } from './checks.js';
20
+ import { envelopeViolations, engineViolations, finalFold, isSafeInt, pluginViolations, replaySurface, tokenMeterViolations, violation } from './checks.js';
21
+
22
+ /** retrace 类 marker:data.editor 存在(assistant/message replace,turn/step=null)。 */
23
+ function isKnownMarkerCandidate(event) {
24
+ return Boolean(event) && event.type === 'assistant/message' && event.surfaceOp && event.surfaceOp !== 'append' && event.data?.editor !== undefined;
25
+ }
21
26
 
22
27
  /** 把一个"拟写事件"规整为带 seq 的事件;seq 未携带时按追加位置赋值。 */
23
28
  function normalizeCandidate(candidate, nextSeq) {
@@ -78,6 +83,21 @@ export function createPreWriter(input = {}) {
78
83
  if (folded.error) {
79
84
  violations.push(violation('S8', { lineNo: null }, `官方 foldSurface 重放失败:${folded.error.message} —— 会话加载会被拒(SessionPersistenceCorruptionError)`));
80
85
  }
86
+ // T1 —— token-meter 配对(事故根因 3 固化)。写前校验只判定**拟写事件自身**
87
+ // 的 step 配对:retrace 的 turn-null 编辑/撤回 marker 必然命中(空
88
+ // assistant/message replace 无 step 可配对),但编辑功能必须可用——白名单
89
+ // 降级为 warning(已知设计债,压缩前需 doctor 清理);非 marker 的
90
+ // assistant/message 配对失败保持 error。历史已有事件的 T1 归属离线体检
91
+ // (check),不在这里重复拦截(否则历史 marker 会让后续编辑全部被拒)。
92
+ const lastCandidate = candidateEvents[candidateEvents.length - 1];
93
+ for (const t1 of tokenMeterViolations(candidateEvents.map((event) => ({ event })))) {
94
+ if (t1.id !== 'T1' || t1.seq !== lastCandidate?.seq) continue;
95
+ if (isKnownMarkerCandidate(lastCandidate)) {
96
+ violations.push({ ...t1, severity: 'warning', message: `${t1.message}(已知 retrace marker 设计债:压缩前需 doctor 清理)` });
97
+ } else {
98
+ violations.push(t1);
99
+ }
100
+ }
81
101
  const bySeverity = { error: 0, warning: 0, info: 0 };
82
102
  for (const v of violations) bySeverity[v.severity] = (bySeverity[v.severity] ?? 0) + 1;
83
103
  return {
package/lib/repair.js CHANGED
@@ -31,7 +31,7 @@ import path from 'node:path';
31
31
  import { constants, zstdCompressSync, zstdDecompressSync } from 'node:zlib';
32
32
  import { decompressZstd, loadSessionLog } from './log-reader.js';
33
33
  import { validateSessionLog } from './validate.js';
34
- import { CHUNK_ROW_TYPES, MARKER_PREFIXES } from './checks.js';
34
+ import { CHUNK_ROW_TYPES, MARKER_PREFIXES, SURFACE_TYPES } from './checks.js';
35
35
 
36
36
  /** 复刻 dsh-session expandRow:chunk 行展开为完整事件(含 data/turn/step)。 */
37
37
  function expandChunkRow(row) {
@@ -370,6 +370,165 @@ export function trimLastMessagesText(text, keepMessages) {
370
370
  return { ...r, kept: keepMessages, cutoff };
371
371
  }
372
372
 
373
+ /** 从被遮蔽的 user/assistant 消息做提取式摘要(跨范围均匀采样,不依赖模型)。 */
374
+ function extractiveSummary(msgEvents) {
375
+ const lines = [];
376
+ const step = Math.max(1, Math.ceil(msgEvents.length / 18));
377
+ const sampled = [];
378
+ for (let i = 0; i < msgEvents.length; i += step) sampled.push(msgEvents[i]);
379
+ const last = msgEvents[msgEvents.length - 1];
380
+ if (sampled[sampled.length - 1] !== last) sampled.push(last);
381
+ for (const ev of sampled) {
382
+ let text = '';
383
+ if (ev.type === 'user/message') {
384
+ text = (ev.data?.content ?? []).filter((b) => b.type === 'text').map((b) => b.text).join(' ');
385
+ } else if (ev.type === 'assistant/message') {
386
+ text = (ev.data?.message?.content ?? []).filter((b) => b.type === 'text').map((b) => b.text).join(' ');
387
+ }
388
+ text = text.replace(/\s+/g, ' ').trim();
389
+ if (!text) continue;
390
+ const chunk = text.length > 120 ? `${text.slice(0, 120)}…` : text;
391
+ lines.push(`${ev.type === 'user/message' ? '问' : '答'} ${chunk}`);
392
+ if (lines.join('\n').length > 1800) break;
393
+ }
394
+ if (lines.length === 0) return '(早期对话无文本内容)';
395
+ return `【早期对话提取式摘要 · 完整原文保留在会话日志与备份中】\n${lines.join('\n')}`;
396
+ }
397
+
398
+ /**
399
+ * DSH 官方压缩(compaction):不删除任何事件——在日志尾部追加
400
+ * compaction/start → compaction/summary → checkpoint(user/message, replace
401
+ * [start..end]) → compaction/end,把 [start..end] 从模型表面遮蔽,替换为
402
+ * 提取式摘要。旧事件全部保留(append-only 审计),日志一行不删。
403
+ * @param {string} text - JSONL 全文(严格连续)。
404
+ * @param {number} keepMessages - 保留的最近 append 消息数。
405
+ * @returns {{ text: string, compacted: boolean, kept: number, shadowed: number, summary: string }}
406
+ */
407
+ export function compactLastMessagesText(text, keepMessages) {
408
+ const parts = text.split('\n');
409
+ const bySeq = new Map();
410
+ const surfaceNodes = [];
411
+ for (let i = 1; i < parts.length; i++) {
412
+ const raw = parts[i].trim();
413
+ if (!raw) continue;
414
+ const decoded = decodeLine(raw);
415
+ if (decoded === null) continue;
416
+ for (const ev of decoded) {
417
+ bySeq.set(ev.seq, ev);
418
+ if (SURFACE_TYPES.has(ev.type)) {
419
+ const op = ev.surfaceOp;
420
+ if (op === 'append') surfaceNodes.push(ev.seq);
421
+ else if (op && op.op === 'replace') {
422
+ const s = surfaceNodes.indexOf(op.start);
423
+ const e = surfaceNodes.indexOf(op.end);
424
+ if (s !== -1 && e !== -1 && s <= e) surfaceNodes.splice(s, e - s + 1);
425
+ surfaceNodes.push(ev.seq);
426
+ }
427
+ }
428
+ }
429
+ }
430
+ const appendMsgs = surfaceNodes.filter((seq) => {
431
+ const ev = bySeq.get(seq);
432
+ return ev && (ev.type === 'user/message' || (ev.type === 'assistant/message' && ev.surfaceOp === 'append'));
433
+ });
434
+ const total = appendMsgs.length;
435
+ if (total <= keepMessages) return { text, compacted: false, kept: total, shadowed: 0, summary: '' };
436
+ const target = appendMsgs[total - keepMessages];
437
+ let boundary = target;
438
+ for (const seq of appendMsgs) {
439
+ const ev = bySeq.get(seq);
440
+ if (ev.type === 'user/message' && seq <= target) boundary = seq;
441
+ }
442
+ const shadowedSeqs = surfaceNodes.filter((seq) => seq < boundary);
443
+ const start = shadowedSeqs[0];
444
+ const end = shadowedSeqs[shadowedSeqs.length - 1];
445
+ const summary = extractiveSummary(shadowedSeqs.map((seq) => bySeq.get(seq)).filter(Boolean));
446
+ // 压缩事件插入到"第一个保留事件"之前(seq = boundary..boundary+3),
447
+ // 之后的事件整体 +4 重编号——保证表面顺序为 [checkpoint, 近期轮次…]。
448
+ const boundarySeq = boundary;
449
+ const compactionId = `dsh-fix-${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`;
450
+ const startSeq = boundarySeq;
451
+ const summarySeq = boundarySeq + 1;
452
+ const checkpointSeq = boundarySeq + 2;
453
+ const endSeq = boundarySeq + 3;
454
+ const now = Date.now();
455
+ const appended = [
456
+ { type: 'compaction/start', seq: startSeq, time: now, data: { compactionId, turn: null } },
457
+ {
458
+ type: 'compaction/summary',
459
+ seq: summarySeq,
460
+ time: now,
461
+ data: {
462
+ compactionId,
463
+ summary,
464
+ shadowedRange: { start, end },
465
+ shadowedSeqs,
466
+ shadowedTokenCount: Math.round(summary.length / 4),
467
+ provider: 'dsh-log-contract',
468
+ model: 'extractive',
469
+ },
470
+ },
471
+ {
472
+ type: 'user/message',
473
+ seq: checkpointSeq,
474
+ time: now,
475
+ surfaceOp: { op: 'replace', start, end },
476
+ sourceEventSeqs: [startSeq, summarySeq, ...shadowedSeqs],
477
+ data: {
478
+ id: `checkpoint-${compactionId}`,
479
+ role: 'user',
480
+ source: { kind: 'user' },
481
+ content: [{ type: 'text', text: summary }],
482
+ },
483
+ },
484
+ { type: 'compaction/end', seq: endSeq, time: now, data: { compactionId, turn: null } },
485
+ ];
486
+ // 找到第一个事件 seq >= boundary 的行(保留区起点),在其前插入压缩事件
487
+ let insertIdx = parts.length - 1;
488
+ for (let i = 1; i < parts.length; i++) {
489
+ const raw = parts[i].trim();
490
+ if (!raw) continue;
491
+ const decoded = decodeLine(raw);
492
+ if (decoded === null || decoded.length === 0) continue;
493
+ if (decoded[0].seq >= boundarySeq) {
494
+ insertIdx = i;
495
+ break;
496
+ }
497
+ }
498
+ const shiftFields = (v) => {
499
+ if (typeof v.seq === 'number') v.seq += 4;
500
+ if (CHUNK_ROW_TYPES.has(v.type) && typeof v.seq0 === 'number') v.seq0 += 4;
501
+ if (Array.isArray(v.sourceEventSeqs)) v.sourceEventSeqs = v.sourceEventSeqs.map((x) => x + 4);
502
+ if (v.surfaceOp && v.surfaceOp.op === 'replace') {
503
+ v.surfaceOp.start += 4;
504
+ v.surfaceOp.end += 4;
505
+ }
506
+ return v;
507
+ };
508
+ const out = [];
509
+ for (let i = 0; i < parts.length; i++) {
510
+ if (i === insertIdx) for (const e of appended) out.push(JSON.stringify(e));
511
+ const raw = parts[i];
512
+ if (i > 0 && i >= insertIdx && raw.trim()) {
513
+ let v;
514
+ try {
515
+ v = JSON.parse(raw);
516
+ } catch {
517
+ out.push(raw);
518
+ continue;
519
+ }
520
+ if (typeof v.seq === 'number') {
521
+ out.push(JSON.stringify(shiftFields(v)));
522
+ continue;
523
+ }
524
+ }
525
+ out.push(raw);
526
+ }
527
+ const newText = out.join('\n');
528
+ if (!newText.endsWith('\n')) return { text: newText + '\n', compacted: true, kept: keepMessages, shadowed: shadowedSeqs.length, summary };
529
+ return { text: newText, compacted: true, kept: keepMessages, shadowed: shadowedSeqs.length, summary };
530
+ }
531
+
373
532
  /**
374
533
  * 按官方 writer 帧格式重建 .jsonl.zstd(帧1=header,帧2=其余,均带 checksum)。
375
534
  * @param {string} text - JSONL 全文(以 "\n" 结尾)。
@@ -395,7 +554,7 @@ export function rebuildZstdText(text) {
395
554
  * 对单个会话日志执行诊断 +(可选)修复。
396
555
  * @param {string} file - .jsonl 或 .jsonl.zstd 路径。
397
556
  * @param {{
398
- * removeMarkers?: boolean, dropFailedTurns?: boolean, trimLast?: number,
557
+ * removeMarkers?: boolean, dropFailedTurns?: boolean, trimLast?: number, compactLast?: number,
399
558
  * apply?: boolean, backupDir?: string
400
559
  * }} opts
401
560
  * @returns {{
@@ -455,6 +614,13 @@ export function repairSession(file, opts = {}) {
455
614
  const r = trimLastMessagesText(plain, opts.trimLast);
456
615
  applyFix('trim', r, `裁剪到最近 ${r.kept} 条消息(丢弃 ${r.removed} 行,重编号 ${r.renumbered} 行)`);
457
616
  }
617
+ if (typeof opts.compactLast === 'number') {
618
+ const r = compactLastMessagesText(plain, opts.compactLast);
619
+ if (r.compacted) {
620
+ issues.push({ kind: 'compact', detail: `官方压缩:遮蔽 ${r.shadowed} 个 surface 节点,保留最近 ${r.kept} 条消息;旧事件全部保留(日志零删除)` });
621
+ plain = r.text;
622
+ }
623
+ }
458
624
  // 全部修复完成后做一次终检(中间态的临时违规不阻塞——后续修复可能已消除)
459
625
  const scanFinal = strictScanText(plain);
460
626
  const checkFinal = validateSessionLog(loadSessionLogFromText(plain));
package/lib/validate.js CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  pluginViolations,
18
18
  replaySurface,
19
19
  violation,
20
+ tokenMeterViolations,
20
21
  wireViolations,
21
22
  } from './checks.js';
22
23
 
@@ -101,6 +102,9 @@ export function validateSessionLog(log, opts = {}) {
101
102
  violations.push(...replay.violations);
102
103
 
103
104
  const folded = finalFold(events.map((e) => e.event));
105
+
106
+ // ── T · token meter 配对(事故根因 3)──
107
+ violations.push(...tokenMeterViolations(events));
104
108
  if (folded.error) {
105
109
  violations.push(violation('S8', { lineNo: null }, `官方 foldSurface 重放失败:${folded.error.message} —— 会话加载会被拒(SessionPersistenceCorruptionError)`));
106
110
  }
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.1",
4
+ "version": "0.2.2",
5
5
  "packageManager": "pnpm@11.7.0",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",