@jjh32456/dsh-longlive 1.6.5 → 1.6.7

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/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## 1.6.7
2
+
3
+ - 热截断加固:turn===0 / 无 turn 记录的组不再永久屏障(含 system/developer 指令的原地保留,只含普通消息的随旧前缀整组归档);中段缺 turn/end 的轮只有最后启动的当前轮不可切;新增 onNoPlan 拒绝原因通道,切不了会写 'hot-truncate: 未切(reason)',杜绝静默零进展。
4
+
5
+ ## 1.6.6
6
+
7
+ - 热截断修复:去掉 skipPast 永久钉死(aborted/interrupted 轮只要有 turn/end 就整轮可切);段首 system/message 永不进切段,中途注入的 system 副本随旧前缀归档。修复后真实会话一次可切 35 个 closed 轮(约 46 万 tokens),上下文回到最近 4 轮量级。
8
+
1
9
  # 变更记录
2
10
 
3
11
  本文件从 0.7.6 开始记录;更早的版本没有随包维护变更日志(0.7.5 及以前的 tarball 里没有
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjh32456/dsh-longlive",
3
- "version": "1.6.5",
3
+ "version": "1.6.7",
4
4
  "type": "module",
5
5
  "description": "久视(longlive):一个磁盘上的共享大脑,让每个会话都是同一个你——记忆卡、状态卡、开机卡让新会话接上同一段关系与未完成的事;配套会话卫生(热截断/备份/D2/D6 裁剪)与记忆工具(巩固/召回/程序性记忆)。1.6.4 修复记忆根可移植性(默认根 = <DSH home>/longlive-memory,一处配置贯穿全部落盘),并同步面板 0.2.5 文案与 G1-G4 修复。",
6
6
  "keywords": [
@@ -342,21 +342,31 @@ function roughPointerTokens(text) {
342
342
  }
343
343
 
344
344
  /**
345
- * Select the oldest whole completed turns that sit outside the retention
346
- * window. Returns null when no safe cut exists (the common, expected answer).
345
+ * Select the oldest whole closed turns that sit outside the retention window.
346
+ * Returns null when no safe cut exists; every such refusal reports a structured
347
+ * reason through `helpers.onNoPlan` so a silently disabled hot truncate is
348
+ * observable (the engine logs it once per reason).
347
349
  *
348
- * The host protects surface position 0 when it holds the system prompt: a
349
- * user/message replacement may not shadow that node. This engine's contract is
350
- * stronger than the host's: a `system/message` is never part of a cut. The span
351
- * therefore starts at the first user/assistant node after every leading
352
- * system node, and a system node anywhere inside a candidate span is a hard
353
- * right boundary. Checkpoint nodes at the head of the span are folded into the
354
- * same cut, which keeps exactly one pointer.
350
+ * The leading run of `system/message` / `developer/message` nodes (the real
351
+ * system prompt at host-protected position 0) is never moved. A `system/message`
352
+ * re-injected by a host restart further inside the surface is an archived copy
353
+ * of that same prompt (host-tolerated inside a replacement range). Checkpoint
354
+ * nodes at the head of the span are folded into the same cut, which keeps
355
+ * exactly one pointer.
355
356
  */
356
357
  export function selectCutPlan(session, options, measurement, helpers = {}) {
357
358
  const table = helpers.table ?? turnTable(session);
358
359
  const groups = surfaceGroups(session, table, helpers.isCheckpointSource);
359
- if (groups.length === 0) return null;
360
+ const onNoPlan = typeof helpers.onNoPlan === 'function' ? helpers.onNoPlan : null;
361
+ const noPlan = (reason, detail) => {
362
+ try {
363
+ onNoPlan?.({ reason, detail: detail === undefined ? null : detail });
364
+ } catch {
365
+ // Diagnostics never break a plan.
366
+ }
367
+ return null;
368
+ };
369
+ if (groups.length === 0) return noPlan('no-groups');
360
370
  const tokens = new Map();
361
371
  for (const node of measurement?.nodes ?? []) {
362
372
  if (node && typeof node.seq === 'number' && Number.isFinite(node.tokens)) tokens.set(node.seq, node.tokens);
@@ -370,8 +380,9 @@ export function selectCutPlan(session, options, measurement, helpers = {}) {
370
380
  };
371
381
 
372
382
  // Walk backwards until the retained suffix satisfies BOTH limits. A turn
373
- // that is not completed is never part of the retained *window*, so it also
374
- // cannot be cut; the scan still counts it as a completed-turn boundary stop.
383
+ // that is not completed counts toward the token budget but never toward the
384
+ // completed-turn floor, so the retained suffix may temporarily hold a few
385
+ // extra turns while a closed aborted turn sits inside the window.
375
386
  let keptTurns = 0;
376
387
  let keptTokens = 0;
377
388
  let boundary = -1;
@@ -385,103 +396,160 @@ export function selectCutPlan(session, options, measurement, helpers = {}) {
385
396
  break;
386
397
  }
387
398
  }
388
- if (boundary <= 0) return null;
399
+ if (boundary <= 0) return noPlan('not-enough-turns', { groups: groups.length });
389
400
 
390
401
  const surfaceNodes = session?.surface?.nodes ?? [];
391
- if (surfaceNodes.length === 0) return null;
402
+ if (surfaceNodes.length === 0) return noPlan('no-surface');
403
+
404
+ // 2026-11-06 1.6.7 hardening — no permanent barrier, no silent zero progress.
405
+ //
406
+ // 1.6.6 removed the aborted-turn freeze but treated every group without a
407
+ // usable turn record as an immovable open group and stopped the span before
408
+ // it. Three surface shapes then made the planner return null forever even
409
+ // though whole closed turns older than the retention window existed:
410
+ // * a log truncated mid-turn leaves orphan messages at the surface head
411
+ // (no turn/start) — a turn===0 group at the head (red team E1);
412
+ // * the host appends a user/message between turns — a turn===0 group in
413
+ // the middle (red team E3);
414
+ // * a turn whose turn/end was lost by log truncation — turn N has no
415
+ // endSeq but later turns exist (red team E2).
416
+ //
417
+ // Rules now:
418
+ // * the whole LEADING run of system/message + developer/message nodes is
419
+ // never moved (head instructions; host-protected position 0);
420
+ // * a turn===0 group that carries a system/developer node is a head
421
+ // instruction zone and stays in place; a turn===0 group of plain messages
422
+ // is an orphan prefix and is archived with the cut (recoverable);
423
+ // * a group whose turn has no turn/end is the CURRENT turn only when it is
424
+ // the last started turn; an earlier turn without turn/end is abandoned
425
+ // (the log was truncated inside it) and is cuttable, archived through an
426
+ // inferred range up to the next turn/start;
427
+ // * the planner skips a blocked head barrier and cuts the next run of
428
+ // wholly-cuttable groups; if nothing is cuttable it reports the reason
429
+ // through onNoPlan instead of returning null silently.
430
+ const groupStart = new Array(groups.length);
431
+ const groupEnd = new Array(groups.length);
432
+ const groupOfPosition = new Array(surfaceNodes.length);
433
+ {
434
+ let cursor = 0;
435
+ for (let index = 0; index < groups.length; index += 1) {
436
+ groupStart[index] = cursor;
437
+ for (let offset = 0; offset < groups[index].seqs.length; offset += 1) groupOfPosition[cursor++] = index;
438
+ groupEnd[index] = cursor - 1;
439
+ }
440
+ }
441
+ const typeOfSeq = (seq) => table.bySeq.get(seq)?.type;
442
+ const isInstructionSeq = (seq) => typeOfSeq(seq) === 'system/message' || typeOfSeq(seq) === 'developer/message';
443
+ const protectedSeqs = new Set();
444
+ {
445
+ let position = 0;
446
+ while (position < surfaceNodes.length && isInstructionSeq(surfaceNodes[position])) {
447
+ protectedSeqs.add(surfaceNodes[position]);
448
+ position += 1;
449
+ }
450
+ }
451
+ const lastStarted = table.turns.length > 0 ? table.turns[table.turns.length - 1] : null;
452
+ const turnInfoOf = (group) => (group.kind === 'turn' && group.turn !== 0 ? table.byNumber.get(group.turn) : undefined);
453
+ const isCurrentTurnGroup = (group) => {
454
+ if (group.kind !== 'turn' || group.turn === 0) return false;
455
+ const info = table.byNumber.get(group.turn);
456
+ if (info === undefined) return true;
457
+ return info.endSeq === null && lastStarted !== null && info.turn === lastStarted.turn;
458
+ };
459
+ const isCuttableGroup = (group) => {
460
+ if (group === undefined) return false;
461
+ if (group.kind === 'checkpoint') return true;
462
+ if (group.kind !== 'turn') return false;
463
+ // A turn===0 group is either an orphan head zone (contains instructions) or
464
+ // a stray message block between turns; only the latter may be archived.
465
+ if (group.turn === 0) return !group.seqs.some((seq) => isInstructionSeq(seq));
466
+ const info = table.byNumber.get(group.turn);
467
+ if (info === undefined) return false;
468
+ return !isCurrentTurnGroup(group);
469
+ };
470
+ // An abandoned turn (no turn/end, but a later turn/start exists) never got a
471
+ // closing event; the archive range is inferred up to the next turn/start so
472
+ // every one of its original events stays recoverable.
473
+ const resolvedEndSeq = (group) => {
474
+ const info = table.byNumber.get(group.turn);
475
+ const lastSurfaceSeq = group.seqs[group.seqs.length - 1];
476
+ if (info === undefined || !Number.isInteger(info.startSeq)) return lastSurfaceSeq;
477
+ if (Number.isInteger(info.endSeq)) return info.endSeq;
478
+ const next = table.turns.find((entry) => Number.isInteger(entry.startSeq) && entry.startSeq > info.startSeq);
479
+ if (next === undefined) return lastSurfaceSeq;
480
+ return Math.max(info.startSeq, next.startSeq - 1);
481
+ };
392
482
 
393
- // Never include a leading system prompt in the replaced span.
483
+ // The span starts after the protected head run and on a user/assistant node
484
+ // (the pointer replaces one); a system copy skipped at the head of a turn
485
+ // stays outside the span exactly like 1.6.6 — it is never moved out.
394
486
  let startIndex = 0;
395
- while (startIndex < surfaceNodes.length
396
- && table.bySeq.get(surfaceNodes[startIndex])?.type === 'system/message') {
397
- startIndex += 1;
398
- }
399
- // The pointer replaces a user/assistant message, so the first cut node must
400
- // be one. A leading developer node is kept in place instead of cut.
487
+ while (startIndex < surfaceNodes.length && protectedSeqs.has(surfaceNodes[startIndex])) startIndex += 1;
401
488
  while (startIndex < surfaceNodes.length) {
402
- const type = table.bySeq.get(surfaceNodes[startIndex])?.type;
489
+ const type = typeOfSeq(surfaceNodes[startIndex]);
403
490
  if (type === 'user/message' || type === 'assistant/message') break;
404
491
  startIndex += 1;
405
492
  }
406
- if (startIndex >= surfaceNodes.length) return null;
407
-
408
- // The cut prefix is groups[0..boundary); their surface lengths sum to the
409
- // last positional index that belongs to the prefix.
410
- let endIndex = -1;
411
- for (let index = 0; index < boundary; index += 1) endIndex += groups[index].seqs.length;
412
-
413
- // An aborted/unfinished turn is never cut — but it must not freeze every
414
- // later cut either. Keep it in place and start the span after the LAST
415
- // aborted/unfinished turn in the prefix, so the completed turns that follow
416
- // it stay cuttable. (2026-11-06 hotfix: a restart-aborted turn used to make
417
- // selectCutPlan return null forever, so automatic hot-truncate never fired
418
- // again on that session.)
419
- let skipPast = -1;
420
- for (let index = 0; index < boundary; index += 1) {
421
- const group = groups[index];
422
- if (group.kind !== 'turn' || group.turn === 0 || group.completed) continue;
423
- const lastPos = surfaceNodes.indexOf(group.seqs[group.seqs.length - 1]);
424
- if (lastPos > skipPast) skipPast = lastPos;
425
- }
426
- if (skipPast >= 0) {
427
- startIndex = Math.max(startIndex, skipPast + 1);
428
- while (startIndex < surfaceNodes.length) {
429
- const type = table.bySeq.get(surfaceNodes[startIndex])?.type;
430
- if (type === 'user/message' || type === 'assistant/message') break;
431
- startIndex += 1;
493
+ if (startIndex >= surfaceNodes.length) return noPlan('no-span-start');
494
+ const firstGroup = groupOfPosition[startIndex];
495
+
496
+ // Earliest run of consecutive cuttable groups that lies entirely before the
497
+ // retention boundary and holds at least one real closed turn. Runs made only
498
+ // of orphan messages/pointers are not worth a surface rewrite: skip them.
499
+ let chosenStart = -1;
500
+ let chosenEnd = -1;
501
+ for (let index = firstGroup; index < boundary; index += 1) {
502
+ if (!isCuttableGroup(groups[index])) continue;
503
+ let runEnd = index;
504
+ while (runEnd + 1 < boundary && isCuttableGroup(groups[runEnd + 1])) runEnd += 1;
505
+ let realTurns = 0;
506
+ for (let cursor = index; cursor <= runEnd; cursor += 1) {
507
+ if (groups[cursor].kind === 'turn' && groups[cursor].turn !== 0) realTurns += 1;
432
508
  }
509
+ if (realTurns > 0) { chosenStart = index; chosenEnd = runEnd; break; }
510
+ index = runEnd;
433
511
  }
434
- if (endIndex < startIndex) return null;
435
-
436
- // The host tolerates later system nodes inside a replacement range, but this
437
- // engine promises never to cut one — and never to leave half a turn behind.
438
- // A system node inside the candidate span excludes its whole turn, unless
439
- // that turn sits at the head of the span: then keep it in place and start the
440
- // span after it (a mid-turn system node must not freeze the plan forever).
441
- for (let guard = 0; guard < 64; guard += 1) {
442
- let headAfter = null;
443
- let cutAt = null;
444
- for (let index = startIndex; index <= endIndex; index += 1) {
445
- if (table.bySeq.get(surfaceNodes[index])?.type !== 'system/message') continue;
446
- const seq = surfaceNodes[index];
447
- const owner = groups.find((group) => group.seqs.includes(seq));
448
- const ownerStart = owner === undefined ? index : surfaceNodes.indexOf(owner.seqs[0]);
449
- const ownerLast = owner === undefined ? index : surfaceNodes.indexOf(owner.seqs[owner.seqs.length - 1]);
450
- if (ownerStart <= startIndex) { headAfter = ownerLast + 1; continue; }
451
- cutAt = ownerStart - 1;
452
- break;
453
- }
454
- if (headAfter !== null) {
455
- startIndex = headAfter;
456
- while (startIndex < surfaceNodes.length) {
457
- const type = table.bySeq.get(surfaceNodes[startIndex])?.type;
458
- if (type === 'user/message' || type === 'assistant/message') break;
459
- startIndex += 1;
460
- }
461
- if (endIndex < startIndex) return null;
462
- continue;
463
- }
464
- if (cutAt !== null) endIndex = Math.min(endIndex, cutAt);
465
- break;
512
+ if (chosenStart < 0) {
513
+ return noPlan('blocked-prefix', {
514
+ firstGroup,
515
+ boundary,
516
+ blocked: groups.slice(firstGroup, boundary).map((group) => `${group.kind}:${group.turn}:${group.completed}`),
517
+ });
466
518
  }
467
- if (endIndex < startIndex) return null;
468
-
469
- const shadowedSeqs = surfaceNodes.slice(startIndex, endIndex + 1);
519
+ let spanStart = Math.max(startIndex, groupStart[chosenStart]);
520
+ const endIndex = groupEnd[chosenEnd];
521
+ if (endIndex < spanStart) return noPlan('empty-span', { chosenStart, chosenEnd });
522
+
523
+ // A system/message node *inside* the span is a host-restart re-injection of
524
+ // the same prompt; the host tolerates it inside a replacement range and it is
525
+ // archived together with the old prefix (approved 1.6.6 behaviour).
526
+ const shadowedSeqs = surfaceNodes.slice(spanStart, endIndex + 1);
470
527
  const shadowedSet = new Set(shadowedSeqs);
471
528
  const touchedGroups = groups.filter((group) => group.seqs.some((seq) => shadowedSet.has(seq)));
472
- if (touchedGroups.some((group) => group.kind === 'turn' && group.turn !== 0 && !group.completed)) return null;
473
- const cutTurnGroups = touchedGroups.filter((group) => group.kind === 'turn' && group.turn !== 0 && group.completed);
474
- if (cutTurnGroups.length === 0) return null;
529
+ if (touchedGroups.some((group) => isCurrentTurnGroup(group))) return noPlan('current-turn-touched');
530
+ // No half turn: every touched turn group must be fully shadowed. The only
531
+ // allowed exception is a system/developer copy skipped at the head of the
532
+ // first group (it stays on the surface, never half-cut).
533
+ for (const group of touchedGroups) {
534
+ if (group.kind !== 'turn') continue;
535
+ const missing = group.seqs.filter((seq) => !shadowedSet.has(seq));
536
+ if (missing.some((seq) => !isInstructionSeq(seq))) return noPlan('split-group', { turn: group.turn });
537
+ }
538
+ const cutTurnGroups = touchedGroups.filter((group) => {
539
+ if (group.kind !== 'turn' || group.turn === 0) return false;
540
+ return !isCurrentTurnGroup(group);
541
+ });
542
+ if (cutTurnGroups.length === 0) return noPlan('no-turns', { chosenStart, chosenEnd });
475
543
 
476
- // Archive every original event of the cut turns, plus any checkpoint node
477
- // that positionally lived inside the replaced span (a previous pointer).
544
+ // Archive every original event of the cut turns, plus any checkpoint node and
545
+ // orphan (turn===0) message that positionally lived inside the replaced span.
478
546
  const archivedSeqs = new Set();
479
547
  const archiveEvents = [];
480
548
  for (const group of cutTurnGroups) {
481
- const startSeq = table.byNumber.get(group.turn)?.startSeq;
482
- const endSeq = table.byNumber.get(group.turn)?.endSeq;
483
- if (!Number.isInteger(startSeq) || !Number.isInteger(endSeq)) continue;
484
- for (let seq = startSeq; seq <= endSeq; seq += 1) {
549
+ const info = table.byNumber.get(group.turn);
550
+ if (info === undefined || !Number.isInteger(info.startSeq)) continue;
551
+ const endSeq = resolvedEndSeq(group);
552
+ for (let seq = info.startSeq; seq <= endSeq; seq += 1) {
485
553
  const event = table.bySeq.get(seq);
486
554
  if (event === undefined || archivedSeqs.has(seq)) continue;
487
555
  archivedSeqs.add(seq);
@@ -505,13 +573,13 @@ export function selectCutPlan(session, options, measurement, helpers = {}) {
505
573
  firstSeq: seqs[0],
506
574
  lastSeq: seqs[seqs.length - 1],
507
575
  startSeq: info?.startSeq ?? seqs[0],
508
- endSeq: info?.endSeq ?? seqs[seqs.length - 1],
576
+ endSeq: resolvedEndSeq(group),
509
577
  surfaceEvents: seqs.length,
510
578
  tokens: seqs.reduce((total, seq) => total + prices.get(seq), 0),
511
579
  };
512
580
  });
513
581
  const shadowedTokenCount = shadowedSeqs.reduce((total, seq) => total + prices.get(seq), 0);
514
- if (shadowedTokenCount <= 0) return null;
582
+ if (shadowedTokenCount <= 0) return noPlan('zero-token');
515
583
 
516
584
  const lastTouchedIndex = groups.indexOf(touchedGroups[touchedGroups.length - 1]);
517
585
  const retainedGroups = groups.slice(lastTouchedIndex + 1);
@@ -538,7 +606,9 @@ export function selectCutPlan(session, options, measurement, helpers = {}) {
538
606
  retainedTurns,
539
607
  });
540
608
  const pointerTokens = roughPointerTokens(previewText);
541
- if (shadowedTokenCount < pointerTokens + options.minCutTokens) return null;
609
+ if (shadowedTokenCount < pointerTokens + options.minCutTokens) {
610
+ return noPlan('pointer-too-large', { shadowedTokenCount, pointerTokens });
611
+ }
542
612
 
543
613
  return {
544
614
  start: shadowedSeqs[0],
@@ -681,7 +751,7 @@ export function buildPointerText(facts) {
681
751
  const range = firstTurn === lastTurn ? `第 ${firstTurn} 轮` : `第 ${firstTurn}–${lastTurn} 轮`;
682
752
  const lines = [
683
753
  `本会话共 ${totalTurns} 轮,其中 ${archivedTurns} 轮已压缩归档;需要原文用 session_original(turn=…) 或读归档路径:${archivePath}`,
684
- `本次热截断切掉${range}(${cutTurnCount} 轮 / ${shadowedTokenCount} tokens / ${surfaceEventCount} 个表面事件);只切已完成且早于保留窗口的轮,切点之后的原文未动。`,
754
+ `本次热截断切掉${range}(${cutTurnCount} 轮 / ${shadowedTokenCount} tokens / ${surfaceEventCount} 个表面事件);只切已结束且早于保留窗口的轮(含被中止/中断的旧轮),当前未完成的轮不切,切点之后的原文未动。`,
685
755
  `归档:${eventCount} 条原始事件(JSONL,每行含 seq/turn/原始消息)` + (sha256 ? `,sha256=${sha256.slice(0, 16)}…` : '') + '。',
686
756
  `保留窗口:最近 ${retainTurns} 个已完成轮、至少 ${retainTokens} tokens(本次保留 ${retainedTurns} 轮)。`,
687
757
  ];
@@ -864,6 +934,14 @@ export function createCutEngine(deps) {
864
934
  return selectCutPlan(session, this.options, measurement, {
865
935
  table,
866
936
  isCheckpointSource: isCompactCheckpointSource,
937
+ // "cut, or say why not": a refusal that would otherwise be silent is
938
+ // logged once per reason (warnOnce) so a permanently disabled hot
939
+ // truncate is always observable in the host log.
940
+ onNoPlan: (info) => {
941
+ const reason = String(info?.reason ?? 'unknown');
942
+ const detail = info?.detail === undefined || info?.detail === null ? '' : ` ${JSON.stringify(info.detail)}`;
943
+ this.warnOnce(`no-plan:${reason}`, `hot-truncate: 未切(${reason})${detail}`);
944
+ },
867
945
  });
868
946
  }
869
947
 
@@ -11,7 +11,8 @@
11
11
  // mode=off, live-compact deference and no-op degradation.
12
12
  //
13
13
  // What is pinned here:
14
- // * an open / aborted turn is never cut;
14
+ // * an open turn is never cut; a closed aborted turn is cut whole with the
15
+ // old prefix (1.6.6: it used to be a barrier that pinned everything);
15
16
  // * only turns older than retainTurns AND retainTokens are cut;
16
17
  // * the cut point and later surface text are untouched (only a prefix shrinks);
17
18
  // * the archive contains every original event of the cut turns with a
@@ -395,34 +396,42 @@ test('H2 retainTurns and retainTokens both gate the cut', async () => {
395
396
  assert.equal(tokenDominant.surface.nodes.length, 1 + 10);
396
397
  });
397
398
 
398
- test('H3 aborted turns never freeze later cuts (and are never cut themselves)', async () => {
399
+ test('H3 closed aborted turns are cut with the old prefix and never freeze later cuts', async () => {
399
400
  const session = new MockSession();
400
401
  for (let turn = 1; turn <= 3; turn += 1) addTurn(session, turn, { completed: false });
401
402
  for (let turn = 4; turn <= 9; turn += 1) addTurn(session, turn, { completed: true });
402
403
  addTurn(session, 10, { open: true });
403
- // 2026-11-06 hotfix: a restart-aborted prefix used to make the plan null
404
- // forever. The fixed planner skips past the aborted turns and still cuts the
405
- // completed turns that follow (retain window keeps the last 4 completed).
406
- const { engine } = engineFor({ archiveDir: caseDir('archive'), perEvent: 5000, retainTurns: 4, retainTokens: 0, autoMinTokens: 0 });
404
+ // 1.6.6 hotfix: 1.6.5 still skipped past aborted turns, so when aborted turns
405
+ // recur near the tail the span collapsed to "the one completed turn after the
406
+ // last aborted turn" (the 396k-token bug). A turn with turn/end is closed and
407
+ // cuttable as a whole, so the prefix is removed in one plan.
408
+ const dir = caseDir('archive');
409
+ const { engine } = engineFor({ archiveDir: dir, perEvent: 5000, retainTurns: 4, retainTokens: 0, autoMinTokens: 0 });
407
410
  const result = await engine.compactIfNeeded(makeAgent(session), 'pressure', new AbortController().signal);
408
- assert.ok(result, 'completed turns after the aborted prefix are still cuttable');
409
- assert.equal(result.shadowedSeqs.length, 4, 'only turns 4 and 5 (2 surface nodes each) are cut');
411
+ assert.ok(result, 'aborted turns must not block the prefix cut');
412
+ assert.equal(result.shadowedSeqs.length, 10, 'turns 1-5 (2 surface nodes each) are cut');
413
+ const archive = readArchive(path.join(dir, fs.readdirSync(dir).find((name) => name.endsWith('.jsonl'))));
414
+ assert.deepEqual(archive.header.cutTurns.map((entry) => entry.turn), [1, 2, 3, 4, 5]);
410
415
  assert.equal(pointerEvents(session).length, 1);
416
+ // Only the four retained completed turns and the open turn stay.
417
+ assert.equal(session.surface.nodes.length, 1 + 8 + 2);
411
418
  const summary = pointerEvents(session)[0];
412
419
  const pointer = session.events[summary.seq + 1];
413
- assert.match(pointer.data.content[0].text, /第 4–5 轮/);
420
+ assert.match(pointer.data.content[0].text, /第 1–5 轮/);
414
421
  });
415
422
 
416
423
  // ---------------------------------------------------------------------------
417
424
  // Archive
418
425
  // ---------------------------------------------------------------------------
419
426
 
420
- test('H3b mid-turn system node at the head of the span does not freeze later cuts', async () => {
421
- // 2026-11-06 second hotfix: the candidate span can start at a turn whose
422
- // first surface node is an assistant message and whose later node is a
423
- // system/message (host-injected). The old system-node guard trimmed the
424
- // span to nothing; the fixed planner keeps that head turn in place and
425
- // starts the span after it.
427
+ test('H3b a mid-turn system node is archived with its old prefix instead of freezing the plan', async () => {
428
+ // 1.6.6: 1.6.5 kept the whole turn containing a host-injected system node in
429
+ // place and started the span after it. When such injections land inside
430
+ // several completed turns (every host restart re-emits the prompt mid-turn),
431
+ // that rule pinned ≈100k tokens permanently: real session 2026-11-06 turns
432
+ // 66/72/75. The protected copy is the leading one at surface 0 (see H28); a
433
+ // later injected copy is host-tolerated inside a replacement range, so it is
434
+ // archived with the prefix it belongs to.
426
435
  const session = new MockSession();
427
436
  for (let turn = 1; turn <= 3; turn += 1) addTurn(session, turn, { completed: false });
428
437
  session.append('turn/start', { turn: 4 });
@@ -455,13 +464,212 @@ test('H3b mid-turn system node at the head of the span does not freeze later cut
455
464
  session.append('turn/end', { turn: 4, reason: { kind: 'completed' } });
456
465
  for (let turn = 5; turn <= 10; turn += 1) addTurn(session, turn, { completed: true });
457
466
  addTurn(session, 11, { open: true });
458
- const { engine } = engineFor({ archiveDir: caseDir('archive'), perEvent: 5000, retainTurns: 4, retainTokens: 0, autoMinTokens: 0 });
467
+ const dir = caseDir('archive');
468
+ const { engine } = engineFor({ archiveDir: dir, perEvent: 5000, retainTurns: 4, retainTokens: 0, autoMinTokens: 0 });
459
469
  const result = await engine.compactIfNeeded(makeAgent(session), 'pressure', new AbortController().signal);
460
- assert.ok(result, 'a mid-turn system node at the span head must not freeze the plan');
461
- assert.equal(result.shadowedSeqs.length, 4, 'turns 5 and 6 (2 surface nodes each) are cut');
470
+ assert.ok(result, 'a mid-turn system node must not freeze the prefix cut');
471
+ assert.equal(result.shadowedSeqs.length, 13, 'turns 1-6 (2+2+2+3+2+2 surface nodes) are cut');
472
+ const archive = readArchive(path.join(dir, fs.readdirSync(dir).find((name) => name.endsWith('.jsonl'))));
473
+ assert.deepEqual(archive.header.cutTurns.map((entry) => entry.turn), [1, 2, 3, 4, 5, 6]);
474
+ assert.equal(session.surface.nodes.length, 1 + 8 + 2, 'pointer + four retained turns + open turn');
462
475
  const summary = pointerEvents(session)[0];
463
476
  const pointer = session.events[summary.seq + 1];
464
- assert.match(pointer.data.content[0].text, /第 5–6 轮/);
477
+ assert.match(pointer.data.content[0].text, /第 1–6 轮/);
478
+ });
479
+
480
+ test('H30 a mid-surface aborted turn does not collapse the plan to one turn (396k regression)', async () => {
481
+ // Real session 2026-11-06 shape: the log has many completed turns, one
482
+ // restart-aborted turn near the tail, and the retention boundary right after
483
+ // it. 1.6.5 moved the span past the aborted turn, so the cut became "one
484
+ // completed turn / 6-18k tokens" while the whole older prefix stayed. The
485
+ // new planner must cut the entire prefix, aborted turn included, so that
486
+ // only the four retained completed turns and the open turn are left.
487
+ const session = new MockSession();
488
+ for (let turn = 1; turn <= 10; turn += 1) addTurn(session, turn, { completed: true });
489
+ addTurn(session, 11, { completed: false }); // aborted, closed
490
+ for (let turn = 12; turn <= 20; turn += 1) addTurn(session, turn, { completed: true });
491
+ addTurn(session, 21, { open: true });
492
+ const dir = caseDir('archive');
493
+ const { engine } = engineFor({ archiveDir: dir, perEvent: 5000, retainTurns: 4, retainTokens: 0, autoMinTokens: 0 });
494
+ const result = await engine.compactIfNeeded(makeAgent(session), 'pressure', new AbortController().signal);
495
+ assert.ok(result);
496
+ assert.equal(result.shadowedSeqs.length, 32, '16 turns x 2 surface nodes, aborted turn included');
497
+ const archive = readArchive(path.join(dir, fs.readdirSync(dir).find((name) => name.endsWith('.jsonl'))));
498
+ assert.deepEqual(archive.header.cutTurns.map((entry) => entry.turn), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
499
+ // pointer + turns 17-20 (8 nodes) + open turn 21 (2 nodes)
500
+ assert.equal(session.surface.nodes.length, 1 + 8 + 2);
501
+ const abortedUser = session.events.find((event) => event.type === 'user/message' && event.data.id === 'user-11');
502
+ assert.equal(session.surface.nodes.includes(abortedUser.seq), false, 'the aborted turn left the surface');
503
+ const openUser = session.events.find((event) => event.type === 'user/message' && event.data.id === 'user-21');
504
+ assert.equal(session.surface.nodes.includes(openUser.seq), true, 'the open turn is never cut');
505
+ assert.match(result.summary[0].text, /第 1–16 轮/);
506
+ });
507
+
508
+ test('H30b real-session shape: head system + mid-surface injected system + aborted turns', async () => {
509
+ // Mirrors session-e219dedf on 2026-11-06: the real system prompt sits at
510
+ // surface 0 and must stay; a restart injects a second system/message in the
511
+ // middle of the surface (inside turn 12); several turns are aborted; the
512
+ // current turn is open. A single cut must remove the whole old prefix and
513
+ // leave exactly the four retained completed turns plus the open turn.
514
+ const session = new MockSession();
515
+ addTurnWithSystemHead(session, 1);
516
+ for (let turn = 2; turn <= 20; turn += 1) {
517
+ if (turn === 12) {
518
+ session.append('turn/start', { turn: 12 });
519
+ session.append('user/message', {
520
+ id: 'user-12', role: 'user', content: [{ type: 'text', text: '第 12 轮用户消息' }],
521
+ source: { kind: 'user', clientTimeZone: 'Asia/Shanghai' },
522
+ }, { surfaceOp: 'append' });
523
+ session.append('assistant/message', {
524
+ turn: 12, step: 1,
525
+ message: {
526
+ id: 'assistant-12', role: 'assistant',
527
+ source: { kind: 'model', provider: 'test', model: 'test' },
528
+ content: [{ type: 'text', text: '第 12 轮回答' }],
529
+ },
530
+ }, { surfaceOp: 'append' });
531
+ session.append('system/message', {
532
+ turn: 12, step: 2,
533
+ message: {
534
+ id: 'system-12', role: 'system', source: { kind: 'system-prompt' },
535
+ content: [{ type: 'text', text: 'You are an AI agent powered by DeepSeek Harness.' }],
536
+ },
537
+ }, { surfaceOp: 'append' });
538
+ session.append('turn/end', { turn: 12, reason: { kind: 'completed' } });
539
+ continue;
540
+ }
541
+ if (turn === 8 || turn === 15) addTurn(session, turn, { completed: false });
542
+ else addTurn(session, turn, { completed: true });
543
+ }
544
+ addTurn(session, 21, { open: true });
545
+ const systemEvent = session._events.find((event) => event.type === 'system/message');
546
+ const injected = session._events.filter((event) => event.type === 'system/message')[1];
547
+ assert.ok(systemEvent && injected);
548
+ const dir = caseDir('archive');
549
+ const { engine } = engineFor({ archiveDir: dir, perEvent: 5000, retainTurns: 4, retainTokens: 0, autoMinTokens: 0 });
550
+ const result = await engine.compactIfNeeded(makeAgent(session), 'pressure', new AbortController().signal);
551
+ assert.ok(result, 'the 396k shape must produce a plan');
552
+ const archive = readArchive(path.join(dir, fs.readdirSync(dir).find((name) => name.endsWith('.jsonl'))));
553
+ assert.deepEqual(archive.header.cutTurns.map((entry) => entry.turn), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
554
+ assert.equal(result.shadowedSeqs.includes(systemEvent.seq), false, 'the head system prompt is never cut');
555
+ assert.equal(result.shadowedSeqs.includes(injected.seq), true, 'the injected copy goes with the old prefix');
556
+ assert.equal(session.surface.nodes[0], systemEvent.seq, 'head system prompt stays at surface 0');
557
+ // head system + pointer + four retained turns (8 nodes) + open turn (2 nodes)
558
+ assert.equal(session.surface.nodes.length, 1 + 1 + 8 + 2);
559
+ assert.equal(session.surface.nodes.includes(injected.seq), false, 'injected copy left the surface');
560
+ });
561
+
562
+ // ---------------------------------------------------------------------------
563
+ // 1.6.7 hardening: barriers must never disable hot truncate silently
564
+ // ---------------------------------------------------------------------------
565
+
566
+ /** Append a message-shaped event that belongs to no turn (no turn/start). */
567
+ function orphanMessage(session, type, id, text) {
568
+ const data = type === 'assistant/message'
569
+ ? { message: { id, role: 'assistant', content: [{ type: 'text', text }] } }
570
+ : { role: type === 'system/message' ? 'system' : 'user', id, content: [{ type: 'text', text }] };
571
+ return session.append(type, data, { surfaceOp: 'append' });
572
+ }
573
+
574
+ test('H35 head truncated mid-turn (orphan messages before the system prompt) still cuts', async () => {
575
+ // Red team E1: raw log truncation drops the head turn/start, leaving orphan
576
+ // user/assistant surface nodes before the re-appended system prompt. 1.6.6
577
+ // treated the turn===0 group as open => plan null forever. Now the orphan
578
+ // block/head instructions are kept in place and the closed turns after them
579
+ // are cut (red team E1: null -> [1,2]).
580
+ const session = new MockSession();
581
+ const orphanU = orphanMessage(session, 'user/message', 'orphan-u', 'orphan-mid-turn');
582
+ orphanMessage(session, 'assistant/message', 'orphan-a', 'orphan answer');
583
+ const headSystem = orphanMessage(session, 'system/message', 'head-sys', 'HEAD-SYSTEM');
584
+ for (let turn = 1; turn <= 7; turn += 1) {
585
+ if (turn === 7) addTurn(session, turn, { open: true });
586
+ else addTurn(session, turn, { completed: true });
587
+ }
588
+ const dir = caseDir('archive');
589
+ const { engine } = engineFor({ archiveDir: dir, perEvent: 5000, retainTurns: 4, retainTokens: 0, autoMinTokens: 0 });
590
+ const result = await engine.compactIfNeeded(makeAgent(session), 'pressure', new AbortController().signal);
591
+ assert.ok(result, 'the head barrier must not disable the planner (E1 was null before)');
592
+ const file = fs.readdirSync(dir).find((name) => name.endsWith('.jsonl'));
593
+ const parsed = readArchive(path.join(dir, file));
594
+ assert.deepEqual(parsed.header.cutTurns.map((entry) => entry.turn), [1, 2]);
595
+ assert.equal(result.shadowedSeqs.includes(headSystem.seq), false, 'head system instruction is never cut');
596
+ assert.equal(session.surface.nodes.includes(headSystem.seq), true, 'head system instruction stays on the surface');
597
+ assert.equal(session.surface.nodes.includes(orphanU.seq), true, 'orphan head block stays in place');
598
+ });
599
+
600
+ test('H36 a stray user/message between turns does not pin the prefix (red team E3)', async () => {
601
+ const session = new MockSession();
602
+ const headSystem = orphanMessage(session, 'system/message', 'head-sys', 'HEAD');
603
+ addTurn(session, 1, { completed: true });
604
+ const stray = session.append('user/message', {
605
+ id: 'between-turns', role: 'user', content: [{ type: 'text', text: 'between-turns' }],
606
+ }, { surfaceOp: 'append' });
607
+ for (let turn = 2; turn <= 7; turn += 1) {
608
+ if (turn === 7) addTurn(session, turn, { open: true });
609
+ else addTurn(session, turn, { completed: true });
610
+ }
611
+ const dir = caseDir('archive');
612
+ const { engine } = engineFor({ archiveDir: dir, perEvent: 5000, retainTurns: 4, retainTokens: 0, autoMinTokens: 0 });
613
+ const result = await engine.compactIfNeeded(makeAgent(session), 'pressure', new AbortController().signal);
614
+ assert.ok(result, 'the stray turn-0 group must not freeze the cut (E3 cut only [1] before)');
615
+ const file = fs.readdirSync(dir).find((name) => name.endsWith('.jsonl'));
616
+ const parsed = readArchive(path.join(dir, file));
617
+ assert.deepEqual(parsed.header.cutTurns.map((entry) => entry.turn), [1, 2]);
618
+ assert.equal(result.shadowedSeqs.includes(stray.seq), true, 'the stray message is inside the cut span');
619
+ assert.equal(session.surface.nodes.includes(stray.seq), false, 'the stray message left the surface');
620
+ assert.equal(session.surface.nodes.includes(headSystem.seq), true, 'the head system instruction stays');
621
+ assert.equal(parsed.records.some((record) => record.seq === stray.seq), true, 'the stray message is recoverable from the archive');
622
+ });
623
+
624
+ test('H37 a mid-log turn missing turn/end does not block later cuts (red team E2)', async () => {
625
+ const session = new MockSession();
626
+ orphanMessage(session, 'system/message', 'head-sys', 'HEAD');
627
+ addTurn(session, 1, { completed: true });
628
+ const abandonedStart = session.events.length;
629
+ addTurn(session, 2, { open: true }); // turn/end lost by log truncation
630
+ const abandonedEnd = session.events.length - 1;
631
+ for (let turn = 3; turn <= 6; turn += 1) addTurn(session, turn, { completed: true });
632
+ addTurn(session, 7, { open: true }); // the real current turn
633
+ const currentUser = session.events.find((event) => event.type === 'user/message' && event.data.id === 'user-7');
634
+ const dir = caseDir('archive');
635
+ const { engine } = engineFor({ archiveDir: dir, perEvent: 5000, retainTurns: 4, retainTokens: 0, autoMinTokens: 0 });
636
+ const result = await engine.compactIfNeeded(makeAgent(session), 'pressure', new AbortController().signal);
637
+ assert.ok(result, 'an abandoned mid-log unclosed turn must not freeze the planner');
638
+ const file = fs.readdirSync(dir).find((name) => name.endsWith('.jsonl'));
639
+ const parsed = readArchive(path.join(dir, file));
640
+ assert.deepEqual(parsed.header.cutTurns.map((entry) => entry.turn), [1, 2], 'turns before the retention window are cut');
641
+ const abandoned = parsed.header.cutTurns.find((entry) => entry.turn === 2);
642
+ assert.ok(abandoned.endSeq > abandoned.startSeq, 'abandoned turn archive range is inferred, not empty');
643
+ assert.equal(parsed.records.some((record) => record.turn === 2), true, 'abandoned turn events are recoverable');
644
+ assert.equal(session.surface.nodes.includes(currentUser.seq), true, 'the current open turn is never cut');
645
+ assert.equal(session.surface.nodes.some((seq) => seq >= abandonedStart && seq <= abandonedEnd), false, 'the abandoned turn left the surface');
646
+ assert.equal(result.shadowedSeqs.includes(currentUser.seq), false);
647
+ });
648
+
649
+ test('H38 "cut or say why": a blocked prefix logs a structured refusal instead of silence', async () => {
650
+ // Normal case: not enough completed turns yet.
651
+ const small = new MockSession();
652
+ addTurn(small, 1, { completed: true });
653
+ addTurn(small, 2, { completed: false });
654
+ addTurn(small, 3, { open: true });
655
+ const { engine, ctx } = engineFor({ archiveDir: caseDir('archive'), perEvent: 5000, retainTurns: 4, retainTokens: 0, autoMinTokens: 0 });
656
+ const none = await engine.compactIfNeeded(makeAgent(small), 'pressure', new AbortController().signal);
657
+ assert.equal(none, null);
658
+ assert.match(ctx.text('warn'), /未切(not-enough-turns)/);
659
+
660
+ // Blocked prefix: the only groups older than the boundary are a turn===0
661
+ // system zone, so there is nothing whole to cut. The reason must be visible.
662
+ const blocked = new MockSession();
663
+ orphanMessage(blocked, 'system/message', 'head-sys', 'HEAD');
664
+ for (let turn = 1; turn <= 5; turn += 1) {
665
+ if (turn === 5) addTurn(blocked, turn, { open: true });
666
+ else addTurn(blocked, turn, { completed: true });
667
+ }
668
+ const second = engineFor({ archiveDir: caseDir('archive'), perEvent: 5000, retainTurns: 4, retainTokens: 0, autoMinTokens: 0 });
669
+ const blockedResult = await second.engine.compactIfNeeded(makeAgent(blocked), 'pressure', new AbortController().signal);
670
+ assert.equal(blockedResult, null, 'nothing cuttable yet');
671
+ assert.match(second.ctx.text('warn'), /未切(blocked-prefix)/);
672
+ assert.match(second.ctx.text('warn'), /head-sys|blocked-prefix/);
465
673
  });
466
674
 
467
675
  test('H4 archive: atomic JSONL, exact event count, sha256, append-only', async () => {
@@ -525,7 +733,7 @@ test('H5 pointer text carries N/M, session_original(turn=…), and the archive p
525
733
  const text = result.summary[0].text;
526
734
  assert.match(text, /本会话共 9 轮,其中 3 轮已压缩归档;需要原文用 session_original\(turn=…\) 或读归档路径:/);
527
735
  assert.ok(text.includes(dir), 'archive directory is in the pointer');
528
- assert.match(text, /只切已完成且早于保留窗口的轮/);
736
+ assert.match(text, /只切已结束且早于保留窗口的轮/);
529
737
  assert.match(text, /保留窗口:最近 6 个已完成轮、至少 0 tokens(本次保留 6 轮)/);
530
738
 
531
739
  const pointerEvent = session.events[result.summarySeq + 1];
@@ -586,7 +794,12 @@ test('H28 a system prompt at surface 0 is never cut; the pointer lands at positi
586
794
  assert.equal(parsed.header.cutTurns[0].startSeq, 0, 'the archive still covers the whole turn');
587
795
  });
588
796
 
589
- test('H29 a system message inside the candidate span stops the cut before its turn', async () => {
797
+ test('H29 a mid-span host-injected system message is archived with the old prefix', async () => {
798
+ // 1.6.6: the head system prompt is still never cut (H28). A host-injected
799
+ // system/message in the middle of the surface is a duplicate re-emitted by a
800
+ // restart; the host tolerates it inside a replacement range, and keeping it
801
+ // outside pinned every following turn forever. It is archived with the old
802
+ // prefix instead of stopping the cut before its turn.
590
803
  const session = new MockSession();
591
804
  addTurn(session, 1);
592
805
  addTurnWithSystemInside(session, 2);
@@ -596,12 +809,12 @@ test('H29 a system message inside the candidate span stops the cut before its tu
596
809
  const dir = caseDir('archive');
597
810
  const { engine } = engineFor({ archiveDir: dir });
598
811
  const result = await engine.compactNow(makeAgent(session), new AbortController().signal);
599
- assert.ok(result, 'the safe prefix before the system message is still cuttable');
600
- assert.equal(result.shadowedSeqs.includes(systems[0].seq), false);
812
+ assert.ok(result, 'the old prefix is cuttable');
813
+ assert.equal(result.shadowedSeqs.includes(systems[0].seq), true, 'the injected copy is archived with the prefix');
601
814
  const file = fs.readdirSync(dir).find((name) => name.endsWith('.jsonl'));
602
815
  const parsed = readArchive(path.join(dir, file));
603
- assert.deepEqual(parsed.header.cutTurns.map((entry) => entry.turn), [1], 'only turn 1 is cut');
604
- assert.equal(session.surface.nodes.includes(systems[0].seq), true, 'the system node remains on the surface');
816
+ assert.deepEqual(parsed.header.cutTurns.map((entry) => entry.turn), [1, 2], 'turn 1 and the injected turn 2 are cut');
817
+ assert.equal(session.surface.nodes.includes(systems[0].seq), false, 'the injected copy left the surface with its turn');
605
818
  });
606
819
 
607
820
  test('H6 successive cuts update the cumulative archived count and fold the old pointer', async () => {