dsh-live-compact 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,31 +1,26 @@
1
1
  # 边做边压 Live Compact
2
2
 
3
- Live Compact 就是边做边压。活着的时候就压,不等窗口堆满。
3
+ 一段对话做久了,读过的日志、跑过的命令和工具输出会越积越多。Live Compact 会在每轮结束时检查,能安全收短的长输出就整理一下。这一步在本机做,无需再请求模型。只替换选中的一段,切点前面的内容保持原样。整理前会备份原文,备份失败就保留原来的内容。
4
4
 
5
- DSH 把压缩写死在大约 80%。不到这条线,上下文一直堆。到了才调一次模型,十几秒,还要计费。压完注意力会飘。更麻烦的是,压完没多久又满了。1M 的窗口根本不够用,再压一次更慢。
5
+ ## 云端和本地怎么用
6
6
 
7
- 为什么非要等到 80%?边做边压不行吗?
7
+ - **用 DeepSeek、GLM 这类云端 API**,按下面的命令安装即可。先看答案是否正确,再一起看缓存命中、未命中输入和实际费用。命中率的百分比不是费用本身,输入变少时要连同 token 数一起比较。
8
+ - **用本机或内网跑的模型**,同样按下面的命令安装。优先看答案对不对、当前上下文有没有降低、实际少了多少 token。界面显示「上下文 2 万多」是还留着的内容量,不是没有压缩。本地缓存复用也会尽量改善,但不套云端计费缓存的固定命中率门槛;同一任务前后还可以比较复用量和预填充耗时。
8
9
 
9
- ## 云端和端侧
10
+ 默认到 2 万 token,插件才开始检查有没有合适的内容可以压缩。实际会不会压缩,还要看这轮读进了什么、下一轮可能有多长。上下文接近窗口上限时,DSH 仍可调用模型做一次大摘要。
10
11
 
11
- 云端(公网地址,如 DeepSeek、GLM):装上即可。不要设置 `SPINE_THUNDER`。看题对不对,以及缓存命中。切点前面的原文不动。
12
-
13
- 端侧(本机、内网、27B):也可以装。不要设置 `SPINE_THUNDER`。不要拿云端缓存命中来要求它。看题对不对,以及切掉多少 token。本地服务没有云端那种前缀缓存,每次提问都会把当前上下文重新预填充。两万多是当前上下文的大小,不是插件没切。
14
-
15
- `SPINE_THUNDER` 默认关闭。只有设为 `1` 才走雷霆。再设 `SPINE_THUNDER_MODEL=27b` 时,前缀超过 8 万会每次都少发,一次雷霆变成次次雷霆。端侧不要开。
16
-
17
- 超过 2 万 token 就切一刀。切的是刚结束的这一轮,不调用模型。切点前面的原文不动,缓存命中还是原来的水准。上下文因此一直被按在较低的位置。雷霆大压缩能拖就拖,拖到尽可能后面。就算哪天满了、真的触发了,掉下来之后小切还在,不会很快又堆回去。
12
+ 平常用默认设置就够了。`SPINE_THUNDER` 是实验性的模型摘要模式,默认关闭。需要试用时才设为 `1`。本机或内网模型建议保持关闭。
18
13
 
19
14
  headless:
20
15
 
21
16
  ```sh
22
- dsh plugin --profile headless add dsh-live-compact@1.1.0
17
+ dsh plugin --profile headless add dsh-live-compact@1.1.2
23
18
  ```
24
19
 
25
- 网页和桌面端(共用 web profile,装一次两边都生效):
20
+ 网页端:
26
21
 
27
22
  ```sh
28
- dsh plugin --profile web add dsh-live-compact@1.1.0
23
+ dsh plugin --profile web add dsh-live-compact@1.1.2
29
24
  ```
30
25
 
31
26
  想先装到本机看版本:
@@ -35,6 +30,38 @@ npm install -g dsh-live-compact
35
30
  dsh-live-compact --version
36
31
  ```
37
32
 
33
+ ## 1.1.2
34
+
35
+ - 常用压缩参数可以在 DSH 插件设置里改,保留原有环境变量用法。
36
+ - 专用备份目录确认只有一个插件进程使用时,低于用量水线可以跳过全目录扫描;完整校准满 60 秒后,下次轮转会重新扫描。默认目录和共享目录继续按原规则检查、清理。
37
+ - 扫描不完整或计量不合法时拒绝本次备份。无法确认其他会话已经退出时,不删除它的正文。
38
+ - 失败工具结果保留退出码、错误类型、标准错误和标准输出里的关键原因;过长时保留原因和首尾,不能把失败卡片写成正常完成。`npm run test:gate` 也会检查这条路径。
39
+ - 提交小切前重新检查 checkpoint、用户边界和工具配对;等待期间出现的新消息也会重新检查。
40
+ - 账号模式没有显式地址、恢复旧官方卡片和精确的过程通知可以组合工作;真实用户消息与未知通知仍保留边界。
41
+ - 重启后会核验已有备份和游标,再从最近的工作位置继续。
42
+ - Windows 会重新设置备份访问权限,失败时停止写备份并保留原工具输出。
43
+
44
+ ### Windows 备份权限
45
+
46
+ Windows 双用户实测(Actions run `42367713007`,Windows 11 Enterprise ARM / Server 2022 x64)确认:1.1.1 的 `/inheritance:r /grant:r` 会留下其他主体的显式 ACE,第二个普通用户仍能读取备份。1.1.2 改为完整替换 DACL。本次运行时代码修改后,最终候选仍须重新完成原生 Windows 与 Desktop 验收,结果随最终候选记录。
47
+
48
+ 权限写入改为固定的 `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe`(Windows PowerShell 5.1 / .NET Framework),使用 `-NoProfile -NonInteractive -EncodedCommand`。脚本固定内嵌在已打包的 `shadow.js`,路径、SID、文件类型通过子进程环境传递,不拼进脚本。当前用户 SID 仍由固定 System32 `whoami.exe` 每进程获取一次,脚本再对照自身 Windows 身份。新建受保护的 DACL,只允许当前用户、SYSTEM、Administrators 完全控制;目录 ACE 带 OI/CI,直接修正的文件 ACE 不带继承标志。只修改 DACL,不提权,不改 owner、group 或 SACL,不使用临时宽松 ACL。写后读取实际 DACL,核验保护位、完整 ACE 数量、SID、Allow、权限及继承标志,收到确认才缓存。工具失败、超时、SID 非法或读回不符都会熔断本进程后续备份,不回退到 chmod。
49
+
50
+ 目录缓存核对规范路径、设备/文件编号及创建时间;替换目录或改指向的已知别名会拒绝后续备份。经确认目录内以 `wx` 新建的空文件,核对已打开句柄与路径指向同一文件后,使用继承的 allowlist,不逐文件启动子进程,写入正文前完成检查。既有文件不能只凭父目录缓存获得信任:仅在本进程已确认同一文件身份时复用缓存,否则对被请求的文件单独替换 DACL。拒绝文件符号链接、非普通文件及多硬链接文件。不扫描或批量修复历史备份;捕获/恢复所请求的旧 raw、meta、cursor 文件在使用时修正。未触及的旧文件(包括直接外部读取或 shadow-search 读取的旧文件)不保证已修复,也无法撤回此前泄露的数据。不要把 shadow 指向混有其他文件的目录。
51
+
52
+ 缓存信任同一用户和管理员不在进程运行期间原地改写 ACL;元数据核对不能发现同一文件上的 ACL 原地变更,也不能消除同用户/管理员并发改路径的全部竞态。SystemRoot/windir 必须指向可信 Windows 安装;文件系统须提供稳定文件身份并支持 Windows DACL,工具缺失或策略禁止运行时拒绝备份。Unix 仍使用目录 0700 / 文件 0600。切点、token/cap 策略、#27 根目录协调与重启口径不变。
53
+
54
+ 离线门禁用 `npm run test:gate`。其中 Windows 权限测试用注入的文件系统和命令验证失败分支。真实权限另用两个 Windows 普通账户验证,分别检查写入、可读的对照文件和备份的读取结果。评分器的离线测试用 `npm run test:live-gate`。
55
+
56
+ ## 1.1.1
57
+
58
+ 1. Windows 上取 SID 和写权限不再走 PATH。固定调用 `%SystemRoot%\System32` 下的 `whoami.exe` 和 `icacls.exe`。环境变量不是盘符绝对路径时,这次备份直接跳过,不退回裸命令名。
59
+ 2. 当时用 `/inheritance:r /grant:r` 替代 `icacls /restore`;后续双用户实测确认它保留其他显式 ACE,不能实现完整 allowlist。修正及验证限制见上面的 1.1.2 候选说明。
60
+ 3. 权限失败不再把宿主打退出。这次备份不落盘,不退回 `chmod`,只提示一次。压缩照常进行。
61
+ 4. 进程重启后补抓工具结果,只补最近 3 轮。已经记下的位置还在,就从那里接着记。记下的位置比当前日志还大,按重启口径补最近 3 轮,不从 0 把整份日志再扫一遍。
62
+ 5. 一轮里有不少于 1000 token 的 `read`,这一刀只切这一轮,不把要留的后 3 轮短结果一起卷进去。这一轮里单个工具结果达到 3.2 万,只留指针。这一轮切不下来,才按原来的规则切更早的轮。
63
+ 6. 65 万、4.8 万、3.2 万、`CHUNK_MIN` 1000、云端默认 `0.8` / `65536` / `8192`、`SPINE_THUNDER` 默认关闭,都没改。没有加「省下的必须盖过重写」这种新门槛。备份目录快满时仍是先扫再判断,这版没改,留到 1.1.2。
64
+
38
65
  ## 1.1.0
39
66
 
40
67
  1. Issue #19 里下一轮输入回到 21 万到 24 万、前缀被打掉的那一刀,是切点走到了已有卡片左边。1.1.0 把最新一张卡片当成地板。往左走到卡片就停。切点右边还有更新的卡片,这一刀不切。已进缓存的前缀不再被后面的刀改写。
@@ -44,17 +71,21 @@ dsh-live-compact --version
44
71
 
45
72
  ## 门禁和复测
46
73
 
47
- `npm run test:gate` 已过。Issue #19 的卡片地板、一次收尾重写只记一次、65 万和 `CHUNK_MIN` 1000 不动,以及空闲时不再重扫全日志,都在这道门禁里。连模型的 8 组 31 轮没有重跑。这次不改填日志题的判分,也不改切哪一段。下面四张图是 1.0.9 那次实测,1.1.0 沿用。
74
+ `npm run test:gate` 已过。Windows 取权限、宿主不退出、重启只补最近 3 轮、长 `read` 只切自己那一轮,都在这道门禁里。65 万、3.2 万、`CHUNK_MIN` 1000、云端默认值没改。
75
+
76
+ 这轮连的是插件版。10 组同一套 31 轮填日志,组内串行,两个模型同时开。原版 DSH 压缩对比留到 1.1.2。Coder 390 27B 这轮没跑,后补。测评图在下面,也在 [v1.1.1](https://github.com/Tranquillitatis-Lab/dsh-live-compact/releases/tag/v1.1.1)。
48
77
 
49
- 云端看题对不对,以及每轮第一个 `step_end` 的缓存命中。本地看题对不对和切掉的 token。DS V4.1 Flash,8 组,184/184,跳过 0,缓存 98.22%,题末最高 35334,切掉 97306,估算费用 $0.208。GLM 5.3 Flash,同一套 8 组,184/184,同一公式缓存 74.66%,切掉 481483。第 6、8 组几乎每轮只读一次全文,规则没有为它改。Coder 390 27B,3 组 31 轮,填日志 69/69,切掉 20635,都在第 30 轮。墙钟 29 分、20 分、86 分。费用是离峰用量估算,不是账单。
78
+ 看图时,先看日志题答对多少,再看首步缓存和压缩节省。首步缓存取每轮第一个 `step_end`,把命中 token 加起来,再除以这些请求的总输入 token。压缩节省是 `spine-cuts.jsonl` 里 `saved` 的合计。压缩轮次耗时是发生压缩的各轮总耗时之和,压缩操作本身为毫秒级。任务墙钟是从第 1 轮开始到第 31 轮结束的实际用时。结束上下文取最后一轮的 `totalTokens`,包含此前保留下来的内容。
50
79
 
51
- ![汇总分题](docs/slide-110-by-question.jpg)
80
+ DS V4.1 Flash 跑了 10 组,填日志 230/230,跳过 0。首步缓存 98.64%,压缩节省 65 180 token,总用时 36 分 18 秒。多数组结束在 3.5 万到 4.6 万 token。第 1 组读了 25 次全文,4 次压缩共节省 21 493 token,结束时仍有 282 871。第 2 组几乎只用 bash,只读了一次全文,未触发压缩,结束时是 37 781。
52
81
 
53
- ![DS V4.1 Flash](docs/slide-110-ds.jpg)
82
+ GLM 5.3 Flash 用了同一个插件、同一套题。有 7 组填日志达到 23/23。第 5、6 组全程连接失败。第 7 组前段连接失败,完成 14/23。有用量记录的轮次,首步缓存是 98.97%。第 1、2 组主要用 grep,未触发压缩,结束时分别是 39 511 和 29 987 token。第 4、9 组反复读全文,结束时分别是 285 535 和 281 515,和 DS 第 1 组很像。可比的 7 组里,DS 每组都更快。
54
83
 
55
- ![GLM 5.3 Flash](docs/slide-110-glm.jpg)
84
+ ![插件版对比](docs/slide-111-overview.jpg)
56
85
 
57
- ![Coder 390 27B](docs/slide-110-390.jpg)
86
+ ![DS V4.1 Flash](docs/slide-111-ds.jpg)
87
+
88
+ ![GLM 5.3 Flash](docs/slide-111-glm.jpg)
58
89
 
59
90
  ## 1.0.9
60
91
 
@@ -225,20 +256,24 @@ dsh-live-compact --version
225
256
  - 去掉「已确认」。摘要开头写明:这是旧内容摘录,不是新指令,也不是你的授权。
226
257
  - 这一刀如果没有合格的摘要,就整段留下,不去调模型。卡片可以很短。
227
258
  - 备份只有自己能读。文件名不带会话号。本机路径不发给模型。备份默认上限 256MB。满了先删最老的,删不出空间才不切。
228
- - 2 万和 65 万没动。配置里的 0.8 只管官方大压缩,不管这一刀。想改小切的起点,只能在启动前设 `SPINE_MIN_TOKENS`。
259
+ - 2 万和 65 万没动。配置里的 0.8 只管官方大压缩,不管这一刀。想改小切的起点,用下面的设置界面改 `minTokens`(老的 `SPINE_MIN_TOKENS` 仍然有效)。
229
260
 
230
261
  ## 环境变量
231
262
 
232
263
  | 变量 | 默认值 | 作用 |
233
264
  |---|---:|---|
234
- | `SPINE_THRESHOLD_RATIO` | 0.8 | 预设中官方大压缩的触发比例,在 `gate.js` 加载时读取。 |
235
- | `SPINE_HEADROOM_TOKENS` | 65536 | 预设中官方大压缩的预留空间(token),在 `gate.js` 加载时读取。 |
236
- | `SPINE_RETAIN_TOKENS` | 8192 | 预设中官方大压缩的保留量(token),在 `gate.js` 加载时读取。 |
265
+ | `SPINE_THRESHOLD_RATIO` | 0.8 | 官方大压缩的触发比例,构造引擎或加载预设时读取。 |
266
+ | `SPINE_HEADROOM_TOKENS` | 65536 | 官方大压缩的预留空间(token),构造引擎或加载预设时读取。 |
267
+ | `SPINE_RETAIN_TOKENS` | 8192 | 官方大压缩的保留量(token),构造引擎或加载预设时读取;显式 `retainRatio` 优先。 |
237
268
  | `SPINE_POINTER_TOKENS` | `SPINE_CHUNK_MAX`(默认 32000) | 单个工具结果超过此 token 数才只留指针。 |
238
269
  | `SPINE_MIN_TOKENS` | 20000 | 轮后小切的最低上下文 token 数。 |
239
- | `SPINE_KEEP_ROUNDS` | 3 | 原处保留的最近已结束工具轮数。 |
270
+ | `SPINE_KEEP_ROUNDS` | 3 | 原处保留的最近已结束工具轮数,至少 1 轮;旧非法值回退。 |
240
271
  | `SPINE_KEEP_ROUND_TOKENS` | 32000 | 云端收尾的停止线;最近 3 个已结束工具轮始终留在原文。 |
272
+ | `SPINE_CHUNK_MIN` | 1000 | 一刀至少包含的 token 数。 |
273
+ | `SPINE_CHUNK_MAX` | 32000(`RAIL_TOKENS`) | 一刀最多包含的 token 数,也是指针阈值的默认值。 |
274
+ | `SPINE_COOLDOWN_MS` | 0 | 官方压缩后的额外时间窗口;仅正数开启,未设置、空串、非数字、0 或负数均关闭。 |
241
275
  | `SPINE_SHADOW_DIR` | `~/.dsh/compaction-spine/shadow` | 备份目录;设了 `DSH_HOME` 时以它替代 `~/.dsh`。 |
276
+ | `SPINE_SHADOW_SINGLE_PROCESS` | 关闭 | 仅 `1` 开启专用目录契约;必须另设专用 `SPINE_SHADOW_DIR`,详见下文。 |
242
277
  | `SPINE_SHADOW_MAX_BYTES` | 268435456 | 正数直接作为备份字节上限,优先于 `SPINE_SHADOW_MAX_MB`。 |
243
278
  | `SPINE_SHADOW_MAX_MB` | 256 | 未设置正数字节上限时,按 MB 设置备份上限。 |
244
279
  | `SPINE_ENGINE` | 自动识别 | 设为 `cloud` 或 `local` 可强制切法,其他值忽略。 |
@@ -246,9 +281,12 @@ dsh-live-compact --version
246
281
  | `SPINE_THUNDER_MODEL` | `flash` | 设为 `27b` 且前缀超过 8 万时,每次官方压缩都少发。端侧不要设。 |
247
282
  | `SPINE_ENGINE_FAIL` | 无 | **仅供测试**:用 `import`、`construct`、`after-super` 注入引擎故障。 |
248
283
  | `SPINE_STOCK_FAIL` | 无 | **仅供测试**:设为 `1` 时禁止回退原版引擎。 |
249
- | `SPINE_CHUNK_MIN` | 1000 | 一刀至少包含的 token 数。 |
250
- | `SPINE_CHUNK_MAX` | 32000(`RAIL_TOKENS`) | 一刀最多包含的 token 数,也是指针阈值的默认值。 |
251
- | `SPINE_COOLDOWN_MS` | 0 | 官方压缩后的额外时间窗口;仅正数开启,未设置、空串、非数字、0 或负数均关闭。 |
284
+
285
+ 设置界面中的数值留空或重置会清除显式覆盖。优先级是:**显式配置 > 有效环境变量 > 默认值**;空串、纯空白和非数字环境变量视为未配置,显式 `0` 仍对允许零值的字段有效。未配置字段可显示为空,旁边的说明列出默认值。
286
+
287
+ 插件小切参数在每次轮后维护开始时取得本引擎的一致快照,保存后下一次维护生效,多个引擎互不覆盖。官方大压缩参数沿用宿主的构造时快照,保存后需要重建引擎或会话。宿主支持的 `retainRatio`、`maxTokens`、摘要模型路由、`modelPolicies`、溢出重试等配置继续由宿主校验并保留;`retainRatio` 与 `retainTokens` 互斥。
288
+
289
+ 接管开关只影响未来加载的官方预设。新预设按 **预设引擎显式配置 > 根插件显式配置 > 有效环境变量 > 默认值** 取值;已有预设保留加载时的数值,根设置后续修改需要重新加载预设。原生客户端 slot、资源声明和安装方式沿用现有接口。
252
290
 
253
291
  每个新的 `compact-checkpoint` 只让轮后小切跳过一次,`spine-checkpoint` 不触发这个跳过。
254
292
 
@@ -259,8 +297,8 @@ dsh-live-compact --version
259
297
  从 npm 装。headless 和网页是两个 profile,要各装一次。桌面端是另一个 profile,普通 `dsh plugin --profile desktop` 会被拒绝。
260
298
 
261
299
  ```sh
262
- dsh plugin --profile headless add dsh-live-compact@1.1.0
263
- dsh plugin --profile web add dsh-live-compact@1.1.0
300
+ dsh plugin --profile headless add dsh-live-compact@1.1.2
301
+ dsh plugin --profile web add dsh-live-compact@1.1.2
264
302
  ```
265
303
 
266
304
  想先装到本机看版本:
@@ -270,12 +308,12 @@ npm install -g dsh-live-compact
270
308
  dsh-live-compact --version
271
309
  ```
272
310
 
273
- 备用:上不了 npm 的,用国内镜像站的包。1.0.2 到 1.0.9 的旧链接仍在。
311
+ 旧版 1.1.1 的国内镜像安装命令:
274
312
 
275
313
  ```sh
276
- dsh plugin --profile headless add https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.1.0.tgz
277
- dsh plugin --profile web add https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.1.0.tgz
278
- npm install -g --force "https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.1.0.tgz"
314
+ dsh plugin --profile headless add https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.1.1.tgz
315
+ dsh plugin --profile web add https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.1.1.tgz
316
+ npm install -g --force "https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.1.1.tgz"
279
317
  dsh-live-compact --version
280
318
  ```
281
319
 
@@ -283,7 +321,7 @@ dsh-live-compact --version
283
321
 
284
322
  DSH-TUI 同样换不掉预设里的原版压缩,先不要装。
285
323
 
286
- 被切掉的原文会写到 `~/.dsh/compaction-spine/shadow`,只有自己能读。上限 256MB。快满时删最老的备份。卡片里的序号是会话日志的事件号。要在当前会话的备份里找一行,用 `import { searchShadow } from "dsh-live-compact/shadow-search"`。
324
+ 被切掉的原文会写到 `~/.dsh/compaction-spine/shadow`。Unix 权限为目录 0700 / 文件 0600;Windows 新建/修正后的备份允许当前用户、SYSTEM、Administrators,历史文件及缓存限制见上面的 ACL 说明。上限 256MB。快满时删最老的备份。卡片里的序号是会话日志的事件号。要在当前会话的备份里找一行,用 `import { searchShadow } from "dsh-live-compact/shadow-search"`。
287
325
 
288
326
  `dsh-live-compact stats` 的 `saved` 是切完当下少掉的 token,`rewrite` 是同一次收尾实际要重写的尾巴,只记一次。同一步连切不会按刀数把尾巴乘上去。这两个数口径不同,比值不是盈亏。桌面端没有终端,`rg "spine cut"` 只在终端输出里找得到。逐刀明细在 shadow 目录的 `spine-cuts.jsonl`。
289
327
 
@@ -301,4 +339,18 @@ rm -f ~/.dsh/compaction-spine/shadow/*/*.txt ~/.dsh/compaction-spine/shadow/*.tx
301
339
 
302
340
  设了 `DSH_HOME` 或 `SPINE_SHADOW_DIR` 时,目录在那边。删不掉旧文件时,这一刀不切,同一次会话只警告一次。
303
341
 
342
+ 默认目录(包括 `DSH_HOME` 下的目录)和未声明独占的目录,每次仍执行完整轮换扫描,可继续与旧版 writer 共用。仅设置 `SPINE_SHADOW_DIR` 不开启估算。需要减少扫描时,可将它指向一个**专用本地目录**,并另设 `SPINE_SHADOW_SINGLE_PROCESS=1`;即使显式指定默认目录,也不会开启。这个声明要求整个目录只由本进程的这一份插件使用,不能与旧版、不遵守协调协议的 writer、外部修改目录树或正文的程序共用。自定义代码调用底层备份函数时,必须传入同一 `root`,不能把会话子目录当根。无法满足契约时保持默认设置。
343
+
344
+ 专用根首次完整扫描后,只有目录身份、容量和所有权不变、预计用量严格低于 80%,且完整校准还不到 60 秒时,才省去正文遍历。60 秒到期后,在下一次同步轮转时重新扫描;没有后台定时清理。快返回和写入都不会延长有效期。时钟异常或回退也改走全扫。扫描不完整或计量不合法时拒绝本次需要容量准入的备份,保留原内容,不用漏算后的数字放行。
345
+
346
+ 正常锁路径下,所有普通 raw/cut/official 写入先在根目录取得原子锁并检查协作标记;cut/official 的容量检查和正文写入也在同一临界区。按 UTF-8 或 Buffer 的实际正文长度保守计账,覆盖写和失败清理不随意扣回,完整扫描后再校正。raw 沿用原来的捕获规则,写入本身不触发容量轮转;连续 raw 写入可能先超过限额,由下一次轮转处理。容量沿用正文口径,不含伴随 meta、owner、统计或锁文件,不是整个目录的物理占用硬配额。超大备份例外和删除顺序、引用保护不变;其他会话进程只有明确已死亡才按原规则清理,权限不足或 owner 状态不明时保守保留。共享目录仍可能有同步扫描停顿。
347
+
348
+ `.spine-rotation.owner` 保存随机生命周期标识,第二个参与进程、worker 或插件副本(含路径别名)会先创建 `.spine-rotation.shared`,再写正文;之后此根一直退回完整扫描,另一进程退出也不自动恢复估算。该标记只检测协作方,不能发现旧版或外部 writer。锁由同目录临时文件完整写入、刷盘并关闭后,通过原子硬链接发布为 `.spine-rotation.lock`,包含持有者 PID 和每次事务唯一的随机标识。发布成功后的临时名称清理失败不会丢失锁的精确所有权,也不会阻断后续备份;可能残留不参与轮换的临时名称。
349
+
350
+ 仅当硬链接操作本身返回 `ENOTSUP`、`EOPNOTSUPP` 或 `ENOSYS` 时,先刷盘 shared 围栏,再确认没有先前的活跃或潜在独占事务,才允许原来的完整轮换和 raw/cut/official/游标写入;此根不再启用独占估算。正文写入前继续核验围栏和锁状态。`EPERM`、`EACCES`、`EIO`、`EXDEV`、`EINVAL` 等不明确错误,以及其他步骤返回的 unsupported 错误,都拒绝本次备份。该回退沿用旧共享并发语义,不提供独占容量保证。
351
+
352
+ 活跃锁、PID 已复用、旧版空目录锁、记录损坏或无法确认死亡时,本次备份被拒绝,原文保留;不按锁龄抢占,`EPERM` 和未知错误也不代表死亡。只有 `process.kill(pid, 0)` 明确返回 `ESRCH`,才先持久化 shared 标记、清除估算,并将该锁的完整身份原子记录到 `.spine-rotation.recovered`;硬链接不支持时以 `.spine-rotation.recovered-<pid>-<事务标识>` 空文件的完整名称记录并刷盘,避免半写 JSON。原锁始终保留作围栏,不删除、不替换;之后所有协作实例对这个已确认死亡的身份均走原来的完整轮换和正文写入,PID 后来复用也不改变该身份已经死亡的事实。恢复后共享操作沿用原来的并发语义,不再提供独占容量临界区。标记文件均刷盘,POSIX 另刷目录;Windows 的 Node 接口不能刷目录句柄,不承诺突然断电后的目录项持久性。
353
+
354
+ 正常重启没有遗留锁时,旧 owner 仍触发共享回退,不自动替换或继承旧估算。重新启用独占估算应选新专用目录,或仅在所有使用者停止后清除 `.spine-rotation.lock`、`.spine-rotation.owner`、`.spine-rotation.shared`、`.spine-rotation.recovered` 及该根的事务专属 recovered 空文件。旧版空锁等不明确的记录也只能在确认全部使用者停止后人工处理。与不遵守协议的旧版 writer 共用时,仍不得声明专用目录契约。
355
+
304
356
  MIT,见 `LICENSE`。
package/adapter.js ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * 把所有对上游 DSH 的依赖收敛到此处,以减轻未来版本变更的适配压力。
3
+ * 后续新增的上游依赖也都集中在这里,其它文件不出现 DSH 包名。
4
+ * Every upstream DSH dependency belongs in this file, including future ones.
5
+ */
6
+
7
+ export const STOCK_ENGINE = "@deepseek-ai/dsh-compaction-basic";
8
+
9
+ export const loadBase = () => import("@deepseek-ai/dsh-compaction-basic");
10
+
11
+ export const loadCompaction = () => import("@deepseek-ai/dsh-compaction");
12
+
13
+ export const loadLlm = () => import("@deepseek-ai/dsh-llm");
14
+
15
+ // 官方设置界面读的是 schemastery schema,构造器同样从这里取。
16
+ export const loadSchema = () => import("@deepseek-ai/schemastery");
package/client.js ADDED
@@ -0,0 +1,145 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-live-compact",
3
+ factory(require) {
4
+ /** 全走宿主原生接口的设置界面。 */
5
+ const React = require("react");
6
+ const { SettingsFormModel, settingsNumberField, SettingsForm, SettingsValueField, Switch } = require("@deepseek-ai/dsh-client-ui-primitives");
7
+ const h = React.createElement;
8
+
9
+ const NS = "compaction-spine";
10
+ const GROUPS = [
11
+ { id: "basic", keys: ["minTokens", "pointerTokens", "keepRounds"] },
12
+ {
13
+ id: "advanced",
14
+ keys: [
15
+ "chunkMin", "chunkMax", "keepRoundTokens", "bigGate", "cooldownMs",
16
+ "thresholdRatio", "headroomTokens", "retainTokens", "compactionRetries",
17
+ ],
18
+ },
19
+ ];
20
+ const KEYS = GROUPS.flatMap((group) => group.keys);
21
+ const PRESET_FIELD = {
22
+ field: "takeoverPresets",
23
+ format: (value) => String(value),
24
+ parse: (text) => ({ kind: "set", value: text === "true" }),
25
+ };
26
+ const LABELS = {
27
+ minTokens: ["起切 token 数", "默认 20000"],
28
+ pointerTokens: ["大结果只留指针", "0 = 跟随一刀上限"],
29
+ keepRounds: ["保留原文轮数", "至少 1 轮,默认 3"],
30
+ chunkMin: ["一刀最小 token", "默认 1000"],
31
+ chunkMax: ["一刀最大 token", "默认 32000"],
32
+ keepRoundTokens: ["收尾停止线", "默认 32000"],
33
+ bigGate: ["绝对停切线", "默认 650000"],
34
+ cooldownMs: ["官方压缩后冷却 ms", "0 = 只跳过一次"],
35
+ thresholdRatio: ["官方大压缩触发比例", "默认 0.8"],
36
+ headroomTokens: ["官方大压缩预留", "默认 65536"],
37
+ retainTokens: ["官方大压缩保留", "默认 8192"],
38
+ compactionRetries: ["官方大压缩重试", "默认 0"],
39
+ };
40
+
41
+ const zh = {
42
+ takeoverPresets: "接管官方预设",
43
+ takeoverHint: "将官方预设中的压缩引擎换成本插件。保存后仅影响新加载的预设;已有会话需重建。关闭不影响根层压缩引擎。",
44
+ summary: "20000 起切、3 轮留原文、650000 停",
45
+ basic: "常用",
46
+ advanced: "高级设置",
47
+ save: "保存",
48
+ saving: "保存中…",
49
+ invalid: "请填有效数字;留空清除覆盖,使用环境变量或默认值。",
50
+ unavailable: "当前配置不可写",
51
+ saveFailed: "保存失败,修改已保留,请检查后重试。",
52
+ overridden: "已覆盖",
53
+ reset: "恢复默认",
54
+ unavailableHost: "宿主没有提供这一项配置",
55
+ };
56
+ const en = {
57
+ takeoverPresets: "Take over official presets",
58
+ takeoverHint: "Replace compression inside official presets. Applies to newly loaded presets after saving; recreate existing sessions. The root engine stays active when off.",
59
+ summary: "cuts at 20000, keeps 3 rounds, stops at 650000",
60
+ basic: "Common",
61
+ advanced: "Advanced",
62
+ save: "Save",
63
+ saving: "Saving…",
64
+ invalid: "Enter a valid number; leave blank to use the environment or default.",
65
+ unavailable: "Configuration is not writable",
66
+ saveFailed: "Save failed; your edits have been kept.",
67
+ overridden: "Overridden",
68
+ reset: "Reset to default",
69
+ unavailableHost: "The host does not serve this namespace",
70
+ };
71
+
72
+ function SettingsPage(props) {
73
+ const { t } = props;
74
+ const state = props.useLiveCompact((snapshot) => snapshot);
75
+ if (props.view === "summary") return t("summary");
76
+ const section = (group) => group.keys.map((name) => {
77
+ const [label, hint] = LABELS[name];
78
+ return h(SettingsValueField, {
79
+ key: name,
80
+ id: `live-compact-${name}`,
81
+ label,
82
+ hint,
83
+ numeric: true,
84
+ disabled: !state.writable || state.saving,
85
+ overriddenLabel: t("overridden"),
86
+ resetLabel: t("reset"),
87
+ invalidLabel: t("invalid"),
88
+ ...state[name],
89
+ onEdit: (text) => props.edit(name, text),
90
+ onReset: () => props.resetField(name),
91
+ });
92
+ });
93
+ return h(SettingsForm, {
94
+ state,
95
+ labels: {
96
+ unavailable: t("unavailableHost"),
97
+ readOnly: t("unavailable"),
98
+ saveFailed: t("saveFailed"),
99
+ save: t("save"),
100
+ saving: t("saving"),
101
+ },
102
+ onSave: props.save,
103
+ onDiscard: props.discard,
104
+ },
105
+ h("h4", null, t("basic")),
106
+ h("label", { style: { display: "flex", alignItems: "center", gap: "0.75rem" } },
107
+ t("takeoverPresets"),
108
+ h(Switch, {
109
+ label: t("takeoverPresets"),
110
+ checked: state.takeoverPresets.text === "true",
111
+ disabled: !state.writable || state.saving,
112
+ onChange: (checked) => props.edit("takeoverPresets", String(checked)),
113
+ })),
114
+ h("p", null, t("takeoverHint")),
115
+ section(GROUPS[0]),
116
+ h("details", null,
117
+ h("summary", null, t("advanced")),
118
+ section(GROUPS[1])));
119
+ }
120
+
121
+ return {
122
+ inject: ["slots", "locale", "configForms"],
123
+ apply(ctx) {
124
+ const t = ctx.locale.bind("liveCompact");
125
+ ctx.effect(() => ctx.locale.register("liveCompact", { zh, en }), "live-compact: settings dictionary");
126
+ const model = new SettingsFormModel(ctx.configForms.get(NS), [PRESET_FIELD, ...KEYS.map(settingsNumberField)]);
127
+ const store = model.bind(() => ({
128
+ ...model.shell(),
129
+ takeoverPresets: model.field("takeoverPresets"),
130
+ ...Object.fromEntries(KEYS.map((name) => [name, model.field(name)])),
131
+ }));
132
+ ctx.effect(() => () => model.dispose(), "live-compact: form subscription");
133
+ ctx.effect(() => ctx.configForms.whileServed([NS], () => ctx.slots.inject("plugins.row.config", () => ctx.slots.register(
134
+ {
135
+ name: "plugins.row.config",
136
+ key: "dsh-live-compact#compaction-spine",
137
+ locale: "liveCompact",
138
+ inject: () => ({ hooks: { liveCompact: store }, ...model.actions() }),
139
+ },
140
+ SettingsPage,
141
+ ))), "live-compact: settings page");
142
+ },
143
+ };
144
+ },
145
+ });
package/cooldown.js CHANGED
@@ -1,16 +1,16 @@
1
1
  // cooldown.js — 「某类系统事件刚发生过,先让一让」的通用判定
2
2
  //
3
3
  // 【为什么单独成文件】
4
- // 这个判断与压缩引擎、卡片格式、切块选择都无关,只有两个输入:会话的只读节点表 + 一个
5
- // `source.kind` 字符串。放成独立文件后:
4
+ // 这个判断与压缩引擎、卡片格式、切块选择无关,只读取会话节点、恢复边界和
5
+ // `source.kind`。放成独立文件后:
6
6
  // · 调用方(index.js)只加一行判定,不碰任何既有逻辑
7
- // · 整份文件可以原样复制到别的项目(不认识压缩、不 import 宿主包、无全局状态)
7
+ // · 不 import 宿主包;只有一次性跳过 helper 共享 Session 弱引用记录,以支持热重载
8
8
  //
9
9
  // 【两个判据,主判据是「这条官方卡片处理过没有」】
10
10
  // 只看时间窗口会漏掉一种真实情形:官方压缩后长时间没消息(比如一小时),再来消息时
11
11
  // 窗口早过了,spine 立刻切——而那正是要挡的一刀(官方摘要还在 surface 里,容易被卷走)。
12
- // 所以主判据用 seq:每出现一条**新的**官方卡片,调用方就跳过一次动作并记住该 seq;
13
- // 同一条卡片只跳一次,不会永久压制。
12
+ // 所以主判据用 seq:每出现一条实时的新官方卡片,就跳过一次动作并记住该 seq;
13
+ // 恢复边界之前的历史卡片只建立基线,不再跳过。同一条实时卡片只跳一次。
14
14
  // 时间窗口作为可选的额外兜底(`SPINE_COOLDOWN_MS`,默认 0 = 只用 seq 语义),
15
15
  // 用于「压缩后短时间内反复 idle」的场景。
16
16
  // 两者都不引入轮次概念——轮次要定义「什么算一轮」「中断算不算」,成本高且难测。
@@ -21,6 +21,22 @@ export const DEFAULT_COOLDOWN_MS = 300000;
21
21
  /** 官方压缩卡片使用的 `source.kind`(宿主契约字段,不是文本标记)。 */
22
22
  export const OFFICIAL_COMPACTION_KIND = "compact-checkpoint";
23
23
 
24
+ // Session identity survives engine/preset replacement, LRU eviction and module
25
+ // hot reload. Weak keys retain no completed Session, and nothing is persisted.
26
+ const seenKey = Symbol.for("dsh-live-compact.official-compaction-seen");
27
+ const officialSeen = globalThis[seenKey] ??= new WeakMap();
28
+
29
+ export function skipLiveOfficialCompaction(session) {
30
+ const seq = lastSourceKindSeq(session, OFFICIAL_COMPACTION_KIND);
31
+ if (seq === undefined || officialSeen.get(session) === seq) return false;
32
+ officialSeen.set(session, seq);
33
+ const boundary = session.firstLiveSeq;
34
+ // Older hosts without a valid lifecycle boundary keep once-per-card
35
+ // behavior. Never infer history from timestamps or captureNew replay.
36
+ const knownBoundary = Number.isSafeInteger(boundary) && boundary >= 0;
37
+ return !knownBoundary || seq >= boundary;
38
+ }
39
+
24
40
  /**
25
41
  * 解析冷却窗口配置。
26
42
  * @param {Record<string, string|undefined>} [env] 环境变量表(测试可注入)
package/cordis.patch.yml CHANGED
@@ -1,18 +1,14 @@
1
1
  # Host stock stays disabled so this engine can register ctx.compaction.
2
- # Presets are not injected, so a missing gate cannot leave them waiting.
2
+ # The entry installs its preset listener before constructing the engine.
3
+ # takeoverPresets controls future preset loads, not existing session engines.
3
4
  - id: compaction-basic
4
5
  disabled: true
5
6
  - insert:
6
7
  - id: compaction-spine
7
8
  name: dsh-live-compact
8
9
  config:
9
- auto: true
10
- # These three values are also copied onto the web preset engine by gate.js.
11
- thresholdRatio: 0.8
12
- headroomTokens: 65536
13
- retainTokens: 8192
14
- compactionRetries: 0
15
- - id: live-compact-gate
16
- name: dsh-live-compact/gate
10
+ takeoverPresets: true
11
+ # Runtime resolves explicit settings > environment > the 1.1.1 defaults.
12
+ # Keep defaults out of the base patch so reset can restore env fallback.
17
13
 
18
14
 
package/gate.js CHANGED
@@ -1,16 +1,11 @@
1
1
  import { createRequire } from "node:module";
2
+ import { STOCK_ENGINE as STOCK } from "./adapter.js";
3
+ import { configSnapshot, engineConfig } from "./settings.js";
2
4
 
3
- const STOCK = "@deepseek-ai/dsh-compaction-basic";
4
5
  const OURS = "dsh-live-compact";
5
6
  const PRESET_IDS = new Set(["standard", "ptc", "minimal", "cordis", "preset-standard", "preset-ptc", "preset-minimal", "preset-cordis"]);
6
7
  export const GATE_SERVICE = "liveCompactPresetGate";
7
- export const PRESET_ENGINE_CONFIG = {
8
- auto: true,
9
- thresholdRatio: Number(process.env.SPINE_THRESHOLD_RATIO || 0.8),
10
- headroomTokens: Number(process.env.SPINE_HEADROOM_TOKENS || 65536),
11
- retainTokens: Number(process.env.SPINE_RETAIN_TOKENS || 8192),
12
- compactionRetries: 0,
13
- };
8
+ export const PRESET_ENGINE_CONFIG = Object.freeze(engineConfig());
14
9
 
15
10
  const require = createRequire(import.meta.url);
16
11
 
@@ -37,38 +32,43 @@ function isPreset(node) {
37
32
  return Boolean(node.config && typeof node.config === "object" && !Array.isArray(node.config) && PRESET_IDS.has(node.config.id));
38
33
  }
39
34
 
40
- function withEngineConfig(entry) {
41
- const previous = entry.config && typeof entry.config === "object" && !Array.isArray(entry.config) ? entry.config : {};
42
- return { ...PRESET_ENGINE_CONFIG, ...previous };
35
+ function withEngineConfig(entry, rootConfig) {
36
+ const previous = entry.config && typeof entry.config === "object" && !Array.isArray(entry.config) ? configSnapshot(entry.config) : {};
37
+ const { takeoverPresets: _takeover, ...root } = configSnapshot(rootConfig);
38
+ const merged = { ...root, ...previous };
39
+ // 较高优先级显式选择 retention 模式时,移除继承的互斥字段。
40
+ if (previous.retainRatio !== undefined && previous.retainTokens === undefined) delete merged.retainTokens;
41
+ if (previous.retainTokens !== undefined && previous.retainRatio === undefined) delete merged.retainRatio;
42
+ return { ...merged, ...engineConfig(merged) };
43
43
  }
44
44
 
45
- function rewriteEntry(entry, loadable) {
45
+ function rewriteEntry(entry, loadable, rootConfig) {
46
46
  if (!entry || typeof entry !== "object") return entry;
47
47
  const next = { ...entry };
48
48
  if (entry.name === STOCK) {
49
49
  if (!loadable) return entry;
50
50
  next.name = OURS;
51
- next.config = withEngineConfig(entry);
51
+ next.config = withEngineConfig(entry, rootConfig);
52
52
  return next;
53
53
  }
54
- if (Array.isArray(entry.config)) next.config = entry.config.map((item) => rewriteEntry(item, loadable));
55
- else if (entry.config && typeof entry.config === "object") next.config = rewriteNode(entry.config, loadable);
54
+ if (Array.isArray(entry.config)) next.config = entry.config.map((item) => rewriteEntry(item, loadable, rootConfig));
55
+ else if (entry.config && typeof entry.config === "object") next.config = rewriteNode(entry.config, loadable, rootConfig);
56
56
  return next;
57
57
  }
58
58
 
59
- function rewriteNode(node, loadable) {
60
- if (Array.isArray(node)) return node.map((item) => rewriteEntry(item, loadable));
59
+ function rewriteNode(node, loadable, rootConfig) {
60
+ if (Array.isArray(node)) return node.map((item) => rewriteEntry(item, loadable, rootConfig));
61
61
  if (!node || typeof node !== "object") return node;
62
62
  const next = { ...node };
63
- if (Array.isArray(node.plugins)) next.plugins = node.plugins.map((item) => rewriteEntry(item, loadable));
64
- if (Array.isArray(node.config)) next.config = node.config.map((item) => rewriteEntry(item, loadable));
65
- else if (node.config && typeof node.config === "object") next.config = rewriteNode(node.config, loadable);
63
+ if (Array.isArray(node.plugins)) next.plugins = node.plugins.map((item) => rewriteEntry(item, loadable, rootConfig));
64
+ if (Array.isArray(node.config)) next.config = node.config.map((item) => rewriteEntry(item, loadable, rootConfig));
65
+ else if (node.config && typeof node.config === "object") next.config = rewriteNode(node.config, loadable, rootConfig);
66
66
  return next;
67
67
  }
68
68
 
69
- export function rewritePresetConfig(config, loadable = packageLoadable()) {
69
+ export function rewritePresetConfig(config, loadable = packageLoadable(), rootConfig = {}) {
70
70
  if (!isPreset(config)) return config;
71
- return rewriteNode(config, loadable);
71
+ return rewriteNode(config, loadable, rootConfig);
72
72
  }
73
73
 
74
74
  function engineEntry(node) {
@@ -85,30 +85,57 @@ function engineEntry(node) {
85
85
  return null;
86
86
  }
87
87
 
88
- export default class LiveCompactGate {
89
- constructor(ctx) {
90
- const seen = new Set();
91
- ctx.on("internal/config", function (_raw, next) {
92
- const resolved = next();
93
- try {
94
- const loadable = packageLoadable();
95
- const rewritten = rewritePresetConfig(resolved, loadable);
96
- const id = rewritten?.id || rewritten?.config?.id;
97
- if (isPreset(resolved) && id && !seen.has(String(id))) {
98
- seen.add(String(id));
99
- const engine = engineEntry(rewritten);
100
- if (engine) {
101
- const config = engine.config || {};
102
- ctx.logger?.info?.(`spine: 预设 ${id} 已换包 ${engine.name} thresholdRatio=${config.thresholdRatio} headroomTokens=${config.headroomTokens} retainTokens=${config.retainTokens} compactionRetries=${config.compactionRetries}`);
103
- } else if (!loadable) {
104
- ctx.logger?.warn?.(`spine: 本会话未接管预设 ${id},仍用原版压缩。`);
105
- }
106
- }
107
- return rewritten;
108
- } catch {
109
- return resolved;
88
+ // A compatibility gate is provisional. The shallowest real entry owns the one
89
+ // listener; retain its authority while unloaded so nested/legacy reloads cannot
90
+ // silently enable takeover again.
91
+ const installedGates = new WeakMap();
92
+
93
+ function entryDepth(ctx) {
94
+ let depth = 0;
95
+ for (let fiber = ctx.fiber; fiber?.runtime; fiber = fiber.parent.fiber) depth++;
96
+ return depth;
97
+ }
98
+
99
+ export function installPresetGate(ctx, config = {}, { legacy = false } = {}) {
100
+ const depth = entryDepth(ctx);
101
+ const previous = installedGates.get(ctx.root);
102
+ if (previous) {
103
+ if (legacy && !previous.legacy) return;
104
+ if (legacy === previous.legacy && (depth > previous.depth || (depth === previous.depth && previous.active))) return;
105
+ previous.active = false;
106
+ previous.disposeListener?.();
107
+ }
108
+ const gate = { depth, legacy, active: true };
109
+ installedGates.set(ctx.root, gate);
110
+ ctx.effect(() => () => { gate.active = false; });
111
+ const enabled = () => {
112
+ const value = config.takeoverPresets ?? true;
113
+ return typeof value === "boolean" ? value : value.get();
114
+ };
115
+ const seen = new Set();
116
+ gate.disposeListener = ctx.on("internal/config", function (_raw, next) {
117
+ const resolved = next();
118
+ if (!gate.active || !enabled()) return resolved;
119
+ const loadable = packageLoadable();
120
+ const rewritten = rewritePresetConfig(resolved, loadable, config);
121
+ const id = rewritten?.id || rewritten?.config?.id;
122
+ if (isPreset(resolved) && id && !seen.has(String(id))) {
123
+ seen.add(String(id));
124
+ const engine = engineEntry(rewritten);
125
+ if (engine) {
126
+ const config = engine.config || {};
127
+ ctx.logger?.info?.(`spine: 预设 ${id} 已换包 ${engine.name} thresholdRatio=${config.thresholdRatio} headroomTokens=${config.headroomTokens} retainTokens=${config.retainTokens} compactionRetries=${config.compactionRetries}`);
128
+ } else if (!loadable) {
129
+ ctx.logger?.warn?.(`spine: 本会话未接管预设 ${id},仍用原版压缩。`);
110
130
  }
111
- }, { global: true });
112
- ctx.provide(GATE_SERVICE, {});
131
+ }
132
+ return rewritten;
133
+ }, { global: true });
134
+ }
135
+
136
+ // Legacy entry for existing compositions; new installs only register compaction-spine.
137
+ export default class LiveCompactGate {
138
+ constructor(ctx, config) {
139
+ installPresetGate(ctx, config, { legacy: true });
113
140
  }
114
141
  }