opencode-acp 1.12.10-dev.1 → 1.12.10

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
@@ -422,13 +422,13 @@ For the complete list with root cause analysis, see the [bug tracker](https://gi
422
422
 
423
423
  ## Changelog
424
424
 
425
- ### v1.12.10-dev.1 — Decompress Range Mode + Token Classification + Protected Label Accuracy + Nudge Suppression + Discrete Intervals (PRs #73, #155, #157, #158, #159)
425
+ ### v1.12.10 — Batch Compress + Decompress Range Mode + GC Memory-Loss Fix + Token Classification + Nudge Quality (PRs #73, #155, #156, #157, #158, #159, #161)
426
426
 
427
- **Problem**: Five issues across decompress ergonomics, token accounting, and nudge quality. (1) `decompress` required a per-block `acp_status` → decompress-per-block loop to restore multiple compressed blocks. (2) Since v1.12.9 (compress-as-anchor), compress tool `summary` content was misclassified as `toolTokens` instead of `summaryTokens`, inflating tool% and deflating summary% in the context breakdown — misleading the model's compression decisions. (3) The `[PROTECTED: ...]` label listed every tool in a protected message (e.g. `[PROTECTED: grep, skill]`), misleading the model into thinking non-protected tools like `grep` were the reason for protection. (4) When all visible content was protected, the nudge still fired with an empty recommendation list — wasting context and confusing the model. (5) When a nudge was suppressed (filter removed all recommendations), the next-turn check re-evaluated every turn because the baseline never advanced, causing repeated wasted computation.
427
+ **Problem**: Seven issues across compression UX, token accounting, GC safety, and nudge quality. (1) `decompress` required a per-block `acp_status` → decompress-per-block loop to restore multiple compressed blocks. (2) Since v1.12.9 (compress-as-anchor), compress tool `summary` content was misclassified as `toolTokens` instead of `summaryTokens`, inflating tool% and deflating summary% in the context breakdown. (3) The `compress` tool only accepted a single range per call — the model had to issue multiple calls to compress unrelated ranges, wasting turns. (4) The `[PROTECTED: ...]` label listed every tool in a protected message instead of only the triggering tools. (5) When all visible content was protected, the nudge still fired with an empty recommendation list. (6) When a nudge was suppressed, the next-turn check re-evaluated every turn. (7) **The GC system was silently destroying model-written summaries**: any block with `summary.length > 6000` chars was force-truncated to 3000 regardless of context pressure (0% pressure triggered truncation), and blocks with high `survivedCount` were auto-deactivated — causing irrecoverable memory loss across hundreds of sessions.
428
428
 
429
- **Fix**: (1) **PR #73** — Added optional `startId`/`endId` to `decompress` schema; range mode batch-restores every active block whose `effectiveMessageIds` overlaps the resolved range in one call. New pure helper `findActiveBlocksOverlappingMessages` in `decompress-logic.ts`; backward compatible (`blockId` path unchanged). (2) **PR #155** — In `estimateContextComposition` (`lib/messages/inject/utils.ts`), when `toolName === "compress"`, extract `summary` text from `part.state.input.content[].summary` and classify it as `summaryTokens`. Structural overhead remains `toolTokens`. (3) **PR #157** — `buildCompressibleRanges` now only adds tools that actually trigger protection via `isToolNameProtected` or `isFilePathProtected`, mirroring `messageContainsProtectedTool` exactly the label now shows `[PROTECTED: skill]` only. (4) **PR #158** — Added `allProtected = compressible.length === 0 && protected.length > 0` check; `nothingToCompress = filterSuppressed || allProtected` gates nudge injection so soft nudges are suppressed when there's genuinely nothing to compress (the `protected.length > 0` discriminator preserves the "no refs assigned yet" edge case). (5) **PR #159** — When a nudge is suppressed, advance `lastPerMessageNudgeTokens` to `currentTokens` and clear `lastNudgeShownTokens`, creating discrete 5% check intervals instead of every-turn re-evaluation.
429
+ **Fix**: (1) **PR #73** — Added optional `startId`/`endId` to `decompress` schema; range mode batch-restores every active block whose `effectiveMessageIds` overlaps the resolved range. (2) **PR #155** — In `estimateContextComposition`, when `toolName === "compress"`, extract `summary` text and classify it as `summaryTokens`. (3) **PR #156** — The `compress` tool now accepts a `content` array of `{ topic, startId, endId, summary }` entries, allowing the model to compress multiple unrelated ranges in a single call with per-entry topics. (4) **PR #157** — `buildCompressibleRanges` only adds tools that actually trigger protection. (5) **PR #158** Added `allProtected` check to suppress nudges when there's genuinely nothing to compress. (6) **PR #159** — When a nudge is suppressed, advance `lastPerMessageNudgeTokens` to `currentTokens` for discrete 5% check intervals. (7) **PR #161** — Removed the GC oversized-block override (`hasOversizedBlocks` bypass that truncated at 0% context) and the age-based deactivation loop entirely. Truncation now only fires at `majorGcThresholdPercent` (default 100%). `gc.maxBlockAge` is now a no-op. Aging warning threshold raised from 50% to 90% context to stop misleading the model.
430
430
 
431
- Files: `lib/compress/decompress.ts`, `lib/compress/decompress-logic.ts`, `lib/messages/inject/utils.ts`, `lib/messages/inject/inject.ts`. Tests: 757 pass (11 new for decompress-logic, 5 new for token classification, 4 "all protected" tests fixed to actually exercise the `allProtected` branch, +1 new Scenario A test for compress-after-suppression).
431
+ Files: `lib/hooks.ts`, `lib/config.ts`, `lib/prompts/extensions/nudge.ts`, `lib/compress/decompress.ts`, `lib/compress/decompress-logic.ts`, `lib/messages/inject/utils.ts`, `lib/messages/inject/inject.ts`. Tests: 758 pass.
432
432
 
433
433
  ### v1.12.9 — Compress-as-Anchor (PR #153)
434
434
 
package/README.zh-CN.md CHANGED
@@ -395,13 +395,13 @@ ACP 在首次启动时自动将配置从 `dcp.jsonc` 迁移到 `acp.jsonc`,将
395
395
 
396
396
  ## 更新日志
397
397
 
398
- ### v1.12.10-dev.1 — Decompress 范围模式 + Token 分类 + Protected 标签精确性 + Nudge 抑制 + 离散间隔(PR #73, #155, #157, #158, #159)
398
+ ### v1.12.10 — 批量压缩 + Decompress 范围模式 + GC 记忆丢失修复 + Token 分类 + Nudge 质量(PR #73, #155, #156, #157, #158, #159, #161
399
399
 
400
- **问题**:五个问题,涉及 decompress 易用性、token 统计和 nudge 质量。(1)`decompress` 需要先 `acp_status` 再逐块 decompress 的循环才能恢复多个压缩块。(2)自 v1.12.9compress-as-anchor)起,compress 工具的 `summary` 内容被错误分类为 `toolTokens` 而非 `summaryTokens`,导致上下文分布中 tool% 虚高、summary% 虚低 —— 误导模型的压缩决策。(3)`[PROTECTED: ...]` 标签列出受保护消息中的所有工具(例如 `[PROTECTED: grep, skill]`),让模型误以为 `grep` 等非保护工具也是保护原因。(4)当所有可见内容都是受保护内容时,nudge 仍然以空推荐列表注入 —— 浪费上下文且让模型困惑。(5)当 nudge 被抑制(过滤器移除所有推荐)时,下一轮检查每轮都重新评估,因为 baseline 从不前进,导致重复浪费计算。
400
+ **问题**:七个问题,涉及压缩 UX、token 统计、GC 安全和 nudge 质量。(1)`decompress` 需要先 `acp_status` 再逐块 decompress 的循环才能恢复多个压缩块。(2)自 v1.12.9 起,compress 工具的 `summary` 内容被错误分类为 `toolTokens` 而非 `summaryTokens`,导致上下文分布中 tool% 虚高、summary% 虚低。(3)`compress` 工具每次调用只能压缩一个范围 —— 模型需要多次调用才能压缩不相关的范围,浪费轮次。(4)`[PROTECTED: ...]` 标签列出受保护消息中的所有工具而非仅触发保护的工具。(5)当所有可见内容都是受保护内容时,nudge 仍然以空推荐列表注入。(6)当 nudge 被抑制时,下一轮检查每轮都重新评估。(7)**GC 系统在静默销毁模型编写的 summary**:任何 `summary.length > 6000` 字符的块在零上下文压力下被强制截断到 3000,且 `survivedCount` 过高的块被自动 deactivate —— 导致数百个会话的不可恢复记忆丢失。
401
401
 
402
- **修复**:(1)**PR #73** —— 为 `decompress` schema 新增可选 `startId`/`endId`;范围模式批量恢复所有 `effectiveMessageIds` 与解析范围重叠的活跃块,一次调用完成。新纯函数 `findActiveBlocksOverlappingMessages` 在 `decompress-logic.ts`;向后兼容(`blockId` 路径不变)。(2)**PR #155** —— 在 `estimateContextComposition`(`lib/messages/inject/utils.ts`)中,当 `toolName === "compress"` 时,从 `part.state.input.content[].summary` 提取 `summary` 文本并分类为 `summaryTokens`。结构开销仍计为 `toolTokens`。(3)**PR #157** —— `buildCompressibleRanges` 现在只添加通过 `isToolNameProtected` `isFilePathProtected` 实际触发保护的工具,与 `messageContainsProtectedTool` 逻辑完全一致 —— 标签现在只显示 `[PROTECTED: skill]`。(4)**PR #158** —— 新增 `allProtected = compressible.length === 0 && protected.length > 0` 检查;`nothingToCompress = filterSuppressed || allProtected` 门控 nudge 注入,当确实没有可压缩内容时抑制软 nudge(`protected.length > 0` 判别式保留了"尚未分配 ref"的边缘情况)。(5)**PR #159** —— nudge 被抑制时,将 `lastPerMessageNudgeTokens` 前进到 `currentTokens` 并清除 `lastNudgeShownTokens`,创建离散 5% 检查间隔而非每轮重新评估。
402
+ **修复**:(1)**PR #73** —— 为 `decompress` schema 新增可选 `startId`/`endId`;范围模式批量恢复所有 `effectiveMessageIds` 与解析范围重叠的活跃块。(2)**PR #155** —— 在 `estimateContextComposition` 中,当 `toolName === "compress"` 时,提取 `summary` 文本并分类为 `summaryTokens`。(3)**PR #156** —— `compress` 工具现在接受 `content` 数组(`{ topic, startId, endId, summary }`),允许模型在单次调用中压缩多个不相关范围,每个范围有独立 topic。(4)**PR #157** —— `buildCompressibleRanges` 只添加实际触发保护的工具。(5)**PR #158** —— 新增 `allProtected` 检查,当确实没有可压缩内容时抑制 nudge。(6)**PR #159** —— nudge 被抑制时,将 `lastPerMessageNudgeTokens` 前进到 `currentTokens`,创建离散 5% 检查间隔。(7)**PR #161** —— 删除 GC oversized-block 旁路(`hasOversizedBlocks`,在 0% 上下文压力下截断)和 age-based 自动 deactivate 循环。截断现在只在 `majorGcThresholdPercent`(默认 100%)时触发。`gc.maxBlockAge` 变为 no-op。aging warning 门槛从 50% 提高到 90%,不再误导模型。
403
403
 
404
- 文件:`lib/compress/decompress.ts`、`lib/compress/decompress-logic.ts`、`lib/messages/inject/utils.ts`、`lib/messages/inject/inject.ts`。测试:757 通过(decompress-logic 新增 11 个,token 分类新增 5 个,4 个 "all protected" 测试修复为真正触发 `allProtected` 分支,+1 个 Scenario A 测试覆盖抑制后压缩)。
404
+ 文件:`lib/hooks.ts`、`lib/config.ts`、`lib/prompts/extensions/nudge.ts`、`lib/compress/decompress.ts`、`lib/compress/decompress-logic.ts`、`lib/messages/inject/utils.ts`、`lib/messages/inject/inject.ts`。测试:758 通过。
405
405
 
406
406
  ### v1.12.9 — Compress-as-Anchor(PR #153)
407
407
 
package/dist/index.js CHANGED
@@ -1609,7 +1609,8 @@ var defaultConfig = {
1609
1609
  gc: {
1610
1610
  algorithm: "truncate",
1611
1611
  promotionThreshold: 5,
1612
- maxBlockAge: 15,
1612
+ maxBlockAge: Number.MAX_SAFE_INTEGER,
1613
+ // no-op: age-based deactivation removed (memory-loss fix)
1613
1614
  maxOldGenSummaryLength: 3e3,
1614
1615
  majorGcThresholdPercent: "100%",
1615
1616
  batchCleanup: {
@@ -2131,20 +2132,27 @@ function countMessageCharacters(msg) {
2131
2132
 
2132
2133
  // lib/prompts/extensions/tool.ts
2133
2134
  var RANGE_FORMAT_EXTENSION = `
2135
+
2134
2136
  THE FORMAT OF COMPRESS
2135
2137
 
2136
2138
  \`\`\`
2137
2139
  {
2138
- topic: string, // Short label (3-5 words) - e.g., "Auth System Exploration"
2140
+ topic?: string, // OPTIONAL fallback topic for entries without their own.
2141
+ // Omit when every content entry specifies its own topic.
2139
2142
  content: [ // One or more ranges to compress
2140
2143
  {
2144
+ topic?: string, // OPTIONAL per-entry topic for this range.
2145
+ // Falls back to top-level topic.
2146
+ // Give each entry its own topic when compressing
2147
+ // unrelated ranges in one call.
2141
2148
  startId: string, // Boundary ID at range start: mNNNNN or bN
2142
2149
  endId: string, // Boundary ID at range end: mNNNNN or bN
2143
2150
  summary: string // Complete technical summary replacing all content in range
2144
2151
  }
2145
2152
  ]
2146
2153
  }
2147
- \`\`\``;
2154
+ \`\`\`
2155
+ Each entry needs a topic \u2014 either its own or the top-level fallback.`;
2148
2156
  var MESSAGE_FORMAT_EXTENSION = `
2149
2157
  THE FORMAT OF COMPRESS
2150
2158
 
@@ -3969,9 +3977,7 @@ function applyPendingCompressionDurations(state) {
3969
3977
  // lib/compress/range-utils.ts
3970
3978
  var BLOCK_PLACEHOLDER_REGEX = /\(b(\d+)\)|\{block_(\d+)\}/gi;
3971
3979
  function validateArgs2(args) {
3972
- if (typeof args.topic !== "string" || args.topic.trim().length === 0) {
3973
- throw new Error("topic is required and must be a non-empty string");
3974
- }
3980
+ const hasTopLevelTopic = typeof args.topic === "string" && args.topic.trim().length > 0;
3975
3981
  if (!Array.isArray(args.content) || args.content.length === 0) {
3976
3982
  throw new Error("content is required and must be a non-empty array");
3977
3983
  }
@@ -3987,11 +3993,18 @@ function validateArgs2(args) {
3987
3993
  if (typeof entry?.summary !== "string" || entry.summary.trim().length === 0) {
3988
3994
  throw new Error(`${prefix}.summary is required and must be a non-empty string`);
3989
3995
  }
3996
+ const hasEntryTopic = typeof entry?.topic === "string" && entry.topic.trim().length > 0;
3997
+ if (!hasEntryTopic && !hasTopLevelTopic) {
3998
+ throw new Error(
3999
+ `${prefix} needs a topic \u2014 provide ${prefix}.topic or the top-level topic`
4000
+ );
4001
+ }
3990
4002
  }
3991
4003
  }
3992
4004
  function resolveRanges(args, searchContext, state) {
3993
4005
  return args.content.map((entry, index) => {
3994
4006
  const normalizedEntry = {
4007
+ topic: typeof entry.topic === "string" && entry.topic.trim().length > 0 ? entry.topic.trim() : void 0,
3995
4008
  startId: entry.startId.trim(),
3996
4009
  endId: entry.endId.trim(),
3997
4010
  summary: entry.summary
@@ -4185,8 +4198,8 @@ function rebuildRangeInvocation(state, input, searchContext, invocation, protect
4185
4198
  applyCompressionState(
4186
4199
  state,
4187
4200
  {
4188
- topic: input.topic,
4189
- batchTopic: input.topic,
4201
+ topic: plan.entry.topic ?? input.topic ?? "",
4202
+ batchTopic: typeof input.topic === "string" ? input.topic : void 0,
4190
4203
  startId: plan.entry.startId,
4191
4204
  endId: plan.entry.endId,
4192
4205
  mode: "range",
@@ -4253,7 +4266,7 @@ function rebuildMessageInvocation(state, input, searchContext, invocation, gcCon
4253
4266
  state,
4254
4267
  {
4255
4268
  topic: entry.topic,
4256
- batchTopic: input.topic,
4269
+ batchTopic: typeof input.topic === "string" ? input.topic : void 0,
4257
4270
  startId: entry.messageId,
4258
4271
  endId: entry.messageId,
4259
4272
  mode: "message",
@@ -5076,7 +5089,8 @@ async function sendCompressNotification(client, logger, config, state, sessionId
5076
5089
  newlyCompressedToolIds.push(toolId);
5077
5090
  }
5078
5091
  }
5079
- const topic = batchTopic ?? (entries.length === 1 ? state.prune.messages.blocksById.get(entries[0]?.blockId ?? -1)?.topic ?? "(unknown topic)" : "(unknown topic)");
5092
+ const entryBlockTopics = entries.map((e) => state.prune.messages.blocksById.get(e.blockId)?.topic).filter((t) => typeof t === "string" && t.length > 0);
5093
+ const topic = batchTopic ?? (entries.length === 1 ? state.prune.messages.blocksById.get(entries[0]?.blockId ?? -1)?.topic ?? "(unknown topic)" : entryBlockTopics.length > 0 ? entryBlockTopics.join(" \xB7 ") : "(unknown topic)");
5080
5094
  const contextTokensAfter = Math.max(
5081
5095
  0,
5082
5096
  contextTokensBefore - compressedTokens + summaryTokens
@@ -5578,9 +5592,14 @@ function createCompressMessageTool(ctx) {
5578
5592
  import { tool as tool3 } from "@opencode-ai/plugin";
5579
5593
  function buildSchema2(maxSummaryLengthHard) {
5580
5594
  return {
5581
- topic: tool3.schema.string().describe("Short label (3-5 words) for display - e.g., 'Auth System Exploration'"),
5595
+ topic: tool3.schema.string().optional().describe(
5596
+ "Fallback topic for entries without their own. Omit when each content entry specifies its own topic."
5597
+ ),
5582
5598
  content: tool3.schema.array(
5583
5599
  tool3.schema.object({
5600
+ topic: tool3.schema.string().optional().describe(
5601
+ "Short label (3-5 words) for THIS range, e.g. 'Auth System Exploration'. Omit to use top-level topic. When compressing multiple unrelated ranges, give each its own topic for better quality."
5602
+ ),
5584
5603
  startId: tool3.schema.string().describe(
5585
5604
  "Message or block ID marking the beginning of range (e.g. m00001, b2)"
5586
5605
  ),
@@ -5590,7 +5609,7 @@ function buildSchema2(maxSummaryLengthHard) {
5590
5609
  )
5591
5610
  })
5592
5611
  ).describe(
5593
- "One or more ranges to compress, each with start/end boundaries and a summary"
5612
+ "One or more ranges to compress, each with start/end boundaries and a summary. When compressing multiple unrelated ranges in one call, give each its own topic."
5594
5613
  ),
5595
5614
  summaryMaxChars: tool3.schema.number().optional().describe(
5596
5615
  `Override max summary length (default max: ${maxSummaryLengthHard} chars). Use when content is important and needs more detail \u2014 don't lose critical info just to fit the limit.`
@@ -5624,7 +5643,7 @@ function createCompressRangeTool(ctx) {
5624
5643
  const { rawMessages, searchContext } = await prepareSession(
5625
5644
  ctx,
5626
5645
  toolCtx,
5627
- `Compress Range: ${input.topic}`
5646
+ `Compress Range: ${input.topic ?? "(batch)"}`
5628
5647
  );
5629
5648
  const resolvedPlans = resolveRanges(input, searchContext, ctx.state);
5630
5649
  validateNonOverlapping(resolvedPlans);
@@ -5766,7 +5785,7 @@ function createCompressRangeTool(ctx) {
5766
5785
  const applied = applyCompressionState(
5767
5786
  ctx.state,
5768
5787
  {
5769
- topic: input.topic,
5788
+ topic: preparedPlan.entry.topic ?? input.topic ?? "",
5770
5789
  batchTopic: input.topic,
5771
5790
  startId: preparedPlan.entry.startId,
5772
5791
  endId: preparedPlan.entry.endId,
@@ -5791,7 +5810,13 @@ function createCompressRangeTool(ctx) {
5791
5810
  summaryTokens
5792
5811
  });
5793
5812
  }
5794
- await finalizeSession(ctx, toolCtx, rawMessages, notifications, input.topic);
5813
+ await finalizeSession(
5814
+ ctx,
5815
+ toolCtx,
5816
+ rawMessages,
5817
+ notifications,
5818
+ input.topic
5819
+ );
5795
5820
  } catch (error) {
5796
5821
  restoreCompressionState(ctx.state, snapshot);
5797
5822
  throw error;
@@ -6248,7 +6273,7 @@ function buildCompressedBlockGuidance(state, gcConfig, context) {
6248
6273
  }
6249
6274
  }
6250
6275
  const usageRatio = context?.currentTokens && context?.modelContextLimit ? context.currentTokens / context.modelContextLimit : 0;
6251
- if (gcConfig && usageRatio > 0.5) {
6276
+ if (gcConfig && usageRatio > 0.9) {
6252
6277
  const promotionThreshold = gcConfig.promotionThreshold;
6253
6278
  const agingBlocks = [];
6254
6279
  for (const blockId of activeBlockIds) {
@@ -6266,10 +6291,10 @@ function buildCompressedBlockGuidance(state, gcConfig, context) {
6266
6291
  }
6267
6292
  if (agingBlocks.length > 0) {
6268
6293
  lines.push("");
6269
- lines.push("\u26A0\uFE0F Block aging warning \u2014 these blocks may be truncated by GC soon:");
6294
+ lines.push("\u26A0\uFE0F Block aging warning \u2014 context near limit, these blocks may be truncated by last-resort GC:");
6270
6295
  lines.push(...agingBlocks);
6271
6296
  lines.push(
6272
- "To preserve important content: use the compress tool to re-summarize these blocks into new concise ones. Unhandled blocks will be auto-truncated."
6297
+ "Re-summarize these blocks into concise new ones to preserve key facts. At 100% context, oversized blocks are auto-truncated as a last resort."
6273
6298
  );
6274
6299
  }
6275
6300
  }
@@ -8774,7 +8799,7 @@ TOOLS
8774
8799
 
8775
8800
  You have five context-management tools:
8776
8801
 
8777
- - \`compress\` \u2014 Replace a contiguous range of older conversation with a single detailed summary you write. Use when content is genuinely consumed (no longer needed for the current task step). Example: \`compress({ topic: "API exploration", content: [{ startId: "m00150", endId: "m00220", summary: "..." }] })\`.
8802
+ - \`compress\` \u2014 Replace a contiguous range of older conversation with a single detailed summary you write. Use when content is genuinely consumed (no longer needed for the current task step). Single range: \`compress({ topic: "API exploration", content: [{ startId: "m00150", endId: "m00220", summary: "..." }] })\`. Batch (multiple unrelated ranges, each with its own topic): \`compress({ content: [{ topic: "Auth", startId: "m00150", endId: "m00220", summary: "..." }, { topic: "Deploy", startId: "m00300", endId: "m00350", summary: "..." }] })\`.
8778
8803
  - \`decompress\` \u2014 Restore a previously compressed block's full original content, optionally to a file for large blocks. Use when a summary lacks the exact detail you need. Example: \`decompress({ blockId: "b5" })\` or \`decompress({ blockId: "b5", toFile: "path" })\`.
8779
8804
  - \`search_context\` \u2014 Search compressed block summaries (and optionally visible messages) by keyword. Use BEFORE decompressing to find the right block. Example: \`search_context({ query: "auth token refresh" })\`.
8780
8805
  - \`prune\` \u2014 Remove old tool outputs by tool type, keeping only recent calls. Unlike compress (which creates summaries), prune directly strips outputs. Use for disposable outputs like old todowrite states or edit echoes. Example: \`prune({ toolType: "todowrite", keepLatest: 3 })\`.
@@ -8873,6 +8898,16 @@ Rules:
8873
8898
  BATCHING
8874
8899
  When multiple independent ranges are ready and their boundaries do not overlap, include all of them as separate entries in the \`content\` array of a single tool call. Each entry should have its own \`startId\`, \`endId\`, and \`summary\`.
8875
8900
 
8901
+ When the ranges cover unrelated topics, give each entry its own \`topic\` for better summary quality \u2014 do not force unrelated content under a single shared topic. Omit the top-level \`topic\` when every entry has its own. Use the top-level \`topic\` only as a fallback when entries don't specify one.
8902
+
8903
+ \`\`\`
8904
+ compress({ content: [
8905
+ { topic: "Auth System Exploration", startId: "m00010", endId: "m00050", summary: "..." },
8906
+ { topic: "Bug Hunt", startId: "m00060", endId: "m00080", summary: "..." },
8907
+ { topic: "Deployment", startId: "m00090", endId: "m00110", summary: "..." },
8908
+ ]})
8909
+ \`\`\`
8910
+
8876
8911
  KEEP AND REF MARKERS
8877
8912
  When writing a summary, you may embed markers that reference specific messages in the compressed range. The system resolves them automatically:
8878
8913
 
@@ -10567,46 +10602,9 @@ function createSystemPromptHandler(state, logger, config, prompts) {
10567
10602
  };
10568
10603
  }
10569
10604
  function runMajorGC(state, config, logger, messages) {
10570
- const maxBlockAge = config.gc.maxBlockAge ?? 15;
10571
- let agedOutCount = 0;
10572
- let agedOutTokens = 0;
10573
- const now = Date.now();
10574
- for (const [blockId, block] of state.prune.messages.blocksById) {
10575
- if (!block.active) continue;
10576
- const age = block.survivedCount ?? 0;
10577
- if (age > maxBlockAge) {
10578
- block.active = false;
10579
- block.deactivatedAt = now;
10580
- block.deactivatedByBlockId = void 0;
10581
- state.prune.messages.activeBlockIds.delete(Number(blockId));
10582
- const anchorMapped = state.prune.messages.activeByAnchorMessageId.get(block.anchorMessageId);
10583
- if (anchorMapped === Number(blockId)) {
10584
- state.prune.messages.activeByAnchorMessageId.delete(block.anchorMessageId);
10585
- }
10586
- agedOutCount++;
10587
- agedOutTokens += block.summaryTokens ?? Math.round(block.summary.length / 4);
10588
- }
10589
- }
10590
- if (agedOutCount > 0) {
10591
- logger.info("Major GC: deactivated aged-out blocks", {
10592
- agedOutCount,
10593
- agedOutTokens,
10594
- maxBlockAge
10595
- });
10596
- saveSessionState(state, logger).catch(() => {
10597
- });
10598
- }
10599
10605
  if (!state.modelContextLimit) return;
10600
10606
  const currentTokens = getCurrentTokenUsage(state, messages);
10601
- const oversizedThreshold = config.gc.maxOldGenSummaryLength * 2;
10602
- let hasOversizedBlocks = false;
10603
- for (const [, block] of state.prune.messages.blocksById) {
10604
- if (block.active && block.summary.length > oversizedThreshold) {
10605
- hasOversizedBlocks = true;
10606
- break;
10607
- }
10608
- }
10609
- if (!shouldRunMajorGC(currentTokens, state.modelContextLimit, config.gc) && !hasOversizedBlocks) return;
10607
+ if (!shouldRunMajorGC(currentTokens, state.modelContextLimit, config.gc)) return;
10610
10608
  const oldBlocks = [];
10611
10609
  for (const [blockId, block] of state.prune.messages.blocksById) {
10612
10610
  if (!block.active) continue;