@jjh32456/dsh-longlive 1.6.4 → 1.6.6

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,8 +1,26 @@
1
+ ## 1.6.6
2
+
3
+ - 热截断修复:去掉 skipPast 永久钉死(aborted/interrupted 轮只要有 turn/end 就整轮可切);段首 system/message 永不进切段,中途注入的 system 副本随旧前缀归档。修复后真实会话一次可切 35 个 closed 轮(约 46 万 tokens),上下文回到最近 4 轮量级。
4
+
1
5
  # 变更记录
2
6
 
3
7
  本文件从 0.7.6 开始记录;更早的版本没有随包维护变更日志(0.7.5 及以前的 tarball 里没有
4
8
  CHANGELOG)。
5
9
 
10
+ ## 1.6.5 — 2026-11-06
11
+
12
+ **热截断热修复:被宿主重启中断过的会话不再永久失去自动切刀。**
13
+
14
+ 根因:`selectCutPlan` 把「closed-but-not-completed」(有 `turn/end`,但 reason 为
15
+ aborted / interrupted / truncated)的轮次当成不可跨越的屏障。一旦这类轮次落在保留窗口
16
+ 之前,计划会一直返回 null,自动热截断从此静默失效——实测上下文涨到 50 万 tokens 也不再
17
+ 切一刀(1.6.4 的 300k 闸门本身没问题,是计划永远为空)。
18
+
19
+ 1.6.5 改为:这类轮次继续**保留不切**,但计划从它**之后**继续找连续的已完成轮,不再返回
20
+ null;另外修了候选段**首轮内部的 system/message**(宿主中途注入)把段截成空的问题——现在
21
+ 该轮原地保留、段从它之后开始。新增 H3 / H3b 回归测试覆盖这两个场景。
22
+ `node test/run.mjs` / `node test/run-rc.mjs` 全绿。
23
+
6
24
  ## 1.6.4 — 2026-11-06
7
25
 
8
26
  **热截断参数收尾 + D6 重启兼容 + 可移植性收口。** 1.6.3-rc.2 的默认窗口在长对话里几乎
package/QUICKSTART.md CHANGED
@@ -1,4 +1,4 @@
1
- # QUICKSTART — 五步跑通(1.6.4)
1
+ # QUICKSTART — 五步跑通(1.6.5)
2
2
 
3
3
  目标:在一台新机器上,5 步内让 `@jjh32456/dsh-longlive` 装好、能用、能回滚、能卸干净。
4
4
  完整背景见 [README.md](README.md),逐码排错见 [docs/ERRORS.md](docs/ERRORS.md),
@@ -42,7 +42,7 @@ $DSH = '<DSH 安装目录>\resources\runtime\cli\bin\dsh.cmd'
42
42
  先把包放到固定目录,核对 sha256,再装进一个**新 profile**:
43
43
 
44
44
  ```powershell
45
- $TGZ = 'C:\path\to\jjh32456-dsh-longlive-1.6.4.tgz'
45
+ $TGZ = 'C:\path\to\jjh32456-dsh-longlive-1.6.5.tgz'
46
46
  Get-FileHash -Algorithm SHA256 $TGZ # 对照同目录的 .tgz.sha256
47
47
 
48
48
  $LLPROFILE = 'fresh0723b'
@@ -54,7 +54,7 @@ $LLPROFILE = 'fresh0723b'
54
54
  & $DSH plugin --profile $LLPROFILE list --depth 0
55
55
  ```
56
56
 
57
- `list` 应出现 `@jjh32456/dsh-longlive@1.6.4`。`dsh plugin --profile <新名字> add` 自己也会初始化
57
+ `list` 应出现 `@jjh32456/dsh-longlive@1.6.5`。`dsh plugin --profile <新名字> add` 自己也会初始化
58
58
  profile,但那样只带 `@deepseek-ai/dsh-base`;要网页 UI 和与本文一致的验证,先用
59
59
  `--from-default-profile web` 建模板 profile。
60
60
 
@@ -67,8 +67,8 @@ profile,但那样只带 `@deepseek-ai/dsh-base`;要网页 UI 和与本文一
67
67
  ```powershell
68
68
  Set-Location <仓库根目录>
69
69
  & $NODE test\run.mjs
70
- & $NODE scripts\pack.mjs --expect-version 1.6.4
71
- # 产物:dist\jjh32456-dsh-longlive-1.6.4.tgz(+ .sha256)
70
+ & $NODE scripts\pack.mjs --expect-version 1.6.5
71
+ # 产物:dist\jjh32456-dsh-longlive-1.6.5.tgz(+ .sha256)
72
72
  ```
73
73
 
74
74
  `pack.mjs` 只负责打包,不跑测试;`--expect-version` 会在版本不匹配时拒绝出包。
@@ -79,7 +79,7 @@ Set-Location <仓库根目录>
79
79
  宿主正在跑、且当前 profile 就是目标 profile 时:
80
80
 
81
81
  ```
82
- plugin_manager action=install_bundle target="file:C:/path/to/jjh32456-dsh-longlive-1.6.4.tgz"
82
+ plugin_manager action=install_bundle target="file:C:/path/to/jjh32456-dsh-longlive-1.6.5.tgz"
83
83
  ```
84
84
 
85
85
  目标必须是**绝对路径**,且需要上面说的权限。装完按宿主提示重载(或重启该 profile)。
package/README.en.md CHANGED
@@ -1,4 +1,4 @@
1
- # @jjh32456/dsh-longlive (Longlive)
1
+ # @jjh32456/dsh-longlive (Longlive)
2
2
 
3
3
  A DeepSeek Harness plugin that keeps a long session healthy: **inspect it, back it up, and shrink it safely** before the context window fills up. No standalone UI; no network requests or telemetry in the default configuration (Router / RouterFace / embedding call the provider you configure, only after you enable them).
4
4
  ## What you get
@@ -13,7 +13,7 @@ A DeepSeek Harness plugin that keeps a long session healthy: **inspect it, back
13
13
  ```powershell
14
14
  $DSH = '<DSH install>\resources\runtime\cli\bin\dsh.cmd'
15
15
  $NODE = '<DSH install>\resources\runtime\primary-runtime\dependencies\node\bin\node.exe'
16
- & $DSH plugin --profile desktop add "file:C:/path/jjh32456-dsh-longlive-1.6.4.tgz"
16
+ & $DSH plugin --profile desktop add "file:C:/path/jjh32456-dsh-longlive-1.6.5.tgz"
17
17
  & $DSH plugin --profile desktop list --depth 0
18
18
  & $NODE "$env:USERPROFILE\.dsh\profiles\desktop\node_modules\@jjh32456\dsh-longlive\scripts\longlive-doctor.mjs" --profile desktop
19
19
  ```
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # @jjh32456/dsh-longlive(久视)
1
+ # @jjh32456/dsh-longlive(久视)
2
2
 
3
3
  > 1.6.0 起包名由 `dsh-session-longlive` 改为 `dsh-longlive`;1.6.4 起发布名改为 scoped 包
4
4
  > `@jjh32456/dsh-longlive`(npm 包名),显示名仍是「久视」(英文 Longlive);
@@ -81,7 +81,7 @@ $NODE = '<DSH 安装目录>\resources\runtime\primary-runtime\dependencies\node\
81
81
 
82
82
  ```powershell
83
83
  # 1. 安装本地 tgz(用 plugin add,不要用 npm install)
84
- & $DSH plugin --profile desktop add "file:C:/path/jjh32456-dsh-longlive-1.6.4.tgz"
84
+ & $DSH plugin --profile desktop add "file:C:/path/jjh32456-dsh-longlive-1.6.5.tgz"
85
85
 
86
86
  # 2. 确认版本
87
87
  & $DSH plugin --profile desktop list --depth 0
@@ -129,7 +129,7 @@ $NODE = '<DSH 安装目录>\resources\runtime\primary-runtime\dependencies\node\
129
129
 
130
130
  ## 版本与边界
131
131
 
132
- - 本套文档对应 **1.6.4** 的本地 tgz(1.6.0 起包名由 `dsh-session-longlive` 改为 `dsh-longlive`;
132
+ - 本套文档对应 **1.6.5** 的本地 tgz(1.6.0 起包名由 `dsh-session-longlive` 改为 `dsh-longlive`;
133
133
  1.6.4 起发布名改为 scoped 包 `@jjh32456/dsh-longlive`)。
134
134
  **1.6.3 起新增热截断**:一个不调 LLM 的 cut-only 压缩引擎,通过 bundle patch 关掉宿主 stock
135
135
  `compaction-basic` 行并插入 `compaction-hot-truncate`;只切「已完成、且早于保留窗口(默认 4 轮 /
package/SPEC.md CHANGED
@@ -1,11 +1,11 @@
1
- # 插件规格:@jjh32456/dsh-longlive(久视)
1
+ # 插件规格:@jjh32456/dsh-longlive(久视)
2
2
 
3
3
  > 命名(2026-11-06 改):发布包名 **@jjh32456/dsh-longlive**(1.6.4 起 scoped 发布名;
4
4
  > 1.6.0–1.6.3 为 `dsh-longlive`),显示名「久视」(英文 Longlive)。原名
5
5
  > `dsh-session-longlive`(2026-10-30 定),1.6.0 起迁移;拼写是 longlive,不是 longevity。
6
6
  >
7
- > 当前版本:**1.6.4**(1.6.0 改名基座 + 1.6.3 热截断 + 1.6.4 的 300k 触发 / 保留 4 轮与
8
- > 可移植性收口;D2/D6 默认关闭;安装包 `dist/jjh32456-dsh-longlive-1.6.4.tgz`)。
7
+ > 当前版本:**1.6.5**(1.6.0 改名基座 + 1.6.3 热截断 + 1.6.4 的 300k 触发 / 保留 4 轮与
8
+ > 可移植性收口;D2/D6 默认关闭;安装包 `dist/jjh32456-dsh-longlive-1.6.5.tgz`)。
9
9
 
10
10
  ## 零、安全性承诺(2026-10-30 要求:不能出错,出错就死了)
11
11
 
package/docs/CONFIG.md CHANGED
@@ -1,6 +1,6 @@
1
- # CONFIG.md — @jjh32456/dsh-longlive(久视)配置手册
1
+ # CONFIG.md — @jjh32456/dsh-longlive(久视)配置手册
2
2
 
3
- 适用版本:**1.6.4**(以包内 `package.json` 的 `version` 为准)。所有默认值以该源码树为准。
3
+ 适用版本:**1.6.5**(以包内 `package.json` 的 `version` 为准)。所有默认值以该源码树为准。
4
4
  读者:会改 YAML、会看日志,但没参与开发的人。
5
5
 
6
6
  ---
package/docs/DEV.md CHANGED
@@ -1,4 +1,4 @@
1
- # @jjh32456/dsh-longlive — session longevity for DSH
1
+ # @jjh32456/dsh-longlive — session longevity for DSH
2
2
 
3
3
  中文名:**会话长寿**。给 DeepSeek Harness 的宿主插件。
4
4
 
@@ -11,12 +11,12 @@
11
11
  五步版见 [QUICKSTART.md](../QUICKSTART.md)。最短路径(tgz 已在手,CLI 不需要会话权限):
12
12
 
13
13
  ```powershell
14
- & $DSH plugin --profile <profile> add "file:C:/path/to/jjh32456-dsh-longlive-1.6.4.tgz"
15
- & $DSH plugin --profile <profile> list --depth 0 # 应显示 @1.6.4
14
+ & $DSH plugin --profile <profile> add "file:C:/path/to/jjh32456-dsh-longlive-1.6.5.tgz"
15
+ & $DSH plugin --profile <profile> list --depth 0 # 应显示 @1.6.5
16
16
  ```
17
17
 
18
18
  `$DSH` 指 DSH 安装目录里的 `resources\runtime\cli\bin\dsh.cmd`(干净机器的 PATH 里通常没有它)。
19
- 从源码安装则先 `& $NODE scripts\pack.mjs --expect-version 1.6.4`;`$NODE` 指 DSH 安装目录里的
19
+ 从源码安装则先 `& $NODE scripts\pack.mjs --expect-version 1.6.5`;`$NODE` 指 DSH 安装目录里的
20
20
  `resources\runtime\primary-runtime\dependencies\node\bin\node.exe`(>=20)。
21
21
  会话内也可以 `plugin_manager action=install_bundle target="file:<绝对路径>"`,但那需要
22
22
  danger-full-access 或逐次批准。
package/docs/FAQ.md CHANGED
@@ -1,4 +1,4 @@
1
- # 常见问题(FAQ)
1
+ # 常见问题(FAQ)
2
2
 
3
3
  **读完你会得到什么**:装完没反应、侧栏不见了、401/404、剪完变少、换电脑、断电、卸载这些最常见的情况,能自己判断「是正常、是没装好,还是该回滚」。
4
4
 
@@ -8,7 +8,7 @@
8
8
 
9
9
  大概率是正常的。这个插件**没有自带界面**,默认 `mode: "log"` 也只会在后台写一行评估日志,不会弹窗、不会剪东西。
10
10
 
11
- 按顺序确认:`dsh plugin --profile desktop list --depth 0` 有 `@jjh32456/dsh-longlive@1.6.4`;doctor 没有硬错误(退出码 0,或只有 engram 缺失导致的 1);重启后新会话里能数到 22 个工具;聊到超过阈值后,`%USERPROFILE%\.dsh\longlive-memory\.longlive\logs\auto-truncate.log` 才出现评估行(默认阈值 300000,所以短时间内没有是正常的)。
11
+ 按顺序确认:`dsh plugin --profile desktop list --depth 0` 有 `@jjh32456/dsh-longlive@1.6.5`;doctor 没有硬错误(退出码 0,或只有 engram 缺失导致的 1);重启后新会话里能数到 22 个工具;聊到超过阈值后,`%USERPROFILE%\.dsh\longlive-memory\.longlive\logs\auto-truncate.log` 才出现评估行(默认阈值 300000,所以短时间内没有是正常的)。
12
12
 
13
13
  如果这四样都没有,先看宿主启动 stderr 有没有 `1 entry did not activate`。
14
14
 
@@ -92,7 +92,7 @@
92
92
 
93
93
  四个独立证据,至少看到两个:
94
94
 
95
- 1. `plugin list` 显示 `@1.6.4`;
95
+ 1. `plugin list` 显示 `@1.6.5`;
96
96
  2. doctor 没有硬错误(0,或只有 engram 缺失导致的 1);
97
97
  3. 新会话能调用 `session_health`,返回里有 `sessionPath` / `events` / `visibleMessages`;
98
98
  4. 聊到超过阈值后,`auto-truncate.log` 出现评估行(`action` 是 `planned` / `dryrun-planned` / `none` 都算插件在评估)。
package/docs/FEATURES.md CHANGED
@@ -1,4 +1,7 @@
1
- # 1.6.4 可选能力与开关
1
+ # 1.6.5 可选能力与开关
2
+
3
+ 1.6.5 是热截断热修复:被宿主重启中断过的会话(存在 closed-but-not-completed 轮次)不会再
4
+ 永久失去自动切刀;计划会从被中断轮次之后继续找可切的已完成轮。其余行为与 1.6.4 相同。
2
5
 
3
6
  1.6.4 在 1.6.0 改名基座之上加入**热截断**(cut-only `CompactionEngine`:只切已完成且早于
4
7
  保留窗口的整轮,不调 LLM,原始事件不动,只追加一条覆盖率指针;默认 `mode=cut`,自动切达到
@@ -1,8 +1,8 @@
1
- # 从零到第一次截断(QUICKSTART)
1
+ # 从零到第一次截断(QUICKSTART)
2
2
 
3
3
  **读完你会得到什么**:从一台已经装好 DSH 的 Windows 机器出发,装好 longlive,用 `dryrun` 看它「打算怎么剪」,再切到 `request`,最后安全地完成第一次真截断,并且知道怎么退回去。
4
4
 
5
- > 适合谁:第一次拿到 `jjh32456-dsh-longlive-1.6.4.tgz` 的人。全程约 20 分钟,其中真截断那一步应用会自己关闭再打开(VM 实测约 31.5 秒),请挑你不用电脑的时候做。
5
+ > 适合谁:第一次拿到 `jjh32456-dsh-longlive-1.6.5.tgz` 的人。全程约 20 分钟,其中真截断那一步应用会自己关闭再打开(VM 实测约 31.5 秒),请挑你不用电脑的时候做。
6
6
  >
7
7
  > 本文默认 DSH home 是 `%USERPROFILE%\.dsh`;如果你设过 `DSH_HOME`,把命令里的这段整体换掉。
8
8
 
@@ -25,14 +25,14 @@ $NODE = '<DSH 安装目录>\resources\runtime\primary-runtime\dependencies\node\
25
25
  ## 1. 装插件:三条命令
26
26
 
27
27
  ```powershell
28
- $TGZ = 'C:/path/jjh32456-dsh-longlive-1.6.4.tgz' # 换成真实绝对路径
28
+ $TGZ = 'C:/path/jjh32456-dsh-longlive-1.6.5.tgz' # 换成真实绝对路径
29
29
 
30
30
  & $DSH plugin --profile desktop add "file:$TGZ"
31
31
  & $DSH plugin --profile desktop list --depth 0
32
32
  & $NODE "$env:USERPROFILE\.dsh\profiles\desktop\node_modules\@jjh32456\dsh-longlive\scripts\longlive-doctor.mjs" --profile desktop
33
33
  ```
34
34
 
35
- **你应该看到**:`list` 里出现 `@jjh32456/dsh-longlive@1.6.4`;doctor 打印一张表,最后是「全部可选依赖已启用。」或只有 engram 缺失的提示。
35
+ **你应该看到**:`list` 里出现 `@jjh32456/dsh-longlive@1.6.5`;doctor 打印一张表,最后是「全部可选依赖已启用。」或只有 engram 缺失的提示。
36
36
 
37
37
  **报错了怎么办**:
38
38
  - `ENOENT` → `file:` 后面必须是**真实存在的绝对路径**,用正斜杠;不要把 `~` 写进去。
package/docs/README.en.md CHANGED
@@ -14,7 +14,7 @@ A DeepSeek Harness plugin that keeps a long session healthy: **inspect it, back
14
14
  ```powershell
15
15
  $DSH = '<DSH install>\resources\runtime\cli\bin\dsh.cmd'
16
16
  $NODE = '<DSH install>\resources\runtime\primary-runtime\dependencies\node\bin\node.exe'
17
- & $DSH plugin --profile desktop add "file:C:/path/jjh32456-dsh-longlive-1.6.4.tgz"
17
+ & $DSH plugin --profile desktop add "file:C:/path/jjh32456-dsh-longlive-1.6.5.tgz"
18
18
  & $DSH plugin --profile desktop list --depth 0
19
19
  & $NODE "$env:USERPROFILE\.dsh\profiles\desktop\node_modules\@jjh32456\dsh-longlive\scripts\longlive-doctor.mjs" --profile desktop
20
20
  ```
@@ -1,4 +1,4 @@
1
- # TROUBLESHOOTING.md — 按症状查(1.6.4)
1
+ # TROUBLESHOOTING.md — 按症状查(1.6.5)
2
2
 
3
3
  用法:先看「症状」,对不上再看「最可能原因」。每条只给**一条**验证命令——先确认判断,再动手。
4
4
  「什么时候该停手」比「怎么修」更重要:结构一旦不对,继续重试只会扩大损失。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjh32456/dsh-longlive",
3
- "version": "1.6.4",
3
+ "version": "1.6.6",
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": [
@@ -370,8 +370,9 @@ export function selectCutPlan(session, options, measurement, helpers = {}) {
370
370
  };
371
371
 
372
372
  // 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.
373
+ // that is not completed counts toward the token budget but never toward the
374
+ // completed-turn floor, so the retained suffix may temporarily hold a few
375
+ // extra turns while a closed aborted turn sits inside the window.
375
376
  let keptTurns = 0;
376
377
  let keptTokens = 0;
377
378
  let boundary = -1;
@@ -390,7 +391,46 @@ export function selectCutPlan(session, options, measurement, helpers = {}) {
390
391
  const surfaceNodes = session?.surface?.nodes ?? [];
391
392
  if (surfaceNodes.length === 0) return null;
392
393
 
393
- // Never include a leading system prompt in the replaced span.
394
+ // 2026-11-06 1.6.6 hotfix — the aborted-turn freeze, second attempt.
395
+ //
396
+ // 1.6.5 stopped an aborted/unfinished turn from returning null, but it kept
397
+ // the old "never cut an aborted turn" rule: the span was moved *after* the
398
+ // last aborted turn in the prefix. When aborted turns recur near the tail
399
+ // (restart-aborted / interrupted turns; real session 2026-11-06: turns 48,
400
+ // 74, 85, 90, 94), the last one sits right before the retention boundary, so
401
+ // the plan degenerates to "cut the single completed turn after it" (6–18k
402
+ // tokens) while every older completed turn (≈370k tokens) stays pinned in
403
+ // the surface as a permanent prefix. That is the exact shape of the
404
+ // 396k-token context.
405
+ //
406
+ // A turn with a `turn/end` event is closed: it never gains new events, its
407
+ // tool pairs are complete by construction, and cutting it whole keeps the
408
+ // pairing balanced. Only a turn with no `turn/end` yet (the currently open
409
+ // turn, or a turn the log was truncated inside) must stay in place. So the
410
+ // span may now cover closed-but-not-completed turns; `cutTurnGroups` below
411
+ // includes them and the archive copies the full turn range via byNumber.
412
+ const groupStart = new Array(groups.length);
413
+ const groupEnd = new Array(groups.length);
414
+ const groupOfPosition = new Array(surfaceNodes.length);
415
+ {
416
+ let cursor = 0;
417
+ for (let index = 0; index < groups.length; index += 1) {
418
+ groupStart[index] = cursor;
419
+ for (let offset = 0; offset < groups[index].seqs.length; offset += 1) groupOfPosition[cursor++] = index;
420
+ groupEnd[index] = cursor - 1;
421
+ }
422
+ }
423
+ const turnInfoOf = (group) => (group.kind === 'turn' && group.turn !== 0 ? table.byNumber.get(group.turn) : undefined);
424
+ const isOpenGroup = (group) => {
425
+ if (group.kind !== 'turn') return false;
426
+ if (group.turn === 0) return true;
427
+ const info = turnInfoOf(group);
428
+ return info === undefined || info.endSeq === null;
429
+ };
430
+
431
+ // The head of the surface is the real system prompt: the host protects
432
+ // position 0 and this engine keeps the whole leading run of system/message
433
+ // nodes in place. The span starts at the first user/assistant node after it.
394
434
  let startIndex = 0;
395
435
  while (startIndex < surfaceNodes.length
396
436
  && table.bySeq.get(surfaceNodes[startIndex])?.type === 'system/message') {
@@ -404,40 +444,64 @@ export function selectCutPlan(session, options, measurement, helpers = {}) {
404
444
  startIndex += 1;
405
445
  }
406
446
  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
- if (endIndex < startIndex) return null;
413
-
414
- // An aborted/unfinished turn is never cut; stop before its surface group.
415
- for (let index = 0; index < boundary; index += 1) {
416
- const group = groups[index];
417
- if (group.kind === 'turn' && group.turn !== 0 && !group.completed) {
418
- endIndex = Math.min(endIndex, surfaceNodes.indexOf(group.seqs[0]) - 1);
419
- break;
447
+ // A leading group may start with the injected system prompt and continue with
448
+ // the turn's own nodes. The system nodes stay outside; the span starts at the
449
+ // first non-system node of that group — the skipped prefix is system-only, so
450
+ // no user/assistant/tool node is left half-cut. A group whose head contains
451
+ // non-system nodes is kept whole instead.
452
+ let startGroup = groupOfPosition[startIndex];
453
+ while (startGroup < groups.length && groupStart[startGroup] < startIndex) {
454
+ let systemOnlyHead = true;
455
+ for (let position = groupStart[startGroup]; position < startIndex; position += 1) {
456
+ if (table.bySeq.get(surfaceNodes[position])?.type !== 'system/message') { systemOnlyHead = false; break; }
457
+ }
458
+ if (systemOnlyHead) break;
459
+ startIndex = groupEnd[startGroup] + 1;
460
+ while (startIndex < surfaceNodes.length) {
461
+ const type = table.bySeq.get(surfaceNodes[startIndex])?.type;
462
+ if (type === 'user/message' || type === 'assistant/message') break;
463
+ startIndex += 1;
420
464
  }
465
+ if (startIndex >= surfaceNodes.length) return null;
466
+ startGroup = groupOfPosition[startIndex];
421
467
  }
422
468
 
423
- // The host tolerates later system nodes inside a replacement range, but this
424
- // engine promises never to cut one — and never to leave half a turn behind.
425
- // A system node inside the candidate span therefore excludes its whole turn.
426
- for (let index = startIndex; index <= endIndex; index += 1) {
427
- if (table.bySeq.get(surfaceNodes[index])?.type !== 'system/message') continue;
428
- const seq = surfaceNodes[index];
429
- const owner = groups.find((group) => group.seqs.includes(seq));
430
- const ownerStart = owner === undefined ? index : surfaceNodes.indexOf(owner.seqs[0]);
431
- endIndex = Math.min(endIndex, ownerStart - 1);
432
- break;
469
+ // Cut everything older than the retention boundary, minus groups that must
470
+ // stay in place: the currently open/unclosed turn and groups without a turn
471
+ // record are never part of the span. Every other group is cut whole. This is
472
+ // what lets a closed aborted turn be removed together with its neighbours
473
+ // instead of acting as a barrier the span has to jump over.
474
+ let endGroup = boundary - 1;
475
+ let blockedAt = -1;
476
+ for (let index = startGroup; index <= endGroup; index += 1) {
477
+ if (isOpenGroup(groups[index])) { blockedAt = index; break; }
433
478
  }
434
- if (endIndex < startIndex) return null;
435
-
479
+ if (blockedAt >= 0) endGroup = blockedAt - 1;
480
+ if (endGroup < startGroup) return null;
481
+ const endIndex = groupEnd[endGroup];
482
+
483
+ // A system/message node *inside* the span is a host-restart re-injection of
484
+ // the same prompt (the protected copy is position 0, handled above). The
485
+ // previous revision excluded the whole containing turn; in the real session
486
+ // that pinned turns 66/72/75 (≈100k tokens of ordinary dialogue carrying one
487
+ // injected copy each) outside every future cut, so the context could never
488
+ // reach the retention window. The host tolerates later system nodes inside a
489
+ // replacement range, so an injected copy is archived together with the old
490
+ // prefix it belongs to. Nodes before the span start are never touched.
436
491
  const shadowedSeqs = surfaceNodes.slice(startIndex, endIndex + 1);
437
492
  const shadowedSet = new Set(shadowedSeqs);
438
493
  const touchedGroups = groups.filter((group) => group.seqs.some((seq) => shadowedSet.has(seq)));
439
- if (touchedGroups.some((group) => group.kind === 'turn' && group.turn !== 0 && !group.completed)) return null;
440
- const cutTurnGroups = touchedGroups.filter((group) => group.kind === 'turn' && group.turn !== 0 && group.completed);
494
+ if (touchedGroups.some((group) => isOpenGroup(group))) return null;
495
+ for (const group of touchedGroups) {
496
+ if (group.kind !== 'turn') continue;
497
+ const missing = group.seqs.filter((seq) => !shadowedSet.has(seq));
498
+ if (missing.some((seq) => table.bySeq.get(seq)?.type !== 'system/message')) return null;
499
+ }
500
+ const cutTurnGroups = touchedGroups.filter((group) => {
501
+ if (group.kind !== 'turn' || group.turn === 0) return false;
502
+ const info = turnInfoOf(group);
503
+ return info !== undefined && info.endSeq !== null;
504
+ });
441
505
  if (cutTurnGroups.length === 0) return null;
442
506
 
443
507
  // Archive every original event of the cut turns, plus any checkpoint node
@@ -648,7 +712,7 @@ export function buildPointerText(facts) {
648
712
  const range = firstTurn === lastTurn ? `第 ${firstTurn} 轮` : `第 ${firstTurn}–${lastTurn} 轮`;
649
713
  const lines = [
650
714
  `本会话共 ${totalTurns} 轮,其中 ${archivedTurns} 轮已压缩归档;需要原文用 session_original(turn=…) 或读归档路径:${archivePath}`,
651
- `本次热截断切掉${range}(${cutTurnCount} 轮 / ${shadowedTokenCount} tokens / ${surfaceEventCount} 个表面事件);只切已完成且早于保留窗口的轮,切点之后的原文未动。`,
715
+ `本次热截断切掉${range}(${cutTurnCount} 轮 / ${shadowedTokenCount} tokens / ${surfaceEventCount} 个表面事件);只切已结束且早于保留窗口的轮(含被中止/中断的旧轮),当前未完成的轮不切,切点之后的原文未动。`,
652
716
  `归档:${eventCount} 条原始事件(JSONL,每行含 seq/turn/原始消息)` + (sha256 ? `,sha256=${sha256.slice(0, 16)}…` : '') + '。',
653
717
  `保留窗口:最近 ${retainTurns} 个已完成轮、至少 ${retainTokens} tokens(本次保留 ${retainedTurns} 轮)。`,
654
718
  ];
@@ -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,20 +396,169 @@ 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 an aborted turn blocks the prefix but never gets cut itself', 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
- const { engine } = engineFor({ archiveDir: caseDir('archive') });
403
+ addTurn(session, 10, { open: true });
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 });
403
410
  const result = await engine.compactIfNeeded(makeAgent(session), 'pressure', new AbortController().signal);
404
- assert.equal(result, null, 'the aborted turn-1 prefix is not a legal completed-only cut');
405
- assert.equal(pointerEvents(session).length, 0);
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]);
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);
418
+ const summary = pointerEvents(session)[0];
419
+ const pointer = session.events[summary.seq + 1];
420
+ assert.match(pointer.data.content[0].text, /第 1–5 轮/);
406
421
  });
407
422
 
408
423
  // ---------------------------------------------------------------------------
409
424
  // Archive
410
425
  // ---------------------------------------------------------------------------
411
426
 
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.
435
+ const session = new MockSession();
436
+ for (let turn = 1; turn <= 3; turn += 1) addTurn(session, turn, { completed: false });
437
+ session.append('turn/start', { turn: 4 });
438
+ session.append('assistant/message', {
439
+ turn: 4,
440
+ step: 1,
441
+ message: {
442
+ id: 'assistant-4',
443
+ role: 'assistant',
444
+ source: { kind: 'model', provider: 'test', model: 'test' },
445
+ content: [{ type: 'text', text: '第 4 轮回答(前面是重启中断)' }],
446
+ },
447
+ }, { surfaceOp: 'append' });
448
+ session.append('system/message', {
449
+ turn: 4,
450
+ step: 2,
451
+ message: {
452
+ id: 'system-4',
453
+ role: 'system',
454
+ source: { kind: 'system-prompt' },
455
+ content: [{ type: 'text', text: 'A host-injected system message mid-turn.' }],
456
+ },
457
+ }, { surfaceOp: 'append' });
458
+ session.append('user/message', {
459
+ id: 'user-4b',
460
+ role: 'user',
461
+ content: [{ type: 'text', text: '第 4 轮用户追问' }],
462
+ source: { kind: 'user', clientTimeZone: 'Asia/Shanghai' },
463
+ }, { surfaceOp: 'append' });
464
+ session.append('turn/end', { turn: 4, reason: { kind: 'completed' } });
465
+ for (let turn = 5; turn <= 10; turn += 1) addTurn(session, turn, { completed: true });
466
+ addTurn(session, 11, { open: true });
467
+ const dir = caseDir('archive');
468
+ const { engine } = engineFor({ archiveDir: dir, perEvent: 5000, retainTurns: 4, retainTokens: 0, autoMinTokens: 0 });
469
+ const result = await engine.compactIfNeeded(makeAgent(session), 'pressure', new AbortController().signal);
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');
475
+ const summary = pointerEvents(session)[0];
476
+ const pointer = session.events[summary.seq + 1];
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
+
412
562
  test('H4 archive: atomic JSONL, exact event count, sha256, append-only', async () => {
413
563
  const dir = caseDir('archive');
414
564
  const session = buildSession(9, { openLast: false });
@@ -470,7 +620,7 @@ test('H5 pointer text carries N/M, session_original(turn=…), and the archive p
470
620
  const text = result.summary[0].text;
471
621
  assert.match(text, /本会话共 9 轮,其中 3 轮已压缩归档;需要原文用 session_original\(turn=…\) 或读归档路径:/);
472
622
  assert.ok(text.includes(dir), 'archive directory is in the pointer');
473
- assert.match(text, /只切已完成且早于保留窗口的轮/);
623
+ assert.match(text, /只切已结束且早于保留窗口的轮/);
474
624
  assert.match(text, /保留窗口:最近 6 个已完成轮、至少 0 tokens(本次保留 6 轮)/);
475
625
 
476
626
  const pointerEvent = session.events[result.summarySeq + 1];
@@ -531,7 +681,12 @@ test('H28 a system prompt at surface 0 is never cut; the pointer lands at positi
531
681
  assert.equal(parsed.header.cutTurns[0].startSeq, 0, 'the archive still covers the whole turn');
532
682
  });
533
683
 
534
- test('H29 a system message inside the candidate span stops the cut before its turn', async () => {
684
+ test('H29 a mid-span host-injected system message is archived with the old prefix', async () => {
685
+ // 1.6.6: the head system prompt is still never cut (H28). A host-injected
686
+ // system/message in the middle of the surface is a duplicate re-emitted by a
687
+ // restart; the host tolerates it inside a replacement range, and keeping it
688
+ // outside pinned every following turn forever. It is archived with the old
689
+ // prefix instead of stopping the cut before its turn.
535
690
  const session = new MockSession();
536
691
  addTurn(session, 1);
537
692
  addTurnWithSystemInside(session, 2);
@@ -541,12 +696,12 @@ test('H29 a system message inside the candidate span stops the cut before its tu
541
696
  const dir = caseDir('archive');
542
697
  const { engine } = engineFor({ archiveDir: dir });
543
698
  const result = await engine.compactNow(makeAgent(session), new AbortController().signal);
544
- assert.ok(result, 'the safe prefix before the system message is still cuttable');
545
- assert.equal(result.shadowedSeqs.includes(systems[0].seq), false);
699
+ assert.ok(result, 'the old prefix is cuttable');
700
+ assert.equal(result.shadowedSeqs.includes(systems[0].seq), true, 'the injected copy is archived with the prefix');
546
701
  const file = fs.readdirSync(dir).find((name) => name.endsWith('.jsonl'));
547
702
  const parsed = readArchive(path.join(dir, file));
548
- assert.deepEqual(parsed.header.cutTurns.map((entry) => entry.turn), [1], 'only turn 1 is cut');
549
- assert.equal(session.surface.nodes.includes(systems[0].seq), true, 'the system node remains on the surface');
703
+ assert.deepEqual(parsed.header.cutTurns.map((entry) => entry.turn), [1, 2], 'turn 1 and the injected turn 2 are cut');
704
+ assert.equal(session.surface.nodes.includes(systems[0].seq), false, 'the injected copy left the surface with its turn');
550
705
  });
551
706
 
552
707
  test('H6 successive cuts update the cumulative archived count and fold the old pointer', async () => {
@@ -140,17 +140,17 @@ test('只带白名单安装后:没有 prototype,所有 src 相对 import 都
140
140
  assert.deepEqual(offenders, [], `存在跨出包目录/缺失的 import:\n${offenders.join('\n')}`);
141
141
  });
142
142
 
143
- test('R2-07 发布版本面:包版本 1.6.4,文档同步 D2/D6 能力集', () => {
144
- assert.equal(PKG.version, '1.6.4');
143
+ test('R2-07 发布版本面:包版本 1.6.5,文档同步 D2/D6 能力集', () => {
144
+ assert.equal(PKG.version, '1.6.5');
145
145
  const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
146
146
  const en = read('docs/README.en.md');
147
147
  assert.ok(en.includes('@jjh32456/dsh-longlive-panel@0.2.5'), '面板版本未统一到 0.2.5(scoped 包名)');
148
- assert.ok(en.includes('jjh32456-dsh-longlive-1.6.4.tgz'), '英文文档示例 tgz 不是 scoped 的 1.6.4');
148
+ assert.ok(en.includes('jjh32456-dsh-longlive-1.6.5.tgz'), '英文文档示例 tgz 不是 scoped 的 1.6.5');
149
149
  assert.ok(en.includes('@jjh32456/dsh-longlive'), '英文文档缺少 scoped 包名');
150
150
  assert.equal(en.includes('dsh-longlive-panel@0.1.3'), false, '英文文档仍残留 0.1.3');
151
151
  assert.equal(en.includes('0.9.0-rc.1'), false, '英文文档仍残留 rc.1');
152
152
  const features = read('docs/FEATURES.md');
153
- assert.ok(features.startsWith('# 1.6.4'), 'FEATURES.md 标题不是 1.6.4');
153
+ assert.ok(features.startsWith('# 1.6.5'), 'FEATURES.md 标题不是 1.6.5');
154
154
  assert.equal(features.includes('0.9.0-rc.1'), false, 'FEATURES.md 仍残留 rc.1');
155
155
  });
156
156