dsh-live-compact 1.0.7 → 1.0.8

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 (4) hide show
  1. package/README.md +19 -7
  2. package/index.js +96 -56
  3. package/package.json +1 -1
  4. package/plan.js +45 -24
package/README.md CHANGED
@@ -92,6 +92,18 @@ dsh-live-compact --version
92
92
 
93
93
  ![原版对比](docs/slide-stock.jpg)
94
94
 
95
+ ## 1.0.8
96
+
97
+ 同一套 8 组、184 个填日志回合。真实 DeepSeek。跳过工具 0,WARN 184/184,NOTICE 184/184。缓存命中 98.23%,1.0.6 是 97.88%,原版是 98.51%。题末平均 2.6 万,最高 3.6 万,最低 1.6 万。1.0.6 最高 3.5 万。原版最高 12.6 万。费用按离峰用量估算,1.0.8 是 $0.209,1.0.6 是 $0.211,原版是 $0.229,不是账单。切掉的原文 11.4 万 token。原版没有压缩,8 组题末加起来积累了 34.9 万。
98
+
99
+ ![1.0.8 和 1.0.6、原版](docs/slide-108-vs.jpg)
100
+
101
+ 卡片不再使用官方的 `<compacted-summary>` 和 `compact-checkpoint`。官方压缩会把那种节点当成上一版摘要再摘一次,切片摘要会越压越短。新卡片用 `spine-checkpoint`。已经写过的旧卡片仍会停住,不会再切一遍。
102
+
103
+ 遇到已有卡片就停止往前扩。这是故意的。卡片已经换过一次,再穿过去会改掉已经进缓存的前缀。最新一段不够 1000,就跳过,不回头凑。
104
+
105
+ 单个工具结果超过 3.2 万 token 才只留指针。默认不改。要自己调,启动前设 `SPINE_POINTER_TOKENS`,例如 `8000`。不按占比算。
106
+
95
107
  ## 1.0.7 长填日志
96
108
 
97
109
  1.0.7 跑了同一套 8 组。真实 DeepSeek。184 个填日志回合都调用了工具。WARN 183/184,NOTICE 184/184,假 NOTICE 是 0。8 组都切过,一共 43 刀。卡片 43 张都有会话序号,没有备份路径。
@@ -100,7 +112,7 @@ dsh-live-compact --version
100
112
 
101
113
  ![1.0.7 长填日志](docs/slide-107-fill.jpg)
102
114
 
103
- 和已发布的 1.0.6 同一套题比:跳过工具都是 0。费用按离峰用量估算,1.0.7 是 $0.249,1.0.6 是 $0.211,不是账单。缓存命中 96.10%,上一轮 1.0.6 是 97.88%。题末上下文 1.0.7 平均 2.3 万、最高 3.1 万、最低 1.9 万,8 组都在 3.2 万以内。1.0.6 平均 2.6 万、最高 3.5 万。65 万停切线没动。
115
+ 和已发布的 1.0.6、以及上次同一套题的官方压缩比。跳过工具都是 0。费用按离峰用量估算,1.0.7 是 $0.249,1.0.6 是 $0.211,原版是 $0.229,不是账单。缓存命中 1.0.7 是 96.10%,1.0.6 是 97.88%,原版是 98.51%。题末上下文 1.0.7 平均 2.3 万、最高 3.1 万、最低 1.9 万,8 组都在 3.2 万以内。1.0.6 平均 2.6 万、最高 3.5 万。原版平均 4.4 万、最高 12.6 万,有两组到了 8.0 万和 12.6 万。切掉的原文,1.0.7 是 21.5 万 token,1.0.6 是 9.0 万。原版这一轮没有压缩,8 组题末上下文加起来积累了 34.9 万,最高一组 12.6 万。65 万停切线没动。
104
116
 
105
117
  ![和 1.0.6 比](docs/slide-107-vs106.jpg)
106
118
 
@@ -170,8 +182,8 @@ dsh-live-compact --version
170
182
  从 npm 装。headless 和网页是两个 profile,要各装一次。桌面端是另一个 profile,普通 `dsh plugin --profile desktop` 会被拒绝。
171
183
 
172
184
  ```sh
173
- dsh plugin --profile headless add dsh-live-compact@1.0.7
174
- dsh plugin --profile web add dsh-live-compact@1.0.7
185
+ dsh plugin --profile headless add dsh-live-compact@1.0.8
186
+ dsh plugin --profile web add dsh-live-compact@1.0.8
175
187
  ```
176
188
 
177
189
  想先装到本机看版本:
@@ -181,12 +193,12 @@ npm install -g dsh-live-compact
181
193
  dsh-live-compact --version
182
194
  ```
183
195
 
184
- 备用:上不了 npm 的,用国内镜像站的包。1.0.2 到 1.0.6 的旧链接仍在。
196
+ 备用:上不了 npm 的,用国内镜像站的包。1.0.2 到 1.0.7 的旧链接仍在。
185
197
 
186
198
  ```sh
187
- dsh plugin --profile headless add https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.0.7.tgz
188
- dsh plugin --profile web add https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.0.7.tgz
189
- npm install -g --force "https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.0.7.tgz"
199
+ dsh plugin --profile headless add https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.0.8.tgz
200
+ dsh plugin --profile web add https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.0.8.tgz
201
+ npm install -g --force "https://download.merkyorlynn.com/downloads/plugins/dsh-live-compact-1.0.8.tgz"
190
202
  dsh-live-compact --version
191
203
  ```
192
204
 
package/index.js CHANGED
@@ -6,31 +6,31 @@
6
6
  * Notes follow UnwhiteCat's PR #3, rewritten for the current behavior.
7
7
  *
8
8
  * 小切在轮次结束后做,不调模型。最近 3 个已结束的工具轮留在原处。
9
- * 本地切点不越过最后一条用户消息。云端最新一段不足 1000 时才跨较早的用户消息。
10
- * 最后一条用户消息不进切段。不够 1000 token 就跳过。超过 65 万停掉小切,交给官方大压缩。
11
- * The small cut runs after the turn, with no model call. The latest 3 finished
12
- * tool rounds stay native. Local cuts stay after the last user. Cloud may include
13
- * earlier users only when the newest span is under 1000. The last user never
14
- * enters a span. Under 1000 tokens, skip. Above 650000, small cuts stop for the host gate.
9
+ * 切法和 1.0.6 一样:不越过用户消息,最近的工具轮按原预算留在原处。
10
+ * 不够 1000 token 就跳过,不回头凑。超过 65 万停掉小切,交给官方大压缩。
11
+ * The small cut runs after the turn, with no model call. The cut matches 1.0.6:
12
+ * it does not cross a user message, and recent tool rounds stay within the
13
+ * original token budget. Under 1000 tokens, skip. Do not walk back to manufacture
14
+ * a cut. Above 650000, small cuts stop for the host gate.
15
15
  */
16
16
  import { randomBytes, randomUUID } from "node:crypto";
17
17
  import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
18
18
  import { homedir } from "node:os";
19
19
  import { join } from "node:path";
20
20
  import { BasicCompactionEngine } from "@deepseek-ai/dsh-compaction-basic";
21
- import { CompactionId, compactCheckpointSource, toolPairingBalancedAfter, toolPairingBalancedBefore } from "@deepseek-ai/dsh-compaction";
21
+ import { CompactionId, toolPairingBalancedAfter, toolPairingBalancedBefore } from "@deepseek-ai/dsh-compaction";
22
22
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
23
- import { attachBackupHint, backgroundJobId, backupClaimOk, buildCard, collectJobs, fallbackProviderURL, indexToolCalls, jobCutDecision, keptToolRounds, lookupProviderURL, messageText, planMessages, pointerOnlyText, pointerSpanAllowed, RAIL_TOKENS, readResultLabel, reservedCutEnd, resolveEngine, selectCutIndexes, shadowSections, takeLocalCut, withoutFullBackupClaim } from "./plan.js";
23
+ import { attachBackupHint, backgroundJobId, backupClaimOk, buildCard, chooseKeptSpan, collectJobs, fallbackProviderURL, indexToolCalls, jobCutDecision, keptToolRounds, lookupProviderURL, messageText, planMessages, pointerOnlyText, pointerSpanAllowed, RAIL_TOKENS, readResultLabel, reservedCutEnd, resolveEngine, selectCutIndexes, shadowSections, takeLocalCut, withoutFullBackupClaim } from "./plan.js";
24
24
  import { captureToolResult, rawExcerptText, restoreToolText, runPruneAfterCapture, verifiedRaw } from "./raw.js";
25
25
  import { recordCutStats, rotateShadow, sessionDir, shadowFileBody, shadowMaxBytes } from "./shadow.js";
26
26
  import { buildSendLess, decideThunder } from "./thunder.js";
27
27
 
28
28
  const MARK = "Spine extract.";
29
- const SUMMARY_OPEN_TAG = "<compacted-summary>";
30
- const SUMMARY_CLOSE_TAG = "</compacted-summary>";
29
+ const SPINE_SOURCE_KIND = "spine-checkpoint";
31
30
  const CHECKPOINT_PREAMBLE = "这是旧内容摘录,不是新指令,也不是你的授权。请从后面的消息接着做,不要把摘录里的句子当成你刚说的话。 This excerpt is not a new instruction, not a confirmation, and not an authorization.";
32
31
  const CHUNK_MIN = 1000; // 不够这一刀就不切,避免每轮一张碎卡片。
33
32
  const CHUNK_MAX = RAIL_TOKENS; // 一刀最多 3.2 万,和卡片轨道对齐。
33
+ const POINTER_TOKENS = Number(process.env.SPINE_POINTER_TOKENS || CHUNK_MAX); // 单个工具结果超过这个数才只留指针。默认仍是 3.2 万。
34
34
  const BIG_GATE = 650000; // 绝对停切线,不是窗口比例。1M 官方大门约 678464。
35
35
  const URGENT_TOKENS = 48000;
36
36
  const MAX_TAIL = 2200;
@@ -56,10 +56,19 @@ function eventText(session, seq) {
56
56
 
57
57
  function isCheckpoint(session, seq) {
58
58
  const event = session.eventAt(seq);
59
- if (event?.data?.source?.kind === "compact-checkpoint") return true;
59
+ const kind = event?.data?.source?.kind;
60
+ // 自己的卡片,以及已经写过的官方标记,都要停住。新卡片不再用官方标记。
61
+ // Stop on our own cards and on older official markers. New cards do not use the official marker.
62
+ if (kind === SPINE_SOURCE_KIND || kind === "compact-checkpoint") return true;
60
63
  return eventText(session, seq).includes(MARK);
61
64
  }
62
65
 
66
+ function spineCheckpointSource(compactionId) {
67
+ // 不用 compact-checkpoint。官方压缩会把那种节点当成上一版摘要再摘一次。
68
+ // Do not use compact-checkpoint. Official compaction treats that kind as a prior summary and shrinks it again.
69
+ return Object.freeze({ kind: SPINE_SOURCE_KIND, compactionId });
70
+ }
71
+
63
72
  function eventRole(session, seq) {
64
73
  const type = session.eventAt(seq)?.type;
65
74
  if (type === "user/message") return "user";
@@ -126,15 +135,11 @@ function shadowFilePath(requested, key) {
126
135
  return freshShadowFile("shadow", key);
127
136
  }
128
137
 
129
- // 从后往前选一刀。最后一条用户消息不进切段。云端最新一段不足 1000 时,较早的用户消息可以进卡片。
130
- // 工具对必须成对。最近 KEEP_ROUNDS 个已结束工具轮不进这刀。不够 CHUNK_MIN 就返回 null。
131
- // 单个超过 CHUNK_MAX 的工具结果只留指针,并连同对应 tool-call 一起替换。
132
- // 这段里不能有最后一条用户消息或 checkpoint。
133
- // Pick one span from the tail. The last user stays out. Cloud may card earlier users
134
- // only when the newest span is under 1000. Tool pairs stay balanced.
135
- // The newest KEEP_ROUNDS finished tool rounds stay out of the cut. Under CHUNK_MIN, return null.
136
- // One oversized tool result becomes a pointer and is replaced together with its tool-call.
137
- // That span must not contain the last user or a checkpoint.
138
+ // 从后往前选一刀,和 1.0.6 一样。不越过用户消息。工具对必须成对。
139
+ // 最近的工具轮按 KEEP_ROUND_TOKENS 留在原处。不够 CHUNK_MIN 就返回 null。
140
+ // Pick one span from the tail, as in 1.0.6. Do not cross a user message.
141
+ // Tool pairs stay balanced. Recent tool rounds stay within KEEP_ROUND_TOKENS.
142
+ // Under CHUNK_MIN, return null.
138
143
  function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}) {
139
144
  const decision = jobCutDecision(context.jobs || []);
140
145
  if (!decision.cut) return null;
@@ -161,7 +166,13 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
161
166
  visible.push({ index, type: event?.type === "tool/result" ? "tool" : "other", toolCalls });
162
167
  }
163
168
  const rounds = keptToolRounds(visible, KEEP_ROUNDS);
164
- const span = rounds.length ? { start: rounds[0].start, end: rounds[rounds.length - 1].end } : null;
169
+ // 收尾也留最近 3 个工具轮。刚读的日志还要用来答题,不能整段换掉。
170
+ // A drain still keeps the latest 3 tool rounds. The log just read is needed for the next answer.
171
+ const span = chooseKeptSpan(rounds, (from, to) => {
172
+ let tokens = 0;
173
+ for (let index = visible[from.start].index; index <= visible[to.end].index; index += 1) tokens += priced[index].tokens;
174
+ return tokens;
175
+ }, KEEP_ROUND_TOKENS);
165
176
  const cutEnd = span ? reservedCutEnd(visible[span.start].index, first) : null;
166
177
  if (span && cutEnd === null) return null;
167
178
  if (cutEnd !== null) {
@@ -235,7 +246,12 @@ function selectNewestChunk(session, measurement, key = DEFAULT_KEY, context = {}
235
246
  engine: context.engine || "cloud",
236
247
  chunkMin: CHUNK_MIN,
237
248
  chunkMax: CHUNK_MAX,
249
+ pointerMax: POINTER_TOKENS,
238
250
  maxEnd: end,
251
+ totalTokens: context.totalTokens,
252
+ drain: context.drain === true,
253
+ urgentTokens: URGENT_TOKENS,
254
+ keepBudget: KEEP_ROUND_TOKENS,
239
255
  });
240
256
  if (!picked) return null;
241
257
  if (picked.pointerOnly) {
@@ -333,6 +349,8 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
333
349
  approvedCard: null,
334
350
  lastPrefix: "",
335
351
  lastPrefixTokens: 0,
352
+ turnBase: null,
353
+ maxTurnGrowth: 0,
336
354
  };
337
355
  this.sessionState.set(key, state);
338
356
  if (this.sessionState.size > MAX_TRACKED_SESSIONS) {
@@ -367,45 +385,66 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
367
385
  if (measurement.totalTokens < MIN_PRESSURE || measurement.totalTokens >= BIG_GATE) return;
368
386
  this.ensurePruneGuard();
369
387
  this.captureSession(agent.session);
370
- const events = agent.session.snapshotEvents();
371
388
  const resolved = this.engineFor(agent);
372
389
  if (state.engine !== resolved.engine || state.engineSource !== resolved.source) {
373
390
  this.ctx.logger?.info?.(`spine engine=${resolved.engine} source=${resolved.source}`);
374
391
  state.engine = resolved.engine;
375
392
  state.engineSource = resolved.source;
376
393
  }
377
- const chunk = selectNewestChunk(agent.session, measurement, state.key, {
378
- engine: resolved.engine,
379
- jobs: collectJobs(events),
380
- calls: indexToolCalls(events),
381
- rawLookup: (id) => this.rawSeen.get(`${state.key}\0${id}`),
382
- });
383
- if (chunk === null) {
384
- this.ctx.logger?.debug?.("spine cut skipped");
385
- return;
386
- }
387
- const tailBeyondKept = chunk.after - (chunk.keptTokens || 0);
388
- if (tailBeyondKept > MAX_TAIL && measurement.totalTokens < URGENT_TOKENS) return;
389
- const before = measurement.totalTokens;
390
- const started = Date.now();
391
- let reduced = false;
392
- try {
393
- state.localOnly = true;
394
- state.approvedCard = chunk.card;
395
- await this.commitSelectedSpan(agent, chunk, op);
396
- const after = this.ctx.tokenMeter.measure(agent.session).totalTokens;
397
- reduced = after < before;
398
- const saved = before - after;
399
- const stats = saved > 0 ? recordCutStats(shadowDirectory(), { saved, rewrite: Math.max(0, after - (chunk.prefix || 0)) }) : null;
400
- const total = stats ? stats.savedTokens : "unknown";
401
- this.ctx.logger?.info?.(`spine cut ${Date.now() - started}ms ${before} -> ${after} chunk=${chunk.tokens} saved=${saved} total=${total}`);
402
- } catch (error) {
403
- const message = error instanceof Error ? error.message : String(error);
404
- this.ctx.logger?.warn?.(`spine cut failed: ${message}`);
405
- }
406
- if (!reduced) {
407
- this.ctx.logger?.warn?.(`spine cut did not reduce ${before}; leaving the prefix for the gate`);
394
+ const cloud = resolved.engine !== "local";
395
+ // 预测下一轮会过 32000 才收尾。实际总量回到 32000 就停,不收到 16000。
396
+ // Drain when the next request would pass 32000. Stop at the measured 32000. Do not cut down to 16000.
397
+ const last = measurement.totalTokens;
398
+ const base = state.turnBase ?? last;
399
+ const growth = Math.max(0, last - base);
400
+ state.maxTurnGrowth = Math.max(state.maxTurnGrowth || 0, growth);
401
+ let drain = cloud && last + state.maxTurnGrowth > KEEP_ROUND_TOKENS;
402
+ for (let pass = 0; pass < 8; pass += 1) {
403
+ const measured = this.ctx.tokenMeter.measure(agent.session);
404
+ if (drain && measured.totalTokens <= KEEP_ROUND_TOKENS) break;
405
+ const chunk = selectNewestChunk(agent.session, measured, state.key, {
406
+ engine: resolved.engine,
407
+ jobs: collectJobs(agent.session.snapshotEvents()),
408
+ calls: indexToolCalls(agent.session.snapshotEvents()),
409
+ rawLookup: (id) => this.rawSeen.get(`${state.key}\0${id}`),
410
+ totalTokens: measured.totalTokens,
411
+ drain,
412
+ });
413
+ if (chunk === null) {
414
+ if (drain && measured.totalTokens > KEEP_ROUND_TOKENS) {
415
+ this.ctx.logger?.warn?.(`spine drain stopped at ${measured.totalTokens}; no legal span`);
416
+ } else {
417
+ this.ctx.logger?.debug?.("spine cut skipped");
418
+ }
419
+ break;
420
+ }
421
+ const tailBeyondKept = chunk.after - (chunk.keptTokens || 0);
422
+ if (!drain && tailBeyondKept > MAX_TAIL && measured.totalTokens < URGENT_TOKENS) break;
423
+ const before = measured.totalTokens;
424
+ const started = Date.now();
425
+ let reduced = false;
426
+ try {
427
+ state.localOnly = true;
428
+ state.approvedCard = chunk.card;
429
+ await this.commitSelectedSpan(agent, chunk, op);
430
+ const after = this.ctx.tokenMeter.measure(agent.session).totalTokens;
431
+ reduced = after < before;
432
+ const saved = before - after;
433
+ const stats = saved > 0 ? recordCutStats(shadowDirectory(), { saved, rewrite: Math.max(0, after - (chunk.prefix || 0)) }) : null;
434
+ const total = stats ? stats.savedTokens : "unknown";
435
+ this.ctx.logger?.info?.(`spine cut ${Date.now() - started}ms ${before} -> ${after} chunk=${chunk.tokens} saved=${saved} total=${total}`);
436
+ } catch (error) {
437
+ const message = error instanceof Error ? error.message : String(error);
438
+ this.ctx.logger?.warn?.(`spine cut failed: ${message}`);
439
+ break;
440
+ }
441
+ if (!reduced) {
442
+ this.ctx.logger?.warn?.(`spine cut did not reduce ${before}; leaving the prefix for the gate`);
443
+ break;
444
+ }
445
+ if (!drain) break;
408
446
  }
447
+ state.turnBase = this.ctx.tokenMeter.measure(agent.session).totalTokens;
409
448
  await this.ctx.sessions?.flush?.(agent.session);
410
449
  this.rememberPrefix(agent);
411
450
  });
@@ -439,11 +478,10 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
439
478
  const compactionId = CompactionId(randomUUID());
440
479
  const checkpointMessage = createUserMessage({
441
480
  content: [
442
- { type: "text", text: `${CHECKPOINT_PREAMBLE}\n\n${SUMMARY_OPEN_TAG}` },
481
+ { type: "text", text: `${CHECKPOINT_PREAMBLE}\n\n${MARK}` },
443
482
  ...summarized.summary,
444
- { type: "text", text: SUMMARY_CLOSE_TAG },
445
483
  ],
446
- source: compactCheckpointSource(compactionId),
484
+ source: spineCheckpointSource(compactionId),
447
485
  });
448
486
  const framedTokens = this.ctx.tokenMeter.estimateMessage?.(checkpointMessage);
449
487
  if (typeof framedTokens === "number" && framedTokens >= chunk.tokens) {
@@ -502,6 +540,8 @@ export default class SpineCompactionEngine extends BasicCompactionEngine {
502
540
  return super.compactIfNeeded(agent, trigger, signal);
503
541
  }
504
542
 
543
+ // 请求头往往只有 provider。先读目录里的地址,再对官方 DeepSeek 用公网默认端点。
544
+ // Headers often carry only the provider. Read a catalog URL, then use the public DeepSeek endpoint.
505
545
  engineFor(agent) {
506
546
  const header = agent?.session?.requestHeader?.()?.config || {};
507
547
  const context = agent?.session?.requestContext?.() || {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-live-compact",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
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
  "type": "module",
package/plan.js CHANGED
@@ -561,6 +561,7 @@ export function isLocalHost(host) {
561
561
  }
562
562
 
563
563
  // 宿主 listProviders 只给 {id, name},没有 baseURL。按 id 或 provider 取地址。
564
+ // The host list only has {id, name}. Match either field and read a URL if one is present.
564
565
  export function lookupProviderURL(provider, listed) {
565
566
  const name = String(provider || "");
566
567
  if (!name) return "";
@@ -570,6 +571,8 @@ export function lookupProviderURL(provider, listed) {
570
571
 
571
572
  // 官方 DeepSeek 的请求头没有 baseURL。没读到自定义地址时用公网默认端点,避免被当成本地而停切。
572
573
  // Strata 仍由 provider 名判断,不走这里。
574
+ // Official DeepSeek headers omit baseURL. Use the public endpoint so it is not treated as local.
575
+ // A provider name containing strata stays local and does not use this fallback.
573
576
  export function fallbackProviderURL(provider) {
574
577
  if (/^(?:deepseek-official|deepseek)$/i.test(String(provider || ""))) return "https://api.deepseek.com/anthropic";
575
578
  return "";
@@ -638,49 +641,64 @@ function balanced(items, start, end) {
638
641
  return true;
639
642
  }
640
643
 
641
- export function selectCutIndexes(items, { engine = "cloud", chunkMin = 1000, chunkMax = 32000, maxEnd = null } = {}) {
644
+ export function selectCutIndexes(items, {
645
+ engine = "cloud",
646
+ chunkMin = 1000,
647
+ chunkMax = 32000,
648
+ pointerMax = chunkMax,
649
+ maxEnd = null,
650
+ totalTokens,
651
+ drain = false,
652
+ urgentTokens = 48000,
653
+ keepBudget = 32000,
654
+ } = {}) {
642
655
  if (!items?.length) return null;
643
- const limit = maxEnd === null ? items.length - 1 : maxEnd;
656
+ const cloud = engine !== "local";
657
+ // 云端平时不切。到原有紧急线,或这一轮已经在收尾,才批量切。
658
+ // Cloud does not cut on an ordinary turn. Drain only at the existing urgent line, or when this turn already started a drain.
659
+ const draining = cloud && (drain || (typeof totalTokens === "number" && totalTokens >= urgentTokens));
660
+ // 收尾时即使已经低于 32000 也继续切到这批的下限。平时低于 32000 不切。
661
+ // During a drain, keep cutting below 32000. On an ordinary turn, stay under 32000 and do not cut.
662
+ if (!drain && cloud && typeof totalTokens === "number" && totalTokens <= keepBudget) return null;
663
+ const limit = draining ? items.length - 1 : (maxEnd === null ? items.length - 1 : maxEnd);
644
664
  if (limit < 0) return null;
645
665
  const lastUser = items.reduce((found, item, index) => (item.type === "user" ? index : found), -1);
646
666
  const firstUser = items.findIndex((item) => item.type === "user");
647
667
  const floor = firstUser >= 0 ? firstUser + 1 : 0;
648
668
  const oversized = [];
649
669
  for (let index = limit; index >= 0; index -= 1) {
650
- if ((items[index].tokens || 0) <= chunkMax || items[index].type !== "tool") continue;
651
- if (engine === "local" && lastUser >= 0 && index <= lastUser) continue;
670
+ if (items[index].type !== "tool" || (items[index].tokens || 0) <= pointerMax) continue;
671
+ if (!cloud && lastUser >= 0 && index <= lastUser) continue;
652
672
  oversized.push(index);
653
673
  }
654
- let end = walkOffTail(items, limit);
655
- if (end < 0) return oversized.length ? { start: oversized[0], end: oversized[0], pointerOnly: true, gain: 0 } : null;
656
- if (engine === "local") {
657
- if (lastUser >= 0 && end <= lastUser) return oversized.length ? { start: oversized[0], end: oversized[0], pointerOnly: true, gain: 0 } : null;
658
- if ((items[end].tokens || 0) > chunkMax && items[end].type === "tool") return { start: end, end, pointerOnly: true, gain: 0 };
674
+ const pointer = oversized.length ? { start: oversized[0], end: oversized[0], pointerOnly: true, gain: 0 } : null;
675
+ // 没到紧急线时,云端只处理超大工具结果,不因一个刚老化的结果改写前缀。
676
+ // Below the urgent line, cloud only pointer-cuts an oversized tool. One newly aged result does not rewrite the prefix.
677
+ if (cloud && !draining) return pointer;
678
+ const end = walkOffTail(items, limit);
679
+ if (end < 0) return pointer;
680
+ if (!cloud) {
681
+ if (lastUser >= 0 && end <= lastUser) return pointer;
682
+ if ((items[end].tokens || 0) > pointerMax && items[end].type === "tool") return { start: end, end, pointerOnly: true, gain: 0 };
659
683
  if ((items[end].tokens || 0) > chunkMax) return null;
660
684
  const local = expandBack(items, end, Math.max(floor, lastUser), { crossUser: false, chunkMax });
661
685
  if (local.gain < chunkMin || !balanced(items, local.start, local.end)) return null;
662
686
  return local;
663
687
  }
664
- if ((items[end].tokens || 0) > chunkMax && items[end].type === "tool") return { start: end, end, pointerOnly: true, gain: 0 };
688
+ if ((items[end].tokens || 0) > pointerMax && items[end].type === "tool") return { start: end, end, pointerOnly: true, gain: 0 };
665
689
  const phase = expandBack(items, end, 0, { crossUser: false, chunkMax });
666
- if (phase.gain >= chunkMin) {
667
- if (phase.tokens <= chunkMax && balanced(items, phase.start, phase.end) && !(lastUser >= phase.start && lastUser <= phase.end)) return phase;
668
- return null;
669
- }
670
- end = retreatBefore(items, end, lastUser);
671
- if (end < floor) return oversized.length ? { start: oversized[0], end: oversized[0], pointerOnly: true, gain: 0 } : null;
672
- if ((items[end].tokens || 0) > chunkMax && items[end].type === "tool") return { start: end, end, pointerOnly: true, gain: 0 };
673
- const extended = expandBack(items, end, floor - 1, { crossUser: true, chunkMax });
674
- if (extended.start < floor) extended.start = floor;
675
- extended.gain = nonUserGain(items, extended.start, extended.end);
676
- extended.tokens = spanTokens(items, extended.start, extended.end);
677
- if (extended.tokens > chunkMax || extended.gain < chunkMin || !balanced(items, extended.start, extended.end)) return null;
678
- if (lastUser >= extended.start && lastUser <= extended.end) return null;
679
- return extended;
690
+ // 不跨 user 去凑 1000。不够就跳过。
691
+ // Do not walk across users to manufacture 1000 tokens. Under that, skip.
692
+ if (phase.gain < chunkMin) return pointer;
693
+ if (phase.tokens > chunkMax || !balanced(items, phase.start, phase.end)) return null;
694
+ if (lastUser >= phase.start && lastUser <= phase.end) return null;
695
+ return phase;
680
696
  }
681
697
 
682
698
  // pointerOnly 扩到 tool-call 之后,这一段仍不能包含最后一条 user 或 checkpoint。
683
699
  // 本地整段必须严格晚于最后一条 user。较早的 user 可以留在段内,由卡片用 JSON 原文保住。
700
+ // After expanding to the tool-call, the span still must not contain the last user or a checkpoint.
701
+ // A local span must start after the last user. An earlier user may stay and be kept as JSON.
684
702
  export function pointerSpanAllowed(items, start, end, engine = "cloud") {
685
703
  if (!Array.isArray(items) || !Number.isInteger(start) || !Number.isInteger(end)) return false;
686
704
  if (start < 0 || end < start || end >= items.length) return false;
@@ -693,6 +711,9 @@ export function pointerSpanAllowed(items, start, end, engine = "cloud") {
693
711
  return true;
694
712
  }
695
713
 
714
+ // 超大工具结果只留序号指针。助手正文不写成工具行。较早用户原文仍是单行 JSON。
715
+ // An oversized tool result keeps a sequence pointer. Assistant prose is not labeled as a tool.
716
+ // An earlier user, if the span includes one, stays as one-line JSON. No backup path.
696
717
  export function pointerOnlyText(pieces) {
697
718
  const tools = (pieces || []).filter((piece) => sourceRole(piece?.role) === "tool" && Number.isInteger(piece.seq));
698
719
  const users = (pieces || []).filter((piece) => sourceRole(piece?.role) === "user" && String(piece.text || "").trim());