dsh-live-compact 1.0.9 → 1.1.1

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
@@ -6,18 +6,26 @@ DSH 把压缩写死在大约 80%。不到这条线,上下文一直堆。到了
6
6
 
7
7
  为什么非要等到 80%?边做边压不行吗?
8
8
 
9
+ ## 云端和端侧
10
+
11
+ 云端(公网地址,如 DeepSeek、GLM):装上即可。不要设置 `SPINE_THUNDER`。看题对不对,以及缓存命中。切点前面的原文不动。
12
+
13
+ 端侧(本机、内网、27B):也可以装。不要设置 `SPINE_THUNDER`。不要拿云端缓存命中来要求它。看题对不对,以及切掉多少 token。本地服务没有云端那种前缀缓存,每次提问都会把当前上下文重新预填充。两万多是当前上下文的大小,不是插件没切。
14
+
15
+ `SPINE_THUNDER` 默认关闭。只有设为 `1` 才走雷霆。再设 `SPINE_THUNDER_MODEL=27b` 时,前缀超过 8 万会每次都少发,一次雷霆变成次次雷霆。端侧不要开。
16
+
9
17
  超过 2 万 token 就切一刀。切的是刚结束的这一轮,不调用模型。切点前面的原文不动,缓存命中还是原来的水准。上下文因此一直被按在较低的位置。雷霆大压缩能拖就拖,拖到尽可能后面。就算哪天满了、真的触发了,掉下来之后小切还在,不会很快又堆回去。
10
18
 
11
19
  headless:
12
20
 
13
21
  ```sh
14
- dsh plugin --profile headless add dsh-live-compact@1.0.6
22
+ dsh plugin --profile headless add dsh-live-compact@1.1.1
15
23
  ```
16
24
 
17
25
  网页和桌面端(共用 web profile,装一次两边都生效):
18
26
 
19
27
  ```sh
20
- dsh plugin --profile web add dsh-live-compact@1.0.6
28
+ dsh plugin --profile web add dsh-live-compact@1.1.1
21
29
  ```
22
30
 
23
31
  想先装到本机看版本:
@@ -27,6 +35,42 @@ npm install -g dsh-live-compact
27
35
  dsh-live-compact --version
28
36
  ```
29
37
 
38
+ ## 1.1.1
39
+
40
+ 1. Windows 上取 SID 和写权限不再走 PATH。固定调用 `%SystemRoot%\System32` 下的 `whoami.exe` 和 `icacls.exe`。环境变量不是盘符绝对路径时,这次备份直接跳过,不退回裸命令名。
41
+ 2. 不再使用需要还原特权的 `icacls /restore`。目录用 `/inheritance:r /grant:r`,给当前用户、SYSTEM、Administrators 三条可继承的完全控制。文件只用 `F`,不带 `(OI)(CI)`。已收紧目录里的文件不再逐条跑 `icacls`。当前用户 SID 和目录权限每个进程只做一次。
42
+ 3. 权限失败不再把宿主打退出。这次备份不落盘,不退回 `chmod`,只提示一次。压缩照常进行。
43
+ 4. 进程重启后补抓工具结果,只补最近 3 轮。已经记下的位置还在,就从那里接着记。记下的位置比当前日志还大,按重启口径补最近 3 轮,不从 0 把整份日志再扫一遍。
44
+ 5. 一轮里有不少于 1000 token 的 `read`,这一刀只切这一轮,不把要留的后 3 轮短结果一起卷进去。这一轮里单个工具结果达到 3.2 万,只留指针。这一轮切不下来,才按原来的规则切更早的轮。
45
+ 6. 65 万、4.8 万、3.2 万、`CHUNK_MIN` 1000、云端默认 `0.8` / `65536` / `8192`、`SPINE_THUNDER` 默认关闭,都没改。没有加「省下的必须盖过重写」这种新门槛。备份目录快满时仍是先扫再判断,这版没改,留到 1.1.2。
46
+
47
+ ## 1.1.0
48
+
49
+ 1. Issue #19 里下一轮输入回到 21 万到 24 万、前缀被打掉的那一刀,是切点走到了已有卡片左边。1.1.0 把最新一张卡片当成地板。往左走到卡片就停。切点右边还有更新的卡片,这一刀不切。已进缓存的前缀不再被后面的刀改写。
50
+ 2. 同一次收尾可以连切,这是现有设计,没有改。改的是记账。以前每一刀都把尾巴算进重写,四刀就把同一段尾巴乘了四次。现在一次收尾只记一次:第一刀之后的前缀,减去后面几刀省下的。`dsh-live-compact stats` 的 `rewrite` 用这个数。逐刀明细在 shadow 目录的 `spine-cuts.jsonl`,只有序号、token 和切点,没有消息原文。
51
+ 3. 没有加「省下的必须盖过重写」这种新门槛。加了会把同一次收尾里后面的刀切掉。65 万停切线、收尾停在 3.2 万、`CHUNK_MIN` 默认 1000、云端默认 `0.8` / `65536` / `8192`,都没改。`SPINE_THUNDER` 仍是默认关闭。
52
+ 4. 今天桌面端长时间卡住,是每回合、每个工具结果都在拷贝整份会话日志,并反复计量。1.1.0 一个空闲回合只拷贝一次日志。工具结果到达时用已经记下的调用编号,不再为了查一次调用而拷贝整份日志。收尾循环复用这一次的任务和调用表。token 计量是开头一次,每真正切下一刀再测一次。没切的回合只测一次。切哪一段没改。
53
+
54
+ ## 门禁和复测
55
+
56
+ `npm run test:gate` 已过。Windows 取权限、宿主不退出、重启只补最近 3 轮、长 `read` 只切自己那一轮,都在这道门禁里。65 万、3.2 万、`CHUNK_MIN` 1000、云端默认值没改。
57
+
58
+ 这轮连的是插件版。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)。
59
+
60
+ 云端看题对不对,以及每轮第一个 `step_end` 的缓存命中。这个命中是这些首步的 `cacheRead / (cacheRead + input)` 加总。切掉的 token 是 `spine-cuts.jsonl` 里 `saved` 的合计。含切刀的轮是这些轮的墙钟合计,刀本身是毫秒。任务墙钟从第 1 轮文件生成算到第 31 轮写完。结束上下文是最后一轮的 `totalTokens`,已经包含前面各轮,不是每轮增量。
61
+
62
+ DS V4.1 Flash,10 组,填日志 230/230,跳过 0。首步缓存 98.64%。切掉 65180。10 组墙钟合计 36 分 18 秒。多数组结束在 3.5 万到 4.6 万。第 1 组 `read` 全文 25 次,正文留在上下文里,四刀只省 21493,结束 282871。第 2 组几乎只用 bash,只 `read` 一次,没切,结束 37781。
63
+
64
+ GLM 5.3 Flash,同一插件、同一套题。第 1、2、3、4、8、9、10 组填日志 23/23。第 5、6 组全程连接失败,不计入。第 7 组前段连接失败,填日志 14/23,不算通过。不要写成 230/230。有用量的轮首步缓存 98.97%。第 1、2 组主要是 grep,没切,结束 39511 和 29987。插件没有把 GLM 切得更多。第 4、9 组也反复 `read` 全文,结束 285535 和 281515,和第 1 组 DS 是同一种形状。可比的 7 组里,DS 每组都更快。
65
+
66
+ ## 1.0.9
67
+
68
+ 1. 云端和本地按当前配置里这一家的地址区分。请求头通常只有 provider 名。配置里的 `baseURL` 优先,没有再看官方 DeepSeek 和内置目录。公网地址按云端切,回环和内网按本地。读不到地址仍按本地。`SPINE_ENGINE=local` 或 `cloud` 可以强制指定。这不改官方大门的 headroom。
69
+ 2. 官方大压缩的三项可以在启动前用环境变量覆盖:`SPINE_THRESHOLD_RATIO`、`SPINE_HEADROOM_TOKENS`、`SPINE_RETAIN_TOKENS`。云端默认仍是 `0.8`、`65536`、`8192`,不按地址改小。TUI 的 `/settings` 没有这一项。要改当前配置,直接改 `cordis.patch.yml` 里的对应字段。
70
+ 3. 发版门禁是 `npm run test:gate`,不连模型。8 组 31 轮在 `test/live-fill`,要连模型。这两部分都在 GitHub 测试集里。
71
+ 4. 测评分两个口径。云端看题对不对,以及每轮第一个 `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%。Coder 390 27B,3 组 31 轮,填日志 69/69,切掉 20635。费用是离峰用量估算,不是账单。
72
+ 5. 安装用 npm 的 `dsh-live-compact@1.0.9`。上不了 npm 时用镜像包 https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.0.9.tgz 。页面在 https://download.merkyorlynn.com/live-compact.html 。测评图在当时的 [v1.0.9](https://github.com/Tranquillitatis-Lab/dsh-live-compact/releases/tag/v1.0.9)。
73
+
30
74
  ![边做边压](docs/slide-cover.jpg)
31
75
 
32
76
  ![为什么非要等到 80%](docs/slide-why.jpg)
@@ -92,32 +136,6 @@ dsh-live-compact --version
92
136
 
93
137
  ![原版对比](docs/slide-stock.jpg)
94
138
 
95
- ## 1.0.9
96
-
97
- 官方大压缩的三项预设参数可以在启动前用环境变量覆盖:`SPINE_THRESHOLD_RATIO`、`SPINE_HEADROOM_TOKENS`、`SPINE_RETAIN_TOKENS`。云端默认不动,仍是 `0.8`、`65536`、`8192`,不按地址自动改小。TUI 的 `/settings` 目前没有这一项。
98
-
99
- 请求头通常只有 provider 名。云端和本地按这一家的地址区分:当前配置里的 `baseURL` 优先,没有再看官方 DeepSeek 和内置目录。公网地址按云端切,回环和内网按本地。读不到地址仍按本地,不猜。这不改官方大门的 headroom。`SPINE_ENGINE=local` 或 `cloud` 仍可强制指定。
100
-
101
- 发版门禁是 `npm run test:gate`,不连模型。8 组 31 轮在 `test/live-fill`,要连模型。这两部分都在 GitHub 测试集里。Pi 移植不在这道门禁里。
102
-
103
- ## 1.0.9 测评
104
-
105
- 云端看题对不对,以及每轮第一个 `step_end` 的缓存命中 `cacheRead / (cacheRead + input)`。本地不看缓存命中,看题对不对和切掉的 token。切掉是 `shadowedTokenCount`。费用是离峰用量估算,不是账单。
106
-
107
- DS V4.1 Flash,8 组 31 轮。184/184,跳过 0。缓存命中 98.22%。题末最高 35334,平均 28984。切掉 97306。估算费用 $0.208。
108
-
109
- GLM 5.3 Flash,同一套 8 组。184/184,跳过 0。同一公式缓存命中 74.66%。第 6、8 组几乎每轮只 `read` 一次全文,刀落在三轮以前的那份全文上,前缀被改写。切掉 481483。这是模型用法,规则没有为它改。
110
-
111
- Coder 390 27B,只跑了 3 组 31 轮,没有八组并行。填日志 69/69。切掉 20635,都在第 30 轮:组 1 是 10503,组 2 是 5106,组 3 是 5026。墙钟 29 分、20 分、86 分。
112
-
113
- ![汇总分题](docs/slide-109-by-question.jpg)
114
-
115
- ![DS V4.1 Flash](docs/slide-109-ds.jpg)
116
-
117
- ![GLM 5.3 Flash](docs/slide-109-glm.jpg)
118
-
119
- ![Coder 390 27B](docs/slide-109-390.jpg)
120
-
121
139
  `cordis.patch.yml` 是静态整块替换,不读这三个环境变量。要在这里改 `headroomTokens`,需直接改配置块,例如:
122
140
 
123
141
  ```yaml
@@ -231,8 +249,8 @@ Coder 390 27B,只跑了 3 组 31 轮,没有八组并行。填日志 69/69。
231
249
  | `SPINE_SHADOW_MAX_BYTES` | 268435456 | 正数直接作为备份字节上限,优先于 `SPINE_SHADOW_MAX_MB`。 |
232
250
  | `SPINE_SHADOW_MAX_MB` | 256 | 未设置正数字节上限时,按 MB 设置备份上限。 |
233
251
  | `SPINE_ENGINE` | 自动识别 | 设为 `cloud` 或 `local` 可强制切法,其他值忽略。 |
234
- | `SPINE_THUNDER` | 关闭 | 设为 `1` 时启用雷霆摘要路径。 |
235
- | `SPINE_THUNDER_MODEL` | `flash` | 设为 `27b` 时使用对应模型类型,其他值仍为 `flash`。 |
252
+ | `SPINE_THUNDER` | 关闭 | 设为 `1` 才走雷霆。端侧不要开。 |
253
+ | `SPINE_THUNDER_MODEL` | `flash` | 设为 `27b` 且前缀超过 8 万时,每次官方压缩都少发。端侧不要设。 |
236
254
  | `SPINE_ENGINE_FAIL` | 无 | **仅供测试**:用 `import`、`construct`、`after-super` 注入引擎故障。 |
237
255
  | `SPINE_STOCK_FAIL` | 无 | **仅供测试**:设为 `1` 时禁止回退原版引擎。 |
238
256
  | `SPINE_CHUNK_MIN` | 1000 | 一刀至少包含的 token 数。 |
@@ -248,8 +266,8 @@ Coder 390 27B,只跑了 3 组 31 轮,没有八组并行。填日志 69/69。
248
266
  从 npm 装。headless 和网页是两个 profile,要各装一次。桌面端是另一个 profile,普通 `dsh plugin --profile desktop` 会被拒绝。
249
267
 
250
268
  ```sh
251
- dsh plugin --profile headless add dsh-live-compact@1.0.9
252
- dsh plugin --profile web add dsh-live-compact@1.0.9
269
+ dsh plugin --profile headless add dsh-live-compact@1.1.1
270
+ dsh plugin --profile web add dsh-live-compact@1.1.1
253
271
  ```
254
272
 
255
273
  想先装到本机看版本:
@@ -259,12 +277,12 @@ npm install -g dsh-live-compact
259
277
  dsh-live-compact --version
260
278
  ```
261
279
 
262
- 备用:上不了 npm 的,用国内镜像站的包。1.0.2 到 1.0.8 的旧链接仍在。
280
+ 备用:上不了 npm 的,用国内镜像站的包。1.0.2 到 1.1.0 的旧链接仍在。
263
281
 
264
282
  ```sh
265
- dsh plugin --profile headless add https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.0.9.tgz
266
- dsh plugin --profile web add https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.0.9.tgz
267
- npm install -g --force "https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.0.9.tgz"
283
+ dsh plugin --profile headless add https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.1.1.tgz
284
+ dsh plugin --profile web add https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.1.1.tgz
285
+ npm install -g --force "https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.1.1.tgz"
268
286
  dsh-live-compact --version
269
287
  ```
270
288
 
@@ -274,6 +292,8 @@ DSH-TUI 同样换不掉预设里的原版压缩,先不要装。
274
292
 
275
293
  被切掉的原文会写到 `~/.dsh/compaction-spine/shadow`,只有自己能读。上限 256MB。快满时删最老的备份。卡片里的序号是会话日志的事件号。要在当前会话的备份里找一行,用 `import { searchShadow } from "dsh-live-compact/shadow-search"`。
276
294
 
295
+ `dsh-live-compact stats` 的 `saved` 是切完当下少掉的 token,`rewrite` 是同一次收尾实际要重写的尾巴,只记一次。同一步连切不会按刀数把尾巴乘上去。这两个数口径不同,比值不是盈亏。桌面端没有终端,`rg "spine cut"` 只在终端输出里找得到。逐刀明细在 shadow 目录的 `spine-cuts.jsonl`。
296
+
277
297
  改上限:
278
298
 
279
299
  ```sh
package/adapter.js ADDED
@@ -0,0 +1,13 @@
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");
package/gate.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createRequire } from "node:module";
2
+ import { STOCK_ENGINE as STOCK } from "./adapter.js";
2
3
 
3
- const STOCK = "@deepseek-ai/dsh-compaction-basic";
4
4
  const OURS = "dsh-live-compact";
5
5
  const PRESET_IDS = new Set(["standard", "ptc", "minimal", "cordis", "preset-standard", "preset-ptc", "preset-minimal", "preset-cordis"]);
6
6
  export const GATE_SERVICE = "liveCompactPresetGate";
package/index.js CHANGED
@@ -13,22 +13,22 @@
13
13
  * Above 650000, small cuts stop for the host gate.
14
14
  */
15
15
  import { randomBytes, randomUUID } from "node:crypto";
16
- import { existsSync, readFileSync } from "node:fs";
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
17
17
  import { createRequire } from "node:module";
18
18
  import { homedir } from "node:os";
19
19
  import { join } from "node:path";
20
20
  import { pathToFileURL } from "node:url";
21
- import { BasicCompactionEngine } from "@deepseek-ai/dsh-compaction-basic";
22
- import { CompactionId, toolPairingBalancedAfter, toolPairingBalancedBefore } from "@deepseek-ai/dsh-compaction";
23
- import { createUserMessage } from "@deepseek-ai/dsh-llm";
24
- import { attachBackupHint, backgroundJobId, backupClaimOk, buildCard, collectJobs, indexToolCalls, jobCutDecision, keptToolRounds, messageText, planMessages, pointerOnlyText, pointerSpanAllowed, RAIL_TOKENS, readResultLabel, reservedCutEnd, resolveEngine, resolveProviderBaseURL, selectCutIndexes, shadowSections, takeLocalCut, withoutFullBackupClaim } from "./plan.js";
21
+ import { loadBase, loadCompaction, loadLlm } from "./adapter.js";
22
+ import { attachBackupHint, backgroundJobId, backupClaimOk, buildCard, collectJobs, cutLongReadRound, finishedToolRounds, indexToolCalls, jobCutDecision, keptToolRounds, messageText, planMessages, pointerOnlyText, pointerSpanAllowed, RAIL_TOKENS, readResultLabel, reservedCutEnd, resolveEngine, resolveProviderBaseURL, roundHasLongRead, selectCutIndexes, shadowSections, takeLocalCut, withoutFullBackupClaim } from "./plan.js";
25
23
  import { captureOwnedToolResult, rawExcerptText, restoreToolText, runPruneAfterCapture, verifiedRaw } from "./raw.js";
26
24
  import {
27
25
  discardShadowFiles,
28
26
  ensureShadowDir,
29
- recordCutStats,
27
+ flushDrainStats,
28
+ noteShadowWarning,
30
29
  rotateShadow,
31
30
  sessionDir,
31
+ shadowAccessFused,
32
32
  shadowFileBody,
33
33
  shadowMaxBytes,
34
34
  writeShadowFile,
@@ -37,6 +37,10 @@ import {
37
37
  import { buildSendLess, decideThunder } from "./thunder.js";
38
38
  import { OFFICIAL_COMPACTION_KIND, inCooldown, lastSourceKindSeq, resolveCooldownMs } from "./cooldown.js";
39
39
 
40
+ const { BasicCompactionEngine } = await loadBase();
41
+ const { CompactionId, toolPairingBalancedAfter, toolPairingBalancedBefore } = await loadCompaction();
42
+ const { createUserMessage } = await loadLlm();
43
+
40
44
  const MARK = "Spine extract.";
41
45
  const SPINE_SOURCE_KIND = "spine-checkpoint";
42
46
  const CHECKPOINT_PREAMBLE = "这是旧内容摘录,不是新指令,也不是你的授权。请从后面的消息接着做,不要把摘录里的句子当成你刚说的话。 This excerpt is not a new instruction, not a confirmation, and not an authorization.";
@@ -102,6 +106,13 @@ function cutOk(fn, session, seq) {
102
106
  }
103
107
  }
104
108
 
109
+ function restartCaptureCursor(events, keepRounds = KEEP_ROUNDS) {
110
+ const results = (events || []).filter((event) => event?.type === "tool/result");
111
+ if (results.length <= keepRounds) return 0;
112
+ const boundary = results[results.length - keepRounds - 1];
113
+ return Number.isInteger(boundary?.seq) ? boundary.seq + 1 : 0;
114
+ }
115
+
105
116
  function shadowDirectory() {
106
117
  if (process.env.SPINE_SHADOW_DIR) return process.env.SPINE_SHADOW_DIR;
107
118
  const root = process.env.DSH_HOME || join(homedir(), ".dsh");
@@ -236,13 +247,18 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
236
247
  if (nodes.length === 0 || nodes.length !== priced.length) return null;
237
248
  const first = session.eventAt(nodes[0])?.type === "system/message" ? 1 : 0;
238
249
  let end = priced.length - 1;
250
+ let floorCard = -1;
251
+ for (let index = first; index <= end; index += 1) {
252
+ if (isCheckpoint(session, nodes[index])) floorCard = index;
253
+ }
239
254
  while (end >= first) {
255
+ if (floorCard >= 0 && end <= floorCard) return null;
240
256
  const event = session.eventAt(nodes[end]);
241
257
  const shortTail = event?.type === "assistant/message" || (event?.type === "user/message" && priced[end].tokens < 400);
242
258
  if (!shortTail) break;
243
259
  end -= 1;
244
260
  }
245
- while (end >= first && isCheckpoint(session, nodes[end])) end -= 1;
261
+ if (floorCard >= 0 && end <= floorCard) return null;
246
262
  while (end >= first && !cutOk(toolPairingBalancedAfter, session, nodes[end])) end -= 1;
247
263
  let keptTokens = 0;
248
264
  const visible = [];
@@ -251,9 +267,17 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
251
267
  const toolCalls = event?.type === "assistant/message"
252
268
  ? (event.data?.message?.content || []).filter((block) => block.type === "tool-call").length
253
269
  : 0;
254
- visible.push({ index, type: event?.type === "tool/result" ? "tool" : "other", toolCalls });
270
+ const toolCallId = event?.data?.message?.toolCallId || "";
271
+ const call = toolCallId ? context.calls?.get?.(toolCallId) : null;
272
+ visible.push({
273
+ index,
274
+ type: event?.type === "tool/result" ? "tool" : "other",
275
+ toolCalls,
276
+ longRead: event?.type === "tool/result" && call?.name === "read" && (priced[index].tokens || 0) >= CHUNK_MIN,
277
+ });
255
278
  }
256
279
  const rounds = keptToolRounds(visible, KEEP_ROUNDS);
280
+ const newestLong = [...finishedToolRounds(visible)].reverse().find((round) => roundHasLongRead(visible, round));
257
281
  const span = rounds.length ? { start: rounds[0].start, end: rounds.at(-1).end } : null;
258
282
  const cutEnd = span ? reservedCutEnd(visible[span.start].index, first) : null;
259
283
  if (span && cutEnd === null) return null;
@@ -261,10 +285,19 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
261
285
  const keptEnd = visible[span.end].index;
262
286
  for (let index = visible[span.start].index; index <= keptEnd; index += 1) keptTokens += priced[index].tokens;
263
287
  end = cutEnd;
264
- while (end >= first && (isCheckpoint(session, nodes[end]) || isUser(session, nodes[end]))) end -= 1;
265
- while (end >= first && !cutOk(toolPairingBalancedAfter, session, nodes[end])) end -= 1;
288
+ while (end >= first && isUser(session, nodes[end]) && !(floorCard >= 0 && end - 1 <= floorCard)) end -= 1;
289
+ if (floorCard >= 0 && end <= floorCard) return null;
290
+ if (isCheckpoint(session, nodes[end])) return null;
291
+ while (end >= first && !cutOk(toolPairingBalancedAfter, session, nodes[end])) {
292
+ if (floorCard >= 0 && end - 1 <= floorCard) return null;
293
+ end -= 1;
294
+ }
295
+ if (floorCard >= 0 && end <= floorCard) return null;
266
296
  }
267
- if (end < first || isCheckpoint(session, nodes[end])) return null;
297
+ const longSpan = newestLong && visible[newestLong.start].index > floorCard
298
+ ? { start: visible[newestLong.start].index, end: visible[newestLong.end].index }
299
+ : null;
300
+ if (!longSpan && (end < first || isCheckpoint(session, nodes[end]))) return null;
268
301
  const spanPieces = (spanStart, spanEnd) => {
269
302
  const pieces = [];
270
303
  let pendingCommand = "";
@@ -324,7 +357,7 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
324
357
  balancedAfter: cutOk(toolPairingBalancedAfter, session, seq),
325
358
  };
326
359
  });
327
- const picked = selectCutIndexes(items, {
360
+ const cutOptions = {
328
361
  engine: context.engine || "cloud",
329
362
  chunkMin: CHUNK_MIN,
330
363
  chunkMax: CHUNK_MAX,
@@ -333,7 +366,20 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
333
366
  totalTokens: context.totalTokens,
334
367
  drain: context.drain === true,
335
368
  urgentTokens: URGENT_TOKENS,
336
- });
369
+ };
370
+ let usedLongCut = false;
371
+ let picked = null;
372
+ if (longSpan) {
373
+ const longPicked = cutLongReadRound(items, longSpan.start, longSpan.end, cutOptions);
374
+ if (longPicked && longPicked.start >= longSpan.start && longPicked.end <= longSpan.end) {
375
+ picked = longPicked;
376
+ usedLongCut = true;
377
+ }
378
+ }
379
+ if (!picked) {
380
+ if (end < first || isCheckpoint(session, nodes[end])) return null;
381
+ picked = selectCutIndexes(items, cutOptions);
382
+ }
337
383
  if (!picked) return null;
338
384
  if (picked.pointerOnly) {
339
385
  const result = session.eventAt(nodes[picked.end]);
@@ -349,10 +395,12 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
349
395
  }
350
396
  }
351
397
  if (pairStart < 0) return null;
398
+ if (usedLongCut && pairStart < longSpan.start) return null;
352
399
  if (!pointerSpanAllowed(items, pairStart, picked.end, context.engine || "cloud")) return null;
353
400
  if (!cutOk(toolPairingBalancedBefore, session, nodes[pairStart])) return null;
354
401
  picked.start = pairStart;
355
402
  }
403
+ if (usedLongCut && (picked.start < longSpan.start || picked.end > longSpan.end)) return null;
356
404
  const start = picked.start;
357
405
  end = picked.end;
358
406
  const tokens = items.slice(start, end + 1).reduce((sum, item) => sum + item.tokens, 0);
@@ -380,11 +428,14 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
380
428
  sessionState = new Map();
381
429
  shadowFullWarned = new Set();
382
430
  rawSeen = new Map();
431
+ callIndex = new Map();
432
+ captureCursor = new Map();
383
433
  pruneGuarded = false;
384
434
 
385
435
  constructor(ctx, config) {
386
436
  if (process.env.SPINE_ENGINE_FAIL === "construct") throw new Error("spine: constructor refused");
387
437
  super(ctx, config);
438
+ this.persistCapture = true;
388
439
  if (process.env.SPINE_ENGINE_FAIL === "after-super") throw new Error("spine: after super");
389
440
  try {
390
441
  const pendingCuts = new Set();
@@ -392,20 +443,28 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
392
443
  await Promise.allSettled(pendingCuts);
393
444
  });
394
445
  ctx.on("session/event", (session, event) => {
395
- if (event?.type !== "tool/result") return;
396
- this.captureOne(session, event);
446
+ try {
447
+ this.noteEvent(session, event);
448
+ if (event?.type !== "tool/result") return;
449
+ this.captureOne(session, event);
450
+ } catch (error) {
451
+ noteShadowWarning(ctx.logger, error);
452
+ }
397
453
  });
398
454
  ctx.on("agent/status", ({ agent, status }) => {
399
455
  if (status !== "idle") return;
400
456
  const state = this.stateFor(agent);
401
457
  if (state.warming) return;
402
- const done = agent.session.snapshotEvents().some((event) => event.type === "turn/end" && event.data?.reason?.kind === "completed");
458
+ const events = agent.session.snapshotEvents();
459
+ const done = events.some((event) => event.type === "turn/end" && event.data?.reason?.kind === "completed");
403
460
  if (!done) return;
404
461
  state.warming = true;
405
- const job = this.finishTurn(agent).finally(() => {
406
- state.warming = false;
407
- pendingCuts.delete(job);
408
- });
462
+ const job = this.finishTurn(agent, events)
463
+ .catch((error) => noteShadowWarning(this.ctx?.logger, error))
464
+ .finally(() => {
465
+ state.warming = false;
466
+ pendingCuts.delete(job);
467
+ });
409
468
  pendingCuts.add(job);
410
469
  });
411
470
  } catch (error) {
@@ -456,13 +515,17 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
456
515
  return planMessages(messages);
457
516
  }
458
517
 
459
- async finishTurn(agent) {
518
+ async finishTurn(agent, events) {
460
519
  const state = this.stateFor(agent);
461
520
  const signal = AbortSignal.timeout(30000);
521
+ const ev = events || agent.session.snapshotEvents();
522
+ this.captureNew(agent.session, ev);
523
+ const jobs = collectJobs(ev);
524
+ const calls = indexToolCalls(ev);
462
525
  try {
463
526
  await agent.runMaintenance(async (agentSignal) => {
464
527
  const op = AbortSignal.any([agentSignal, signal]);
465
- const measurement = this.ctx.tokenMeter.measure(agent.session);
528
+ let measurement = this.ctx.tokenMeter.measure(agent.session);
466
529
  if (measurement.totalTokens < MIN_PRESSURE || measurement.totalTokens >= BIG_GATE) return;
467
530
  // 官方压缩后的第一次 idle 先让一让,免得紧接着又把它产出的摘要卷走。
468
531
  // 主判据是「这条官方卡片处理过没有」(与实际间隔多久无关),时间窗口只作可选兜底。
@@ -474,7 +537,6 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
474
537
  }
475
538
  if (inCooldown(agent.session, { windowMs: COOLDOWN_MS })) return;
476
539
  this.ensurePruneGuard();
477
- this.captureSession(agent.session);
478
540
  const resolved = this.engineFor(agent);
479
541
  if (state.engine !== resolved.engine || state.engineSource !== resolved.source) {
480
542
  this.ctx.logger?.info?.(`spine engine=${resolved.engine} source=${resolved.source}`);
@@ -490,13 +552,14 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
490
552
  state.maxTurnGrowth = Math.max(state.maxTurnGrowth || 0, growth);
491
553
  const projected = state.maxTurnGrowth > 0 && last + state.maxTurnGrowth > KEEP_ROUND_TOKENS;
492
554
  const drain = cloud && (projected || last >= URGENT_TOKENS);
555
+ const drainNotes = [];
493
556
  for (let pass = 0; pass < 8; pass += 1) {
494
- const measured = this.ctx.tokenMeter.measure(agent.session);
557
+ const measured = measurement;
495
558
  if (drain && measured.totalTokens <= KEEP_ROUND_TOKENS) break;
496
559
  const chunk = selectNewestChunk(agent.session, measured, state.key, {
497
560
  engine: resolved.engine,
498
- jobs: collectJobs(agent.session.snapshotEvents()),
499
- calls: indexToolCalls(agent.session.snapshotEvents()),
561
+ jobs,
562
+ calls,
500
563
  rawLookup: (id) => this.rawSeen.get(`${state.key}\0${id}`),
501
564
  totalTokens: measured.totalTokens,
502
565
  drain,
@@ -518,12 +581,23 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
518
581
  state.localOnly = true;
519
582
  state.approvedCard = chunk.card;
520
583
  await this.commitSelectedSpan(agent, chunk, op);
521
- const after = this.ctx.tokenMeter.measure(agent.session).totalTokens;
584
+ measurement = this.ctx.tokenMeter.measure(agent.session);
585
+ const after = measurement.totalTokens;
522
586
  reduced = after < before;
523
587
  const saved = before - after;
524
- const stats = saved > 0 ? recordCutStats(shadowDirectory(), { saved, rewrite: Math.max(0, after - (chunk.prefix || 0)) }) : null;
525
- const total = stats ? stats.savedTokens : "unknown";
526
- this.ctx.logger?.info?.(`spine cut ${Date.now() - started}ms ${before} -> ${after} chunk=${chunk.tokens} saved=${saved} total=${total}`);
588
+ if (saved > 0) {
589
+ drainNotes.push({
590
+ kind: "cut",
591
+ saved,
592
+ afterPrefix: Math.max(0, after - (chunk.prefix || 0)),
593
+ tailBeyondKept,
594
+ prefix: chunk.prefix || 0,
595
+ total: after,
596
+ tokens: chunk.tokens,
597
+ gate: "cut",
598
+ });
599
+ }
600
+ this.ctx.logger?.info?.(`spine cut ${Date.now() - started}ms ${before} -> ${after} chunk=${chunk.tokens} saved=${saved}`);
527
601
  } catch (error) {
528
602
  const message = error instanceof Error ? error.message : String(error);
529
603
  this.ctx.logger?.warn?.(`spine cut failed: ${message}`);
@@ -535,7 +609,8 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
535
609
  }
536
610
  if (!drain) break;
537
611
  }
538
- state.turnBase = this.ctx.tokenMeter.measure(agent.session).totalTokens;
612
+ if (drainNotes.length) flushDrainStats(shadowDirectory(), drainNotes, { sid: state.key, drain });
613
+ state.turnBase = measurement.totalTokens;
539
614
  await this.ctx.sessions?.flush?.(agent.session);
540
615
  this.rememberPrefix(agent);
541
616
  });
@@ -727,10 +802,13 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
727
802
  const key = session?.id || DEFAULT_KEY;
728
803
  const mapKey = this.rawKey(session, id);
729
804
  if (this.rawSeen.get(mapKey)?.ok) return this.rawSeen.get(mapKey);
730
- let call = null;
731
- try {
732
- call = indexToolCalls(session.snapshotEvents?.() || []).get(id);
733
- } catch {}
805
+ const platform = access.platform ?? process.platform;
806
+ if (!access.runner && platform === "win32" && shadowAccessFused()) {
807
+ const skipped = { ok: false, reason: "unwritable", toolCallId: id };
808
+ this.rawSeen.set(mapKey, skipped);
809
+ return skipped;
810
+ }
811
+ const call = this.callIndex.get(session?.id || DEFAULT_KEY)?.get(id) || null;
734
812
  try {
735
813
  const shadow = shadowDirectory();
736
814
  ensureShadowDir(shadow, access);
@@ -756,7 +834,10 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
756
834
  error?.code === "ESHADOWACCESS" ||
757
835
  error?.code === "ESHADOWCLEANUP"
758
836
  ) {
759
- throw error;
837
+ noteShadowWarning(this.ctx?.logger, error);
838
+ const refused = { ok: false, reason: "unwritable", toolCallId: id };
839
+ this.rawSeen.set(mapKey, refused);
840
+ return refused;
760
841
  }
761
842
  const message = error instanceof Error ? error.message : String(error);
762
843
  this.ctx?.logger?.warn?.(`spine: raw backup failed: ${message}`);
@@ -766,18 +847,76 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
766
847
  }
767
848
  }
768
849
 
769
- captureSession(session) {
850
+ noteEvent(session, event) {
851
+ const found = indexToolCalls([event]);
852
+ if (!found.size) return;
853
+ const key = session?.id || DEFAULT_KEY;
854
+ let index = this.callIndex.get(key);
855
+ if (!index) {
856
+ index = new Map();
857
+ this.callIndex.set(key, index);
858
+ }
859
+ for (const [id, call] of found) {
860
+ if (!index.has(id)) index.set(id, call);
861
+ }
862
+ }
863
+
864
+ captureNew(session, events) {
865
+ const key = session?.id || DEFAULT_KEY;
866
+ let cursor = this.captureCursor.has(key) ? this.captureCursor.get(key) : this.readCaptureCursor(key);
867
+ if (cursor == null) cursor = restartCaptureCursor(events, KEEP_ROUNDS);
868
+ if (!Array.isArray(events)) cursor = 0;
869
+ else if (events.length < cursor) cursor = restartCaptureCursor(events, KEEP_ROUNDS);
870
+ let next = cursor;
770
871
  const seen = new Set();
771
- for (const event of session?.snapshotEvents?.() || []) {
772
- if (event?.type !== "tool/result") continue;
773
- const id = event.data?.message?.toolCallId;
774
- if (!id || seen.has(id)) continue;
775
- seen.add(id);
776
- this.captureOne(session, event);
872
+ for (const event of events) {
873
+ const seq = Number.isInteger(event?.seq) ? event.seq : next;
874
+ if (seq < cursor) continue;
875
+ this.noteEvent(session, event);
876
+ if (event?.type === "tool/result") {
877
+ const id = event.data?.message?.toolCallId;
878
+ if (id && !seen.has(id)) {
879
+ seen.add(id);
880
+ this.captureOne(session, event);
881
+ }
882
+ }
883
+ next = seq + 1;
777
884
  }
885
+ this.captureCursor.set(key, next);
886
+ this.writeCaptureCursor(key, next);
778
887
  return seen;
779
888
  }
780
889
 
890
+ readCaptureCursor(key) {
891
+ if (this.persistCapture !== true) return null;
892
+ try {
893
+ const file = join(shadowDirectory(), "spine-capture.json");
894
+ if (!existsSync(file)) return null;
895
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
896
+ const cursor = parsed?.sessions?.[key];
897
+ return Number.isInteger(cursor) && cursor >= 0 ? cursor : null;
898
+ } catch {
899
+ return null;
900
+ }
901
+ }
902
+
903
+ writeCaptureCursor(key, cursor) {
904
+ if (this.persistCapture !== true) return;
905
+ try {
906
+ const dir = shadowDirectory();
907
+ mkdirSync(dir, { recursive: true });
908
+ const file = join(dir, "spine-capture.json");
909
+ const parsed = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
910
+ const sessions = parsed?.sessions && typeof parsed.sessions === "object" ? parsed.sessions : {};
911
+ sessions[key] = cursor;
912
+ writeFileSync(file, JSON.stringify({ sessions }));
913
+ } catch {}
914
+ }
915
+
916
+ captureSession(session) {
917
+ return this.captureNew(session, session?.snapshotEvents?.() || []);
918
+ }
919
+
781
920
  surfaceToolIds(session) {
782
921
  const ids = [];
783
922
  for (const seq of session?.surface?.nodes || []) {
@@ -892,7 +1031,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
892
1031
  error?.code === "ESHADOWACCESS" ||
893
1032
  error?.code === "ESHADOWCLEANUP"
894
1033
  ) {
895
- throw error;
1034
+ noteShadowWarning(this.ctx?.logger, error);
896
1035
  }
897
1036
  }
898
1037
  const joined = input.messages.map(messageText).join("\n\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-live-compact",
3
- "version": "1.0.9",
3
+ "version": "1.1.1",
4
4
  "description": "In-place newest-turn compaction for DeepSeek Harness. Compress while the task runs and keep the prefix.",
5
5
  "license": "MIT",
6
6
  "author": "MerkyorLynn <268568828+MerkyorLynn@users.noreply.github.com>",
@@ -17,6 +17,7 @@
17
17
  },
18
18
  "files": [
19
19
  "shell.js",
20
+ "adapter.js",
20
21
  "index.js",
21
22
  "gate.js",
22
23
  "plan.js",
@@ -37,7 +38,7 @@
37
38
  "compaction"
38
39
  ],
39
40
  "scripts": {
40
- "test:gate": "node test/p0-gate.mjs && node test/p0-blockers.mjs && node --test test/cooldown.test.mjs test/gate-config.test.mjs"
41
+ "test:gate": "node test/p0-gate.mjs && node test/p0-blockers.mjs && node --test test/cooldown.test.mjs test/gate-config.test.mjs test/drain-stopline.mjs test/scan-once.mjs test/issue-19.mjs test/issue-20.mjs"
41
42
  },
42
43
  "engines": {
43
44
  "node": "^22.19 || >=24",
package/plan.js CHANGED
@@ -506,9 +506,34 @@ export function latestToolRound(events) {
506
506
  return rounds.length ? rounds[rounds.length - 1] : null;
507
507
  }
508
508
 
509
+ export function cutLongReadRound(items, start, end, options = {}) {
510
+ if (!Array.isArray(items) || start < 0 || end < start || end >= items.length) return null;
511
+ const pointerMax = options.pointerMax ?? options.chunkMax ?? 32000;
512
+ for (let index = end; index >= start; index -= 1) {
513
+ if (items[index]?.type === "tool" && (items[index].tokens || 0) >= pointerMax) {
514
+ return { start: index, end: index, pointerOnly: true, gain: 0 };
515
+ }
516
+ }
517
+ const picked = selectCutIndexes(items, {
518
+ ...options,
519
+ drain: true,
520
+ minStart: start,
521
+ maxEnd: end,
522
+ });
523
+ if (!picked || picked.start < start || picked.end > end) return null;
524
+ return picked;
525
+ }
526
+
527
+ export function roundHasLongRead(events, round) {
528
+ for (let index = round.start; index <= round.end; index += 1) {
529
+ if (events[index]?.longRead) return true;
530
+ }
531
+ return false;
532
+ }
533
+
509
534
  export function keptToolRounds(events, limit = 3) {
510
535
  const count = Number.isInteger(limit) && limit > 0 ? limit : 3;
511
- const rounds = finishedToolRounds(events);
536
+ const rounds = finishedToolRounds(events).filter((round) => !roundHasLongRead(events, round));
512
537
  return rounds.slice(-count);
513
538
  }
514
539
 
@@ -746,15 +771,26 @@ function spanTokens(items, start, end) {
746
771
  function walkOffTail(items, end) {
747
772
  while (end >= 0) {
748
773
  const item = items[end];
774
+ // 卡片是停止线。走到它就没有可切的尾巴,不能再往左切进已缓存的前缀。
775
+ // A checkpoint is the floor. Reaching it means the tail has nothing left to cut.
776
+ if (item.checkpoint) return -1;
749
777
  const shortUser = item.type === "user" && (item.tokens || 0) < 400;
750
- if (item.type !== "assistant" && !shortUser && !item.checkpoint) break;
778
+ if (item.type !== "assistant" && !shortUser) break;
751
779
  end -= 1;
752
780
  }
753
781
  while (end >= 0 && items[end].type === "user") end -= 1;
754
- while (end >= 0 && items[end].checkpoint) end -= 1;
782
+ if (end >= 0 && items[end].checkpoint) return -1;
755
783
  return end;
756
784
  }
757
785
 
786
+ export function cutCrossesNewerCheckpoint(items, end) {
787
+ if (!Array.isArray(items) || !Number.isInteger(end)) return false;
788
+ for (let index = end + 1; index < items.length; index += 1) {
789
+ if (items[index]?.checkpoint) return true;
790
+ }
791
+ return false;
792
+ }
793
+
758
794
  function retreatBefore(items, end, boundary) {
759
795
  if (boundary < 0 || end < boundary) return end;
760
796
  end = boundary - 1;
@@ -787,6 +823,7 @@ export function selectCutIndexes(items, {
787
823
  chunkMin = 1000,
788
824
  chunkMax = 32000,
789
825
  pointerMax = chunkMax,
826
+ minStart = 0,
790
827
  maxEnd = null,
791
828
  totalTokens,
792
829
  drain = false,
@@ -798,23 +835,27 @@ export function selectCutIndexes(items, {
798
835
  // Ordinary cloud turns do not cut. The existing 48000 line, or an explicit drain, selects a span.
799
836
  const draining = drain || (typeof totalTokens === "number" && totalTokens >= urgentTokens);
800
837
  if (cloud && !draining) return null;
838
+ const lowerBound = Math.max(0, minStart);
801
839
  const limit = maxEnd === null ? items.length - 1 : maxEnd;
802
- if (limit < 0) return null;
840
+ if (limit < lowerBound) return null;
803
841
  const lastUser = items.reduce((found, item, index) => (item.type === "user" ? index : found), -1);
804
842
  const firstUser = items.findIndex((item) => item.type === "user");
805
843
  const floor = firstUser >= 0 ? firstUser + 1 : 0;
806
844
  const end = walkOffTail(items, limit);
807
- if (end < 0) return null;
845
+ if (end < lowerBound) return null;
846
+ // 切点右边还有卡片,说明这一刀在已缓存前缀里。不切。
847
+ // A checkpoint to the right means this span is inside the cached prefix. Do not cut.
848
+ if (cutCrossesNewerCheckpoint(items, end)) return null;
808
849
  if (!cloud) {
809
850
  if (lastUser >= 0 && end <= lastUser) return null;
810
851
  if ((items[end].tokens || 0) > pointerMax && items[end].type === "tool") return { start: end, end, pointerOnly: true, gain: 0 };
811
852
  if ((items[end].tokens || 0) > chunkMax) return null;
812
- const local = expandBack(items, end, Math.max(floor, lastUser), { crossUser: false, chunkMax });
853
+ const local = expandBack(items, end, Math.max(lowerBound, floor, lastUser), { crossUser: false, chunkMax });
813
854
  if (local.gain < chunkMin || !balanced(items, local.start, local.end)) return null;
814
855
  return local;
815
856
  }
816
857
  let pointer = null;
817
- for (let index = end; index >= 0; index -= 1) {
858
+ for (let index = end; index >= lowerBound; index -= 1) {
818
859
  if (items[index].checkpoint || items[index].type === "user") break;
819
860
  if (items[index].type === "tool" && (items[index].tokens || 0) > pointerMax) {
820
861
  pointer = { start: index, end: index, pointerOnly: true, gain: 0 };
@@ -822,7 +863,7 @@ export function selectCutIndexes(items, {
822
863
  }
823
864
  }
824
865
  if ((items[end].tokens || 0) > pointerMax && items[end].type === "tool") return { start: end, end, pointerOnly: true, gain: 0 };
825
- const phase = expandBack(items, end, 0, { crossUser: false, chunkMax });
866
+ const phase = expandBack(items, end, lowerBound, { crossUser: false, chunkMax });
826
867
  // 不够 1000 就停下;不越过卡片或最后一条 user 去找另一段。
827
868
  // Under 1000, stop. Do not cross a checkpoint or the last user to find another span.
828
869
  if (phase.gain < chunkMin) return pointer;
package/shadow.js CHANGED
@@ -11,24 +11,84 @@ import { execFileSync } from "node:child_process";
11
11
  import { randomBytes } from "node:crypto";
12
12
  import {
13
13
  chmodSync,
14
+ appendFileSync,
14
15
  closeSync,
15
16
  existsSync,
16
17
  mkdirSync,
17
- mkdtempSync,
18
18
  openSync,
19
19
  readFileSync,
20
20
  readSync,
21
21
  readdirSync,
22
22
  renameSync,
23
- rmSync,
24
23
  statSync,
25
24
  unlinkSync,
26
25
  writeFileSync,
27
26
  } from "node:fs";
28
- import { tmpdir } from "node:os";
29
- import { basename, dirname, join, resolve } from "node:path";
27
+ import { basename, dirname, join, resolve, win32 } from "node:path";
30
28
 
29
+ const DRIVE_ROOT = /^[A-Za-z]:[\\/]/;
31
30
  const PLUGIN_NAME = /^(?:(?:cut|official|shadow)-\d+-[0-9a-f]{8}|raw-[0-9a-f]{16}|\d+-[0-9a-f]{8}-[0-9a-f]{8}|\d+-[0-9a-f]{8,16})\.txt$/;
31
+ let cachedSid = "";
32
+ const lockedDirs = new Set();
33
+ let shadowFuse = false;
34
+ const shadowWarnings = new Set();
35
+
36
+ export function resetShadowAccessState() {
37
+ cachedSid = "";
38
+ lockedDirs.clear();
39
+ shadowFuse = false;
40
+ shadowWarnings.clear();
41
+ }
42
+
43
+ export function shadowAccessFused() {
44
+ return shadowFuse;
45
+ }
46
+
47
+ function tripFuse() {
48
+ shadowFuse = true;
49
+ }
50
+
51
+ function envValue(env, name) {
52
+ if (!env) return "";
53
+ const found = Object.keys(env).find((key) => key.toLowerCase() === name.toLowerCase());
54
+ return found ? String(env[found] ?? "").trim() : "";
55
+ }
56
+
57
+ export function windowsSystemRoot(env = process.env) {
58
+ const systemRoot = envValue(env, "SystemRoot");
59
+ const windir = envValue(env, "windir");
60
+ const root = (DRIVE_ROOT.test(systemRoot) ? systemRoot : "") || (DRIVE_ROOT.test(windir) ? windir : "");
61
+ return root.replace(/[\\/]+$/, "");
62
+ }
63
+
64
+ export function win32Tool(name, env = process.env) {
65
+ const root = windowsSystemRoot(env);
66
+ if (!root) {
67
+ const error = new Error("spine: Windows system root is not a drive path");
68
+ error.code = "ESHADOWACCESS";
69
+ throw error;
70
+ }
71
+ return win32.join(root, "System32", name);
72
+ }
73
+
74
+ function lockKey(path) {
75
+ return resolve(path).replace(/[\\/]+$/, "").toLowerCase();
76
+ }
77
+
78
+ export function shadowDirLocked(path) {
79
+ return lockedDirs.has(lockKey(path));
80
+ }
81
+
82
+ export function noteShadowWarning(logger, error) {
83
+ const code = error?.code || "ESHADOWACCESS";
84
+ if (shadowWarnings.has(code)) return;
85
+ shadowWarnings.add(code);
86
+ const status = error?.cause?.status ?? error?.status;
87
+ const step = error?.step || error?.cause?.step || "";
88
+ const where = step ? ` ${step}` : "";
89
+ const exit = status == null ? "" : ` exit ${status}`;
90
+ logger?.warn?.(`spine: shadow permission failed ${code}${where}${exit}`);
91
+ }
32
92
  const SESSION_MARK = /^# spine-session (\S+)/;
33
93
 
34
94
  export function isPluginShadowName(name) {
@@ -69,6 +129,7 @@ export function shadowFileBody(sessionKey, body) {
69
129
  }
70
130
 
71
131
  export const STATS_NAME = "spine-stats.json";
132
+ export const CUTS_NAME = "spine-cuts.jsonl";
72
133
 
73
134
  export function readCutStats(dir) {
74
135
  try {
@@ -77,9 +138,79 @@ export function readCutStats(dir) {
77
138
  cuts: Number(parsed.cuts) || 0,
78
139
  savedTokens: Number(parsed.savedTokens) || 0,
79
140
  rewriteTokens: Number(parsed.rewriteTokens) || 0,
141
+ drains: Number(parsed.drains) || 0,
80
142
  };
81
143
  } catch {
82
- return { cuts: 0, savedTokens: 0, rewriteTokens: 0 };
144
+ return { cuts: 0, savedTokens: 0, rewriteTokens: 0, drains: 0 };
145
+ }
146
+ }
147
+
148
+ export function drainRewrite(notes) {
149
+ const cuts = (notes || []).filter((note) => note?.kind === "cut" && note.saved > 0);
150
+ if (!cuts.length) return 0;
151
+ const later = cuts.slice(1).reduce((sum, note) => sum + (note.saved || 0), 0);
152
+ return Math.max(0, (cuts[0].afterPrefix || 0) - later);
153
+ }
154
+
155
+ export function flushDrainStats(dir, notes, meta = {}) {
156
+ const cuts = (notes || []).filter((note) => note?.kind === "cut" && note.saved > 0);
157
+ const charged = drainRewrite(notes);
158
+ const saved = cuts.reduce((sum, note) => sum + note.saved, 0);
159
+ if (cuts.length) recordDrainStats(dir, { cuts: cuts.length, saved, rewrite: charged });
160
+ const lines = (notes || []).map((note, index) => JSON.stringify({
161
+ v: 1,
162
+ ts: new Date().toISOString(),
163
+ sid: meta.sid || "",
164
+ kind: note.kind || "cut",
165
+ pass: index,
166
+ drain: meta.drain === true,
167
+ saved: note.saved || 0,
168
+ rewriteCharged: note.kind === "cut" && index === (notes || []).findIndex((item) => item?.kind === "cut") ? charged : 0,
169
+ tailBeyondKept: note.tailBeyondKept || 0,
170
+ prefix: note.prefix || 0,
171
+ afterPrefix: note.afterPrefix || 0,
172
+ tokens: note.tokens || 0,
173
+ total: note.total || 0,
174
+ gate: note.gate || "cut",
175
+ }));
176
+ if (!lines.length) return charged;
177
+ try {
178
+ appendFileSync(join(dir, CUTS_NAME), `${lines.join("\n")}\n`);
179
+ } catch {}
180
+ return charged;
181
+ }
182
+
183
+ function recordDrainStats(dir, { cuts = 0, saved = 0, rewrite = 0 } = {}) {
184
+ if (!(cuts > 0) || !(saved > 0)) return null;
185
+ try {
186
+ const file = join(dir, STATS_NAME);
187
+ const lock = join(dir, "spine-stats.lock");
188
+ const tmp = `${file}.${process.pid}-${randomBytes(8).toString("hex")}.tmp`;
189
+ let fd;
190
+ try {
191
+ fd = openSync(lock, "wx");
192
+ } catch {
193
+ return null;
194
+ }
195
+ try {
196
+ const prev = readCutStats(dir);
197
+ const locked = {
198
+ cuts: prev.cuts + cuts,
199
+ savedTokens: prev.savedTokens + saved,
200
+ rewriteTokens: prev.rewriteTokens + rewrite,
201
+ drains: (Number(prev.drains) || 0) + 1,
202
+ updatedAt: new Date().toISOString(),
203
+ };
204
+ writeFileSync(tmp, JSON.stringify(locked));
205
+ renameSync(tmp, file);
206
+ return locked;
207
+ } finally {
208
+ try { unlinkSync(tmp); } catch {}
209
+ closeSync(fd);
210
+ try { unlinkSync(lock); } catch {}
211
+ }
212
+ } catch {
213
+ return null;
83
214
  }
84
215
  }
85
216
 
@@ -116,25 +247,25 @@ export function recordCutStats(dir, { saved = 0, rewrite = 0 } = {}) {
116
247
  }
117
248
  }
118
249
 
119
- export function shadowAccessPlan(platform = process.platform, userSid = "") {
250
+ export function shadowAccessPlan(platform = process.platform, userSid = "", kind = "dir") {
120
251
  if (platform === "win32") {
121
252
  if (!/^S-\d+(?:-\d+)+$/.test(userSid)) {
122
253
  throw new Error("spine: current Windows user SID is required");
123
254
  }
124
255
 
256
+ // 文件不能带继承标志,否则 icacls 会把整条 ACE 当无效丢掉,留下空 DACL。
257
+ // Files reject (OI)(CI), so a file grant must not carry it.
258
+ const scope = kind === "dir" ? "(OI)(CI)" : "";
259
+
125
260
  return {
126
261
  kind: "acl",
127
262
  usesChmodOnly: false,
128
263
  inheritance: "remove",
129
264
  grant: [
130
- `*${userSid}:(OI)(CI)F`,
131
- "*S-1-5-18:(OI)(CI)F",
132
- "*S-1-5-32-544:(OI)(CI)F",
265
+ `*${userSid}:${scope}F`,
266
+ `*S-1-5-18:${scope}F`,
267
+ `*S-1-5-32-544:${scope}F`,
133
268
  ],
134
- sddl:
135
- `D:P(A;OICI;FA;;;${userSid})` +
136
- "(A;OICI;FA;;;SY)" +
137
- "(A;OICI;FA;;;BA)",
138
269
  };
139
270
  }
140
271
 
@@ -146,68 +277,112 @@ export function shadowAccessPlan(platform = process.platform, userSid = "") {
146
277
  };
147
278
  }
148
279
 
149
- export function defaultShadowAccessRunner({ path, kind, platform, execFile = execFileSync }) {
280
+ export function defaultShadowAccessRunner({
281
+ path,
282
+ kind,
283
+ platform,
284
+ execFile = execFileSync,
285
+ env = process.env,
286
+ } = {}) {
150
287
  if (platform !== "win32") {
151
288
  const plan = shadowAccessPlan(platform);
152
289
  chmodSync(path, kind === "dir" ? plan.dirMode : plan.fileMode);
153
290
  return true;
154
291
  }
155
292
 
156
- const identity = execFile(
157
- "whoami.exe",
158
- ["/user", "/fo", "csv", "/nh"],
159
- { encoding: "utf8", windowsHide: true },
160
- );
161
- const match = identity.match(/,"(S-\d+(?:-\d+)+)"\s*$/i);
162
- if (!match) {
163
- throw new Error("spine: cannot determine current Windows user SID");
293
+ if (shadowFuse) {
294
+ const error = new Error("spine: shadow permission already failed");
295
+ error.code = "ESHADOWACCESS";
296
+ throw error;
164
297
  }
165
298
 
166
- const plan = shadowAccessPlan("win32", match[1]);
167
- const target = resolve(path);
168
- const temp = mkdtempSync(join(tmpdir(), "spine-acl-"));
169
-
299
+ let whoamiPath = "";
300
+ let icaclsPath = "";
170
301
  try {
171
- const aclFile = join(temp, "access.acl");
172
- const aclBody = `\ufeff${basename(target)}\r\n${plan.sddl}\r\n`;
302
+ whoamiPath = win32Tool("whoami.exe", env);
303
+ icaclsPath = win32Tool("icacls.exe", env);
304
+ } catch (error) {
305
+ tripFuse();
306
+ throw error;
307
+ }
173
308
 
174
- writeFileSync(aclFile, Buffer.from(aclBody, "utf16le"), {
175
- flag: "wx",
176
- mode: 0o600,
177
- });
309
+ let sid = cachedSid;
310
+ if (!sid) {
311
+ let identity = "";
312
+ try {
313
+ identity = execFile(whoamiPath, ["/user", "/fo", "csv", "/nh"], {
314
+ encoding: "utf8",
315
+ windowsHide: true,
316
+ });
317
+ } catch (error) {
318
+ error.step = "whoami";
319
+ tripFuse();
320
+ throw error;
321
+ }
322
+ const match = String(identity ?? "").match(/,"(S-\d+(?:-\d+)+)"\s*$/i);
323
+ if (!match) {
324
+ const error = new Error("spine: cannot determine current Windows user SID");
325
+ error.step = "whoami";
326
+ tripFuse();
327
+ throw error;
328
+ }
329
+ sid = match[1];
330
+ cachedSid = sid;
331
+ }
178
332
 
333
+ const plan = shadowAccessPlan("win32", sid, kind);
334
+ try {
179
335
  execFile(
180
- "icacls.exe",
181
- [dirname(target), "/restore", aclFile, "/q"],
336
+ icaclsPath,
337
+ [resolve(path), "/inheritance:r", "/grant:r", ...plan.grant],
182
338
  { stdio: "pipe", windowsHide: true },
183
339
  );
184
-
185
- return true;
186
- } finally {
187
- rmSync(temp, { recursive: true, force: true });
340
+ } catch (error) {
341
+ error.step = kind === "file" ? "file" : "dir";
342
+ tripFuse();
343
+ throw error;
188
344
  }
345
+
346
+ if (kind === "dir") lockedDirs.add(lockKey(path));
347
+ return true;
189
348
  }
190
349
 
191
350
  export function tightenShadow(path, kind = "dir", access = {}) {
192
351
  const platform = access.platform ?? process.platform;
193
- const runner = access.runner ?? defaultShadowAccessRunner;
352
+ const windows = platform === "win32" && !access.runner;
353
+ if (windows && kind === "file" && shadowDirLocked(dirname(path))) return;
354
+ if (windows && shadowFuse) {
355
+ const error = new Error("spine: shadow permission action failed; backup refused");
356
+ error.code = "ESHADOWACCESS";
357
+ throw error;
358
+ }
359
+
360
+ const runner = access.runner ?? ((action) => defaultShadowAccessRunner({
361
+ ...action,
362
+ execFile: access.execFile,
363
+ env: access.env,
364
+ }));
194
365
 
195
366
  try {
196
- if (runner({ path, kind, platform }) !== true) {
367
+ if (runner({ path, kind, platform, env: access.env, execFile: access.execFile }) !== true) {
197
368
  throw new Error("permission runner did not confirm success");
198
369
  }
199
370
  } catch (cause) {
371
+ if (windows) tripFuse();
200
372
  const error = new Error(
201
373
  "spine: shadow permission action failed; backup refused",
202
374
  { cause },
203
375
  );
204
376
  error.code = "ESHADOWACCESS";
377
+ error.step = cause?.step;
205
378
  throw error;
206
379
  }
207
380
  }
208
381
 
209
382
  export function ensureShadowDir(dir, access = {}) {
210
383
  mkdirSync(dir, { recursive: true, mode: 0o700 });
384
+ const platform = access.platform ?? process.platform;
385
+ if (!access.runner && platform === "win32" && shadowDirLocked(dir)) return dir;
211
386
  tightenShadow(dir, "dir", access);
212
387
  return dir;
213
388
  }
package/shell.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * 配置预先检查失败时不构造引擎,也不再构造原版,避免报错被盖成「服务已被注册」。
7
7
  * On a preflight failure, construct neither engine. Stock would register and hide the real error.
8
8
  */
9
+ import { loadBase, loadCompaction } from "./adapter.js";
9
10
 
10
11
  let Engine = null;
11
12
  let Stock = null;
@@ -25,7 +26,7 @@ if (process.env.SPINE_ENGINE_FAIL === "import") {
25
26
  }
26
27
 
27
28
  try {
28
- const compaction = await import("@deepseek-ai/dsh-compaction");
29
+ const compaction = await loadCompaction();
29
30
  CompactionEngine = compaction.CompactionEngine;
30
31
  ManualCompactionError = compaction.ManualCompactionError;
31
32
  } catch (error) {
@@ -33,7 +34,7 @@ try {
33
34
  }
34
35
 
35
36
  try {
36
- const stock = await import("@deepseek-ai/dsh-compaction-basic");
37
+ const stock = await loadBase();
37
38
  Stock = stock.BasicCompactionEngine;
38
39
  } catch (error) {
39
40
  if (!loadError) loadError = error instanceof Error ? error : new Error(String(error));