@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 +18 -0
- package/QUICKSTART.md +6 -6
- package/README.en.md +2 -2
- package/README.md +3 -3
- package/SPEC.md +3 -3
- package/docs/CONFIG.md +2 -2
- package/docs/DEV.md +4 -4
- package/docs/FAQ.md +3 -3
- package/docs/FEATURES.md +4 -1
- package/docs/QUICKSTART.md +4 -4
- package/docs/README.en.md +1 -1
- package/docs/TROUBLESHOOTING.md +1 -1
- package/package.json +1 -1
- package/src/compaction/engine-core.js +95 -31
- package/test/hot-truncate.test.mjs +166 -11
- package/test/packaging.test.mjs +4 -4
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.
|
|
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.
|
|
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.
|
|
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.
|
|
71
|
-
# 产物:dist\jjh32456-dsh-longlive-1.6.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
8
|
-
> 可移植性收口;D2/D6 默认关闭;安装包 `dist/jjh32456-dsh-longlive-1.6.
|
|
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
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.
|
|
15
|
-
& $DSH plugin --profile <profile> list --depth 0 # 应显示 @1.6.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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`,自动切达到
|
package/docs/QUICKSTART.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
```
|
package/docs/TROUBLESHOOTING.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jjh32456/dsh-longlive",
|
|
3
|
-
"version": "1.6.
|
|
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
|
|
374
|
-
//
|
|
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
|
-
//
|
|
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
|
|
409
|
-
//
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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 (
|
|
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
|
|
440
|
-
const
|
|
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
|
|
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
|
|
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 });
|
|
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.
|
|
405
|
-
assert.equal(
|
|
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
|
|
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
|
|
545
|
-
assert.equal(result.shadowedSeqs.includes(systems[0].seq),
|
|
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], '
|
|
549
|
-
assert.equal(session.surface.nodes.includes(systems[0].seq),
|
|
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 () => {
|
package/test/packaging.test.mjs
CHANGED
|
@@ -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.
|
|
144
|
-
assert.equal(PKG.version, '1.6.
|
|
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.
|
|
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.
|
|
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
|
|