dsh-live-compact 1.0.9 → 1.1.0

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.
Files changed (5) hide show
  1. package/README.md +49 -36
  2. package/index.js +85 -28
  3. package/package.json +2 -2
  4. package/plan.js +16 -2
  5. package/shadow.js +73 -1
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.0
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.0
21
29
  ```
22
30
 
23
31
  想先装到本机看版本:
@@ -27,6 +35,35 @@ npm install -g dsh-live-compact
27
35
  dsh-live-compact --version
28
36
  ```
29
37
 
38
+ ## 1.1.0
39
+
40
+ 1. Issue #19 里下一轮输入回到 21 万到 24 万、前缀被打掉的那一刀,是切点走到了已有卡片左边。1.1.0 把最新一张卡片当成地板。往左走到卡片就停。切点右边还有更新的卡片,这一刀不切。已进缓存的前缀不再被后面的刀改写。
41
+ 2. 同一次收尾可以连切,这是现有设计,没有改。改的是记账。以前每一刀都把尾巴算进重写,四刀就把同一段尾巴乘了四次。现在一次收尾只记一次:第一刀之后的前缀,减去后面几刀省下的。`dsh-live-compact stats` 的 `rewrite` 用这个数。逐刀明细在 shadow 目录的 `spine-cuts.jsonl`,只有序号、token 和切点,没有消息原文。
42
+ 3. 没有加「省下的必须盖过重写」这种新门槛。加了会把同一次收尾里后面的刀切掉。65 万停切线、收尾停在 3.2 万、`CHUNK_MIN` 默认 1000、云端默认 `0.8` / `65536` / `8192`,都没改。`SPINE_THUNDER` 仍是默认关闭。
43
+ 4. 今天桌面端长时间卡住,是每回合、每个工具结果都在拷贝整份会话日志,并反复计量。1.1.0 一个空闲回合只拷贝一次日志。工具结果到达时用已经记下的调用编号,不再为了查一次调用而拷贝整份日志。收尾循环复用这一次的任务和调用表。token 计量是开头一次,每真正切下一刀再测一次。没切的回合只测一次。切哪一段没改。
44
+
45
+ ## 门禁和复测
46
+
47
+ `npm run test:gate` 已过。Issue #19 的卡片地板、一次收尾重写只记一次、65 万和 `CHUNK_MIN` 1000 不动,以及空闲时不再重扫全日志,都在这道门禁里。连模型的 8 组 31 轮没有重跑。这次不改填日志题的判分,也不改切哪一段。下面四张图是 1.0.9 那次实测,1.1.0 沿用。
48
+
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 分。费用是离峰用量估算,不是账单。
50
+
51
+ ![汇总分题](docs/slide-110-by-question.jpg)
52
+
53
+ ![DS V4.1 Flash](docs/slide-110-ds.jpg)
54
+
55
+ ![GLM 5.3 Flash](docs/slide-110-glm.jpg)
56
+
57
+ ![Coder 390 27B](docs/slide-110-390.jpg)
58
+
59
+ ## 1.0.9
60
+
61
+ 1. 云端和本地按当前配置里这一家的地址区分。请求头通常只有 provider 名。配置里的 `baseURL` 优先,没有再看官方 DeepSeek 和内置目录。公网地址按云端切,回环和内网按本地。读不到地址仍按本地。`SPINE_ENGINE=local` 或 `cloud` 可以强制指定。这不改官方大门的 headroom。
62
+ 2. 官方大压缩的三项可以在启动前用环境变量覆盖:`SPINE_THRESHOLD_RATIO`、`SPINE_HEADROOM_TOKENS`、`SPINE_RETAIN_TOKENS`。云端默认仍是 `0.8`、`65536`、`8192`,不按地址改小。TUI 的 `/settings` 没有这一项。要改当前配置,直接改 `cordis.patch.yml` 里的对应字段。
63
+ 3. 发版门禁是 `npm run test:gate`,不连模型。8 组 31 轮在 `test/live-fill`,要连模型。这两部分都在 GitHub 测试集里。
64
+ 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。费用是离峰用量估算,不是账单。
65
+ 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)。
66
+
30
67
  ![边做边压](docs/slide-cover.jpg)
31
68
 
32
69
  ![为什么非要等到 80%](docs/slide-why.jpg)
@@ -92,32 +129,6 @@ dsh-live-compact --version
92
129
 
93
130
  ![原版对比](docs/slide-stock.jpg)
94
131
 
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
132
  `cordis.patch.yml` 是静态整块替换,不读这三个环境变量。要在这里改 `headroomTokens`,需直接改配置块,例如:
122
133
 
123
134
  ```yaml
@@ -231,8 +242,8 @@ Coder 390 27B,只跑了 3 组 31 轮,没有八组并行。填日志 69/69。
231
242
  | `SPINE_SHADOW_MAX_BYTES` | 268435456 | 正数直接作为备份字节上限,优先于 `SPINE_SHADOW_MAX_MB`。 |
232
243
  | `SPINE_SHADOW_MAX_MB` | 256 | 未设置正数字节上限时,按 MB 设置备份上限。 |
233
244
  | `SPINE_ENGINE` | 自动识别 | 设为 `cloud` 或 `local` 可强制切法,其他值忽略。 |
234
- | `SPINE_THUNDER` | 关闭 | 设为 `1` 时启用雷霆摘要路径。 |
235
- | `SPINE_THUNDER_MODEL` | `flash` | 设为 `27b` 时使用对应模型类型,其他值仍为 `flash`。 |
245
+ | `SPINE_THUNDER` | 关闭 | 设为 `1` 才走雷霆。端侧不要开。 |
246
+ | `SPINE_THUNDER_MODEL` | `flash` | 设为 `27b` 且前缀超过 8 万时,每次官方压缩都少发。端侧不要设。 |
236
247
  | `SPINE_ENGINE_FAIL` | 无 | **仅供测试**:用 `import`、`construct`、`after-super` 注入引擎故障。 |
237
248
  | `SPINE_STOCK_FAIL` | 无 | **仅供测试**:设为 `1` 时禁止回退原版引擎。 |
238
249
  | `SPINE_CHUNK_MIN` | 1000 | 一刀至少包含的 token 数。 |
@@ -248,8 +259,8 @@ Coder 390 27B,只跑了 3 组 31 轮,没有八组并行。填日志 69/69。
248
259
  从 npm 装。headless 和网页是两个 profile,要各装一次。桌面端是另一个 profile,普通 `dsh plugin --profile desktop` 会被拒绝。
249
260
 
250
261
  ```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
262
+ dsh plugin --profile headless add dsh-live-compact@1.1.0
263
+ dsh plugin --profile web add dsh-live-compact@1.1.0
253
264
  ```
254
265
 
255
266
  想先装到本机看版本:
@@ -259,12 +270,12 @@ npm install -g dsh-live-compact
259
270
  dsh-live-compact --version
260
271
  ```
261
272
 
262
- 备用:上不了 npm 的,用国内镜像站的包。1.0.2 到 1.0.8 的旧链接仍在。
273
+ 备用:上不了 npm 的,用国内镜像站的包。1.0.2 到 1.0.9 的旧链接仍在。
263
274
 
264
275
  ```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"
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"
268
279
  dsh-live-compact --version
269
280
  ```
270
281
 
@@ -274,6 +285,8 @@ DSH-TUI 同样换不掉预设里的原版压缩,先不要装。
274
285
 
275
286
  被切掉的原文会写到 `~/.dsh/compaction-spine/shadow`,只有自己能读。上限 256MB。快满时删最老的备份。卡片里的序号是会话日志的事件号。要在当前会话的备份里找一行,用 `import { searchShadow } from "dsh-live-compact/shadow-search"`。
276
287
 
288
+ `dsh-live-compact stats` 的 `saved` 是切完当下少掉的 token,`rewrite` 是同一次收尾实际要重写的尾巴,只记一次。同一步连切不会按刀数把尾巴乘上去。这两个数口径不同,比值不是盈亏。桌面端没有终端,`rg "spine cut"` 只在终端输出里找得到。逐刀明细在 shadow 目录的 `spine-cuts.jsonl`。
289
+
277
290
  改上限:
278
291
 
279
292
  ```sh
package/index.js CHANGED
@@ -26,7 +26,7 @@ import { captureOwnedToolResult, rawExcerptText, restoreToolText, runPruneAfterC
26
26
  import {
27
27
  discardShadowFiles,
28
28
  ensureShadowDir,
29
- recordCutStats,
29
+ flushDrainStats,
30
30
  rotateShadow,
31
31
  sessionDir,
32
32
  shadowFileBody,
@@ -236,13 +236,18 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
236
236
  if (nodes.length === 0 || nodes.length !== priced.length) return null;
237
237
  const first = session.eventAt(nodes[0])?.type === "system/message" ? 1 : 0;
238
238
  let end = priced.length - 1;
239
+ let floorCard = -1;
240
+ for (let index = first; index <= end; index += 1) {
241
+ if (isCheckpoint(session, nodes[index])) floorCard = index;
242
+ }
239
243
  while (end >= first) {
244
+ if (floorCard >= 0 && end <= floorCard) return null;
240
245
  const event = session.eventAt(nodes[end]);
241
246
  const shortTail = event?.type === "assistant/message" || (event?.type === "user/message" && priced[end].tokens < 400);
242
247
  if (!shortTail) break;
243
248
  end -= 1;
244
249
  }
245
- while (end >= first && isCheckpoint(session, nodes[end])) end -= 1;
250
+ if (floorCard >= 0 && end <= floorCard) return null;
246
251
  while (end >= first && !cutOk(toolPairingBalancedAfter, session, nodes[end])) end -= 1;
247
252
  let keptTokens = 0;
248
253
  const visible = [];
@@ -261,8 +266,14 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
261
266
  const keptEnd = visible[span.end].index;
262
267
  for (let index = visible[span.start].index; index <= keptEnd; index += 1) keptTokens += priced[index].tokens;
263
268
  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;
269
+ while (end >= first && isUser(session, nodes[end]) && !(floorCard >= 0 && end - 1 <= floorCard)) end -= 1;
270
+ if (floorCard >= 0 && end <= floorCard) return null;
271
+ if (isCheckpoint(session, nodes[end])) return null;
272
+ while (end >= first && !cutOk(toolPairingBalancedAfter, session, nodes[end])) {
273
+ if (floorCard >= 0 && end - 1 <= floorCard) return null;
274
+ end -= 1;
275
+ }
276
+ if (floorCard >= 0 && end <= floorCard) return null;
266
277
  }
267
278
  if (end < first || isCheckpoint(session, nodes[end])) return null;
268
279
  const spanPieces = (spanStart, spanEnd) => {
@@ -380,6 +391,8 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
380
391
  sessionState = new Map();
381
392
  shadowFullWarned = new Set();
382
393
  rawSeen = new Map();
394
+ callIndex = new Map();
395
+ captureCursor = new Map();
383
396
  pruneGuarded = false;
384
397
 
385
398
  constructor(ctx, config) {
@@ -392,6 +405,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
392
405
  await Promise.allSettled(pendingCuts);
393
406
  });
394
407
  ctx.on("session/event", (session, event) => {
408
+ this.noteEvent(session, event);
395
409
  if (event?.type !== "tool/result") return;
396
410
  this.captureOne(session, event);
397
411
  });
@@ -399,10 +413,11 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
399
413
  if (status !== "idle") return;
400
414
  const state = this.stateFor(agent);
401
415
  if (state.warming) return;
402
- const done = agent.session.snapshotEvents().some((event) => event.type === "turn/end" && event.data?.reason?.kind === "completed");
416
+ const events = agent.session.snapshotEvents();
417
+ const done = events.some((event) => event.type === "turn/end" && event.data?.reason?.kind === "completed");
403
418
  if (!done) return;
404
419
  state.warming = true;
405
- const job = this.finishTurn(agent).finally(() => {
420
+ const job = this.finishTurn(agent, events).finally(() => {
406
421
  state.warming = false;
407
422
  pendingCuts.delete(job);
408
423
  });
@@ -456,13 +471,17 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
456
471
  return planMessages(messages);
457
472
  }
458
473
 
459
- async finishTurn(agent) {
474
+ async finishTurn(agent, events) {
460
475
  const state = this.stateFor(agent);
461
476
  const signal = AbortSignal.timeout(30000);
477
+ const ev = events || agent.session.snapshotEvents();
478
+ this.captureNew(agent.session, ev);
479
+ const jobs = collectJobs(ev);
480
+ const calls = indexToolCalls(ev);
462
481
  try {
463
482
  await agent.runMaintenance(async (agentSignal) => {
464
483
  const op = AbortSignal.any([agentSignal, signal]);
465
- const measurement = this.ctx.tokenMeter.measure(agent.session);
484
+ let measurement = this.ctx.tokenMeter.measure(agent.session);
466
485
  if (measurement.totalTokens < MIN_PRESSURE || measurement.totalTokens >= BIG_GATE) return;
467
486
  // 官方压缩后的第一次 idle 先让一让,免得紧接着又把它产出的摘要卷走。
468
487
  // 主判据是「这条官方卡片处理过没有」(与实际间隔多久无关),时间窗口只作可选兜底。
@@ -474,7 +493,6 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
474
493
  }
475
494
  if (inCooldown(agent.session, { windowMs: COOLDOWN_MS })) return;
476
495
  this.ensurePruneGuard();
477
- this.captureSession(agent.session);
478
496
  const resolved = this.engineFor(agent);
479
497
  if (state.engine !== resolved.engine || state.engineSource !== resolved.source) {
480
498
  this.ctx.logger?.info?.(`spine engine=${resolved.engine} source=${resolved.source}`);
@@ -490,13 +508,14 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
490
508
  state.maxTurnGrowth = Math.max(state.maxTurnGrowth || 0, growth);
491
509
  const projected = state.maxTurnGrowth > 0 && last + state.maxTurnGrowth > KEEP_ROUND_TOKENS;
492
510
  const drain = cloud && (projected || last >= URGENT_TOKENS);
511
+ const drainNotes = [];
493
512
  for (let pass = 0; pass < 8; pass += 1) {
494
- const measured = this.ctx.tokenMeter.measure(agent.session);
513
+ const measured = measurement;
495
514
  if (drain && measured.totalTokens <= KEEP_ROUND_TOKENS) break;
496
515
  const chunk = selectNewestChunk(agent.session, measured, state.key, {
497
516
  engine: resolved.engine,
498
- jobs: collectJobs(agent.session.snapshotEvents()),
499
- calls: indexToolCalls(agent.session.snapshotEvents()),
517
+ jobs,
518
+ calls,
500
519
  rawLookup: (id) => this.rawSeen.get(`${state.key}\0${id}`),
501
520
  totalTokens: measured.totalTokens,
502
521
  drain,
@@ -518,12 +537,23 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
518
537
  state.localOnly = true;
519
538
  state.approvedCard = chunk.card;
520
539
  await this.commitSelectedSpan(agent, chunk, op);
521
- const after = this.ctx.tokenMeter.measure(agent.session).totalTokens;
540
+ measurement = this.ctx.tokenMeter.measure(agent.session);
541
+ const after = measurement.totalTokens;
522
542
  reduced = after < before;
523
543
  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}`);
544
+ if (saved > 0) {
545
+ drainNotes.push({
546
+ kind: "cut",
547
+ saved,
548
+ afterPrefix: Math.max(0, after - (chunk.prefix || 0)),
549
+ tailBeyondKept,
550
+ prefix: chunk.prefix || 0,
551
+ total: after,
552
+ tokens: chunk.tokens,
553
+ gate: "cut",
554
+ });
555
+ }
556
+ this.ctx.logger?.info?.(`spine cut ${Date.now() - started}ms ${before} -> ${after} chunk=${chunk.tokens} saved=${saved}`);
527
557
  } catch (error) {
528
558
  const message = error instanceof Error ? error.message : String(error);
529
559
  this.ctx.logger?.warn?.(`spine cut failed: ${message}`);
@@ -535,7 +565,8 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
535
565
  }
536
566
  if (!drain) break;
537
567
  }
538
- state.turnBase = this.ctx.tokenMeter.measure(agent.session).totalTokens;
568
+ if (drainNotes.length) flushDrainStats(shadowDirectory(), drainNotes, { sid: state.key, drain });
569
+ state.turnBase = measurement.totalTokens;
539
570
  await this.ctx.sessions?.flush?.(agent.session);
540
571
  this.rememberPrefix(agent);
541
572
  });
@@ -727,10 +758,7 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
727
758
  const key = session?.id || DEFAULT_KEY;
728
759
  const mapKey = this.rawKey(session, id);
729
760
  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 {}
761
+ const call = this.callIndex.get(session?.id || DEFAULT_KEY)?.get(id) || null;
734
762
  try {
735
763
  const shadow = shadowDirectory();
736
764
  ensureShadowDir(shadow, access);
@@ -766,18 +794,47 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
766
794
  }
767
795
  }
768
796
 
769
- captureSession(session) {
797
+ noteEvent(session, event) {
798
+ const found = indexToolCalls([event]);
799
+ if (!found.size) return;
800
+ const key = session?.id || DEFAULT_KEY;
801
+ let index = this.callIndex.get(key);
802
+ if (!index) {
803
+ index = new Map();
804
+ this.callIndex.set(key, index);
805
+ }
806
+ for (const [id, call] of found) {
807
+ if (!index.has(id)) index.set(id, call);
808
+ }
809
+ }
810
+
811
+ captureNew(session, events) {
812
+ const key = session?.id || DEFAULT_KEY;
813
+ let cursor = this.captureCursor.get(key) ?? 0;
814
+ if (!Array.isArray(events) || events.length < cursor) cursor = 0;
815
+ let next = cursor;
770
816
  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);
817
+ for (const event of events) {
818
+ const seq = Number.isInteger(event?.seq) ? event.seq : next;
819
+ if (seq < cursor) continue;
820
+ this.noteEvent(session, event);
821
+ if (event?.type === "tool/result") {
822
+ const id = event.data?.message?.toolCallId;
823
+ if (id && !seen.has(id)) {
824
+ seen.add(id);
825
+ this.captureOne(session, event);
826
+ }
827
+ }
828
+ next = seq + 1;
777
829
  }
830
+ this.captureCursor.set(key, next);
778
831
  return seen;
779
832
  }
780
833
 
834
+ captureSession(session) {
835
+ return this.captureNew(session, session?.snapshotEvents?.() || []);
836
+ }
837
+
781
838
  surfaceToolIds(session) {
782
839
  const ids = [];
783
840
  for (const seq of session?.surface?.nodes || []) {
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.0",
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>",
@@ -37,7 +37,7 @@
37
37
  "compaction"
38
38
  ],
39
39
  "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"
40
+ "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"
41
41
  },
42
42
  "engines": {
43
43
  "node": "^22.19 || >=24",
package/plan.js CHANGED
@@ -746,15 +746,26 @@ function spanTokens(items, start, end) {
746
746
  function walkOffTail(items, end) {
747
747
  while (end >= 0) {
748
748
  const item = items[end];
749
+ // 卡片是停止线。走到它就没有可切的尾巴,不能再往左切进已缓存的前缀。
750
+ // A checkpoint is the floor. Reaching it means the tail has nothing left to cut.
751
+ if (item.checkpoint) return -1;
749
752
  const shortUser = item.type === "user" && (item.tokens || 0) < 400;
750
- if (item.type !== "assistant" && !shortUser && !item.checkpoint) break;
753
+ if (item.type !== "assistant" && !shortUser) break;
751
754
  end -= 1;
752
755
  }
753
756
  while (end >= 0 && items[end].type === "user") end -= 1;
754
- while (end >= 0 && items[end].checkpoint) end -= 1;
757
+ if (end >= 0 && items[end].checkpoint) return -1;
755
758
  return end;
756
759
  }
757
760
 
761
+ export function cutCrossesNewerCheckpoint(items, end) {
762
+ if (!Array.isArray(items) || !Number.isInteger(end)) return false;
763
+ for (let index = end + 1; index < items.length; index += 1) {
764
+ if (items[index]?.checkpoint) return true;
765
+ }
766
+ return false;
767
+ }
768
+
758
769
  function retreatBefore(items, end, boundary) {
759
770
  if (boundary < 0 || end < boundary) return end;
760
771
  end = boundary - 1;
@@ -805,6 +816,9 @@ export function selectCutIndexes(items, {
805
816
  const floor = firstUser >= 0 ? firstUser + 1 : 0;
806
817
  const end = walkOffTail(items, limit);
807
818
  if (end < 0) return null;
819
+ // 切点右边还有卡片,说明这一刀在已缓存前缀里。不切。
820
+ // A checkpoint to the right means this span is inside the cached prefix. Do not cut.
821
+ if (cutCrossesNewerCheckpoint(items, end)) return null;
808
822
  if (!cloud) {
809
823
  if (lastUser >= 0 && end <= lastUser) return null;
810
824
  if ((items[end].tokens || 0) > pointerMax && items[end].type === "tool") return { start: end, end, pointerOnly: true, gain: 0 };
package/shadow.js CHANGED
@@ -11,6 +11,7 @@ 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,
@@ -69,6 +70,7 @@ export function shadowFileBody(sessionKey, body) {
69
70
  }
70
71
 
71
72
  export const STATS_NAME = "spine-stats.json";
73
+ export const CUTS_NAME = "spine-cuts.jsonl";
72
74
 
73
75
  export function readCutStats(dir) {
74
76
  try {
@@ -77,9 +79,79 @@ export function readCutStats(dir) {
77
79
  cuts: Number(parsed.cuts) || 0,
78
80
  savedTokens: Number(parsed.savedTokens) || 0,
79
81
  rewriteTokens: Number(parsed.rewriteTokens) || 0,
82
+ drains: Number(parsed.drains) || 0,
80
83
  };
81
84
  } catch {
82
- return { cuts: 0, savedTokens: 0, rewriteTokens: 0 };
85
+ return { cuts: 0, savedTokens: 0, rewriteTokens: 0, drains: 0 };
86
+ }
87
+ }
88
+
89
+ export function drainRewrite(notes) {
90
+ const cuts = (notes || []).filter((note) => note?.kind === "cut" && note.saved > 0);
91
+ if (!cuts.length) return 0;
92
+ const later = cuts.slice(1).reduce((sum, note) => sum + (note.saved || 0), 0);
93
+ return Math.max(0, (cuts[0].afterPrefix || 0) - later);
94
+ }
95
+
96
+ export function flushDrainStats(dir, notes, meta = {}) {
97
+ const cuts = (notes || []).filter((note) => note?.kind === "cut" && note.saved > 0);
98
+ const charged = drainRewrite(notes);
99
+ const saved = cuts.reduce((sum, note) => sum + note.saved, 0);
100
+ if (cuts.length) recordDrainStats(dir, { cuts: cuts.length, saved, rewrite: charged });
101
+ const lines = (notes || []).map((note, index) => JSON.stringify({
102
+ v: 1,
103
+ ts: new Date().toISOString(),
104
+ sid: meta.sid || "",
105
+ kind: note.kind || "cut",
106
+ pass: index,
107
+ drain: meta.drain === true,
108
+ saved: note.saved || 0,
109
+ rewriteCharged: note.kind === "cut" && index === (notes || []).findIndex((item) => item?.kind === "cut") ? charged : 0,
110
+ tailBeyondKept: note.tailBeyondKept || 0,
111
+ prefix: note.prefix || 0,
112
+ afterPrefix: note.afterPrefix || 0,
113
+ tokens: note.tokens || 0,
114
+ total: note.total || 0,
115
+ gate: note.gate || "cut",
116
+ }));
117
+ if (!lines.length) return charged;
118
+ try {
119
+ appendFileSync(join(dir, CUTS_NAME), `${lines.join("\n")}\n`);
120
+ } catch {}
121
+ return charged;
122
+ }
123
+
124
+ function recordDrainStats(dir, { cuts = 0, saved = 0, rewrite = 0 } = {}) {
125
+ if (!(cuts > 0) || !(saved > 0)) return null;
126
+ try {
127
+ const file = join(dir, STATS_NAME);
128
+ const lock = join(dir, "spine-stats.lock");
129
+ const tmp = `${file}.${process.pid}-${randomBytes(8).toString("hex")}.tmp`;
130
+ let fd;
131
+ try {
132
+ fd = openSync(lock, "wx");
133
+ } catch {
134
+ return null;
135
+ }
136
+ try {
137
+ const prev = readCutStats(dir);
138
+ const locked = {
139
+ cuts: prev.cuts + cuts,
140
+ savedTokens: prev.savedTokens + saved,
141
+ rewriteTokens: prev.rewriteTokens + rewrite,
142
+ drains: (Number(prev.drains) || 0) + 1,
143
+ updatedAt: new Date().toISOString(),
144
+ };
145
+ writeFileSync(tmp, JSON.stringify(locked));
146
+ renameSync(tmp, file);
147
+ return locked;
148
+ } finally {
149
+ try { unlinkSync(tmp); } catch {}
150
+ closeSync(fd);
151
+ try { unlinkSync(lock); } catch {}
152
+ }
153
+ } catch {
154
+ return null;
83
155
  }
84
156
  }
85
157