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.
- package/README.md +49 -36
- package/index.js +85 -28
- package/package.json +2 -2
- package/plan.js +16 -2
- 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
|
|
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
|
|
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
|
+

|
|
52
|
+
|
|
53
|
+

|
|
54
|
+
|
|
55
|
+

|
|
56
|
+
|
|
57
|
+

|
|
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
|

|
|
31
68
|
|
|
32
69
|

|
|
@@ -92,32 +129,6 @@ dsh-live-compact --version
|
|
|
92
129
|
|
|
93
130
|

|
|
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
|
-

|
|
114
|
-
|
|
115
|
-

|
|
116
|
-
|
|
117
|
-

|
|
118
|
-
|
|
119
|
-

|
|
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`
|
|
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
|
|
252
|
-
dsh plugin --profile web add dsh-live-compact@1.0
|
|
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.
|
|
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.
|
|
266
|
-
dsh plugin --profile web add https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.0.
|
|
267
|
-
npm install -g --force "https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.0.
|
|
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
|
-
|
|
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
|
-
|
|
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 && (
|
|
265
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
499
|
-
calls
|
|
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
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|