opencode-acp 1.12.9 → 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,6 +422,14 @@ 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 — Batch Compress + Decompress Range Mode + GC Memory-Loss Fix + Token Classification + Nudge Quality (PRs #73, #155, #156, #157, #158, #159, #161)
426
+
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
+
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
+
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
+
425
433
  ### v1.12.9 — Compress-as-Anchor (PR #153)
426
434
 
427
435
  **Problem**: Since v1.12.1, compression summaries were injected as synthetic tool-result messages via a registered `acp_context_recap` tool, while `stripStaleCompressCalls` removed past `compress` tool calls from the API context to avoid duplication. This doubled summary overhead: each block's summary existed both as a synthetic recap message AND in the original (now-stripped) compress call's `summary` parameter. For sessions with many compressions, this "recap overhead" consumed 10–20% of context with no additional information value. The `acp_context_recap` tool description also claimed it "automatically injects" summaries, which was misleading.
package/README.zh-CN.md CHANGED
@@ -395,6 +395,14 @@ ACP 在首次启动时自动将配置从 `dcp.jsonc` 迁移到 `acp.jsonc`,将
395
395
 
396
396
  ## 更新日志
397
397
 
398
+ ### v1.12.10 — 批量压缩 + Decompress 范围模式 + GC 记忆丢失修复 + Token 分类 + Nudge 质量(PR #73, #155, #156, #157, #158, #159, #161)
399
+
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
+
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
+
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
+
398
406
  ### v1.12.9 — Compress-as-Anchor(PR #153)
399
407
 
400
408
  **问题**:自 v1.12.1 起,压缩摘要通过注册的 `acp_context_recap` 工具以 synthetic tool-result 消息注入,同时 `stripStaleCompressCalls` 从 API 上下文中移除历史 `compress` 工具调用以避免重复。这导致摘要开销翻倍:每个块的摘要同时存在于 synthetic recap 消息和原始(已移除的)compress 调用的 `summary` 参数中。对于压缩频繁的会话,这个 "recap 开销" 占用 10–20% 上下文却没有额外信息价值。`acp_context_recap` 工具描述也声称它"自动注入"摘要,具有误导性。
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
  }
@@ -6765,10 +6790,23 @@ function estimateContextComposition(messages, state, protectedTools = [], protec
6765
6790
  const raw = JSON.stringify(part);
6766
6791
  const tokens = Math.round(raw.length / 4);
6767
6792
  msgTotal += tokens;
6768
- toolTokens += tokens;
6769
- msgTool += tokens;
6770
6793
  const toolName = part?.tool || "unknown";
6771
- toolTypeMap.set(toolName, (toolTypeMap.get(toolName) || 0) + tokens);
6794
+ let summaryPartTokens = 0;
6795
+ if (toolName === "compress") {
6796
+ const input = part?.state?.input;
6797
+ if (input?.content && Array.isArray(input.content)) {
6798
+ for (const entry of input.content) {
6799
+ if (typeof entry?.summary === "string") {
6800
+ summaryPartTokens += Math.round(entry.summary.length / 4);
6801
+ }
6802
+ }
6803
+ }
6804
+ }
6805
+ const toolPartTokens = Math.max(0, tokens - summaryPartTokens);
6806
+ toolTokens += toolPartTokens;
6807
+ msgTool += toolPartTokens;
6808
+ summaryTokens += summaryPartTokens;
6809
+ toolTypeMap.set(toolName, (toolTypeMap.get(toolName) || 0) + toolPartTokens);
6772
6810
  if (!msgToolName) msgToolName = toolName;
6773
6811
  }
6774
6812
  }
@@ -6824,7 +6862,20 @@ function buildCompressibleRanges(messages, state, protectedTools = [], protected
6824
6862
  } else if (part.type !== "text" && part.type !== "reasoning") {
6825
6863
  tokens2 += Math.round(JSON.stringify(part).length / 4);
6826
6864
  const toolName = part?.tool;
6827
- if (toolName) tools.add(toolName);
6865
+ const callID = part?.callID;
6866
+ if (toolName && callID) {
6867
+ if (isToolNameProtected(toolName, protectedTools)) {
6868
+ tools.add(toolName);
6869
+ } else if (protectedFilePatterns.length > 0) {
6870
+ const filePaths = getFilePathsFromParameters(
6871
+ toolName,
6872
+ part?.state?.input
6873
+ );
6874
+ if (isFilePathProtected(filePaths, protectedFilePatterns)) {
6875
+ tools.add(toolName);
6876
+ }
6877
+ }
6878
+ }
6828
6879
  }
6829
6880
  }
6830
6881
  protectedMsgInfo.push({ ref, refNum: rn, tokens: tokens2, tools: [...tools] });
@@ -7297,7 +7348,13 @@ var injectCompressNudges = (state, config, logger, messages, prompts, compressio
7297
7348
  logger.debug(lines.join("\n"));
7298
7349
  }
7299
7350
  const filterSuppressed = contextRanges.compressible.length > 0 && !hasRecommendations;
7300
- const shouldInject = nudgeAllowed && (!filterSuppressed || emergencyOverride);
7351
+ const allProtected = contextRanges.compressible.length === 0 && contextRanges.protected.length > 0;
7352
+ const nothingToCompress = filterSuppressed || allProtected;
7353
+ const shouldInject = nudgeAllowed && (!nothingToCompress || emergencyOverride);
7354
+ if (nudgeAllowed && nothingToCompress && !emergencyOverride && currentTokens !== void 0) {
7355
+ state.nudges.lastPerMessageNudgeTokens = currentTokens;
7356
+ state.nudges.lastNudgeShownTokens = void 0;
7357
+ }
7301
7358
  state.nudges.shouldInjectThisTurn = shouldInject;
7302
7359
  let tipsText = null;
7303
7360
  if (shouldInject) {
@@ -7656,6 +7713,37 @@ function parseBlockIdArg(arg) {
7656
7713
  const parsed = Number.parseInt(normalized, 10);
7657
7714
  return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
7658
7715
  }
7716
+ function resolveDecompressMode(args) {
7717
+ const hasBlockId = typeof args.blockId === "string" && args.blockId.trim() !== "";
7718
+ const hasStartId = typeof args.startId === "string" && args.startId.trim() !== "";
7719
+ const hasEndId = typeof args.endId === "string" && args.endId.trim() !== "";
7720
+ if (hasBlockId && (hasStartId || hasEndId)) {
7721
+ return { ok: false, error: "Cannot specify both blockId and startId/endId. Choose one mode." };
7722
+ }
7723
+ if (!hasBlockId && !(hasStartId && hasEndId)) {
7724
+ return { ok: false, error: "Must specify either blockId, or both startId and endId." };
7725
+ }
7726
+ return { ok: true, mode: hasBlockId ? "block" : "range" };
7727
+ }
7728
+ function findActiveBlocksOverlappingMessages(messagesState, messageIds) {
7729
+ if (messageIds.size === 0) {
7730
+ return [];
7731
+ }
7732
+ const matched = /* @__PURE__ */ new Map();
7733
+ for (const [blockId, block] of messagesState.blocksById) {
7734
+ if (!block.active) {
7735
+ continue;
7736
+ }
7737
+ const effectiveIds = block.effectiveMessageIds ?? [];
7738
+ for (const msgId of effectiveIds) {
7739
+ if (messageIds.has(msgId)) {
7740
+ matched.set(blockId, block);
7741
+ break;
7742
+ }
7743
+ }
7744
+ }
7745
+ return Array.from(matched.values()).sort((a, b) => a.blockId - b.blockId);
7746
+ }
7659
7747
  function findActiveParentBlockId(messagesState, block) {
7660
7748
  const queue = [...block.parentBlockIds];
7661
7749
  const visited = /* @__PURE__ */ new Set();
@@ -7806,12 +7894,120 @@ async function prepareDecompressSession(ctx, toolCtx) {
7806
7894
  async function finalizeDecompressSession(ctx) {
7807
7895
  await saveSessionState(ctx.state, ctx.logger);
7808
7896
  }
7809
- var TOOL_DESCRIPTION = `Restores previously compressed content identified by a block ID.
7897
+ function resolveTargets(args, state, rawMessages, logger) {
7898
+ const mode = resolveDecompressMode(args);
7899
+ if (!mode.ok) {
7900
+ return { ok: false, error: `Error: ${mode.error}` };
7901
+ }
7902
+ const messagesState = state.prune.messages;
7903
+ if (mode.mode === "block") {
7904
+ return resolveSingleBlockTarget(messagesState, args.blockId);
7905
+ }
7906
+ return resolveRangeTarget(state, rawMessages, args.startId, args.endId, logger);
7907
+ }
7908
+ function resolveSingleBlockTarget(messagesState, blockIdArg) {
7909
+ const targetBlockId = parseBlockIdArg(blockIdArg);
7910
+ if (targetBlockId === null) {
7911
+ return {
7912
+ ok: false,
7913
+ error: `Error: Invalid block ID "${blockIdArg}". Use format "b0", "b1", etc.`
7914
+ };
7915
+ }
7916
+ const target = resolveCompressionTarget(messagesState, targetBlockId);
7917
+ if (!target) {
7918
+ return {
7919
+ ok: false,
7920
+ error: `Error: Block ${targetBlockId} does not exist. No compression found with that ID.`
7921
+ };
7922
+ }
7923
+ const activeBlocks = target.blocks.filter((block) => block.active);
7924
+ if (activeBlocks.length === 0) {
7925
+ const activeAncestorBlockId = findActiveAncestorBlockId(messagesState, target);
7926
+ if (activeAncestorBlockId !== null) {
7927
+ return {
7928
+ ok: false,
7929
+ error: `Error: Block ${target.displayId} is nested inside active block ${activeAncestorBlockId}. Decompress block ${activeAncestorBlockId} first.`
7930
+ };
7931
+ }
7932
+ return {
7933
+ ok: false,
7934
+ error: `Error: Block ${target.displayId} is not active. It may have already been decompressed.`
7935
+ };
7936
+ }
7937
+ return { ok: true, targets: [target] };
7938
+ }
7939
+ function resolveRangeTarget(state, rawMessages, startId, endId, logger) {
7940
+ const searchContext = buildSearchContext(state, rawMessages);
7941
+ let startReference;
7942
+ let endReference;
7943
+ try {
7944
+ const resolved = resolveBoundaryIds(searchContext, state, startId, endId);
7945
+ startReference = resolved.startReference;
7946
+ endReference = resolved.endReference;
7947
+ } catch (err) {
7948
+ return { ok: false, error: `Error: ${err.message}` };
7949
+ }
7950
+ let selection;
7951
+ try {
7952
+ selection = resolveSelection(searchContext, startReference, endReference);
7953
+ } catch (err) {
7954
+ return { ok: false, error: `Error: ${err.message}` };
7955
+ }
7956
+ if (selection.messageIds.length === 0) {
7957
+ return {
7958
+ ok: false,
7959
+ error: `Error: No messages found in range ${startId}..${endId}.`
7960
+ };
7961
+ }
7962
+ const messageIdSet = new Set(selection.messageIds);
7963
+ const messagesState = state.prune.messages;
7964
+ const overlappingBlocks = findActiveBlocksOverlappingMessages(messagesState, messageIdSet);
7965
+ if (overlappingBlocks.length === 0) {
7966
+ return {
7967
+ ok: false,
7968
+ error: `Error: No active compression blocks overlap the range ${startId}..${endId}. The content may already be fully visible. Use acp_status to review block coverage.`
7969
+ };
7970
+ }
7971
+ const targetMap = /* @__PURE__ */ new Map();
7972
+ for (const block of overlappingBlocks) {
7973
+ const target = resolveCompressionTarget(messagesState, block.blockId);
7974
+ if (target) {
7975
+ targetMap.set(target.displayId, target);
7976
+ }
7977
+ }
7978
+ const targets = Array.from(targetMap.values());
7979
+ logger.info("range decompress resolved", {
7980
+ range: `${startId}..${endId}`,
7981
+ matchedBlocks: overlappingBlocks.map((b) => b.blockId),
7982
+ targets: targets.map((t) => t.displayId)
7983
+ });
7984
+ return { ok: true, targets };
7985
+ }
7986
+ var TOOL_DESCRIPTION = `Restores previously compressed content.
7810
7987
 
7811
- Use this tool when you need exact details from a compressed block that the summary cannot provide.
7988
+ Use this tool when you need exact details from compressed content that the summary cannot provide.
7812
7989
  The tool returns a condensed preview of the restored content so you can reason about it immediately.
7813
7990
 
7814
- Argument: blockId \u2014 the block reference to decompress (e.g., "b0", "b2")
7991
+ TWO MODES:
7992
+
7993
+ 1. Block mode (default): decompress a single block by ID.
7994
+ - blockId: block reference to decompress (e.g., "b0", "b2")
7995
+
7996
+ 2. Range mode: decompress ALL blocks overlapping a message range. Use this to restore
7997
+ content across multiple blocks without calling acp_status + decompress repeatedly.
7998
+ - startId: starting message or block ref (e.g., "m00150")
7999
+ - endId: ending message or block ref (e.g., "m00200")
8000
+
8001
+ Range mode finds every active block whose effectiveMessageIds touch the range and
8002
+ batch-restores them. Partial overlap decompresses the whole block (content cannot be
8003
+ partially restored). Nested blocks are handled automatically.
8004
+
8005
+ ARGUMENTS:
8006
+ - blockId?: string \u2014 use this OR startId+endId (mutually exclusive)
8007
+ - startId?: string \u2014 range start (message or block ref)
8008
+ - endId?: string \u2014 range end (message or block ref)
8009
+ - toFile?: string \u2014 if provided, writes restored content to this file path (must be under
8010
+ /tmp or ~/.cache/opencode/) instead of inflating context. Block(s) stay compressed.
7815
8011
 
7816
8012
  IMPORTANT:
7817
8013
  - Decompressing inflates context. Check context usage before decompressing.
@@ -7820,10 +8016,22 @@ IMPORTANT:
7820
8016
  - Do NOT call this tool in parallel with compress \u2014 their state mutations may conflict.`;
7821
8017
  function buildSchema3() {
7822
8018
  return {
7823
- blockId: tool4.schema.string().describe('Block reference to decompress (e.g., "b0", "b2")'),
7824
- toFile: tool4.schema.string().optional().describe("If provided, writes restored content to this file path instead of inflating context. Block stays compressed. Use read tool to access specific parts. Example: '/tmp/block52.txt'")
8019
+ blockId: tool4.schema.string().optional().describe('Block reference to decompress (e.g., "b0", "b2"). Mutually exclusive with startId/endId.'),
8020
+ startId: tool4.schema.string().optional().describe('Range start: message ref (e.g., "m00150") or block ref (e.g., "b2"). Used with endId.'),
8021
+ endId: tool4.schema.string().optional().describe('Range end: message ref (e.g., "m00200") or block ref (e.g., "b5"). Used with startId.'),
8022
+ toFile: tool4.schema.string().optional().describe("If provided, writes restored content to this file path instead of inflating context. Block stays compressed. Path must be under /tmp or ~/.cache/opencode/. Example: '/tmp/block52.txt'")
7825
8023
  };
7826
8024
  }
8025
+ function extractMessageId(m) {
8026
+ return m.id ?? m.messageId ?? "";
8027
+ }
8028
+ function extractMessageText(m) {
8029
+ const msg = m;
8030
+ const role = msg.role || msg.type || "unknown";
8031
+ const content = typeof msg.content === "string" ? msg.content : typeof msg.text === "string" ? msg.text : JSON.stringify(msg.content || msg.text || "");
8032
+ return `[${role}]
8033
+ ${content}`;
8034
+ }
7827
8035
  function createDecompressTool(ctx) {
7828
8036
  return tool4({
7829
8037
  description: TOOL_DESCRIPTION,
@@ -7833,22 +8041,19 @@ function createDecompressTool(ctx) {
7833
8041
  const contextUsageBefore = ctx.state.modelContextLimit ? Math.round(
7834
8042
  getCurrentTokenUsage(ctx.state, rawMessages) / ctx.state.modelContextLimit * 100
7835
8043
  ) : void 0;
7836
- const targetBlockId = parseBlockIdArg(args.blockId);
7837
- if (targetBlockId === null) {
7838
- return `Error: Invalid block ID "${args.blockId}". Use format "b0", "b1", etc.`;
8044
+ const resolved = resolveTargets(args, ctx.state, rawMessages, ctx.logger);
8045
+ if (!resolved.ok) {
8046
+ return resolved.error;
7839
8047
  }
8048
+ const targets = resolved.targets;
7840
8049
  const messagesState = ctx.state.prune.messages;
7841
- const target = resolveCompressionTarget(messagesState, targetBlockId);
7842
- if (!target) {
7843
- return `Error: Block ${targetBlockId} does not exist. No compression found with that ID.`;
7844
- }
7845
- const activeBlocks = target.blocks.filter((block) => block.active);
7846
- if (activeBlocks.length === 0) {
7847
- const activeAncestorBlockId = findActiveAncestorBlockId(messagesState, target);
7848
- if (activeAncestorBlockId !== null) {
7849
- return `Error: Block ${target.displayId} is nested inside active block ${activeAncestorBlockId}. Decompress block ${activeAncestorBlockId} first.`;
8050
+ const activeBlocks = [];
8051
+ for (const target of targets) {
8052
+ for (const block of target.blocks) {
8053
+ if (block.active) {
8054
+ activeBlocks.push(block);
8055
+ }
7850
8056
  }
7851
- return `Error: Block ${target.displayId} is not active. It may have already been decompressed.`;
7852
8057
  }
7853
8058
  if (args.toFile) {
7854
8059
  const targetPath = args.toFile;
@@ -7858,35 +8063,33 @@ function createDecompressTool(ctx) {
7858
8063
  os.tmpdir() + "/",
7859
8064
  path.join(os.homedir(), ".cache", "opencode") + "/"
7860
8065
  ];
7861
- const resolved = path.resolve(targetPath);
8066
+ const resolvedPath = path.resolve(targetPath);
7862
8067
  const isAllowed = allowedDirs.some((dir) => {
7863
- const rel = path.relative(dir, resolved);
8068
+ const rel = path.relative(dir, resolvedPath);
7864
8069
  return rel === "" || !rel.startsWith("..") && !path.isAbsolute(rel);
7865
8070
  });
7866
8071
  if (!isAllowed) {
7867
8072
  return `Error: toFile path must be under ${os.tmpdir()} or ~/.cache/opencode/. Got: ${targetPath}`;
7868
8073
  }
7869
- const block = activeBlocks[0];
7870
- const msgIds = new Set(block.effectiveMessageIds ?? []);
7871
- const blockMessages = rawMessages.filter((m) => {
7872
- const id = m.id ?? m.messageId ?? "";
7873
- return msgIds.has(id);
7874
- });
7875
- const lines2 = blockMessages.map((m) => {
7876
- const msg = m;
7877
- const role = msg.role || msg.type || "unknown";
7878
- const content = typeof msg.content === "string" ? msg.content : typeof msg.text === "string" ? msg.text : JSON.stringify(msg.content || msg.text || "");
7879
- return `[${role}]
7880
- ${content}`;
7881
- });
8074
+ const msgIdSet = /* @__PURE__ */ new Set();
8075
+ for (const block of activeBlocks) {
8076
+ for (const id of block.effectiveMessageIds ?? []) {
8077
+ msgIdSet.add(id);
8078
+ }
8079
+ }
8080
+ const blockMessages = rawMessages.filter((m) => msgIdSet.has(extractMessageId(m)));
8081
+ const lines2 = blockMessages.map(extractMessageText);
7882
8082
  const { writeFile: writeFile3 } = await import("fs/promises");
7883
- const fileContent = lines2.length > 0 ? lines2.join("\n\n---\n\n") : block.summary ?? "(no content available)";
7884
- await writeFile3(args.toFile, fileContent, "utf-8");
7885
- return `Block b${target.displayId} content (${blockMessages.length} messages, ${fileContent.length} chars) written to ${args.toFile}. Block stays compressed \u2014 context unchanged. Use read tool to access specific parts.`;
8083
+ const fileContent = lines2.length > 0 ? lines2.join("\n\n---\n\n") : activeBlocks[0]?.summary ?? "(no content available)";
8084
+ await writeFile3(targetPath, fileContent, "utf-8");
8085
+ const displayIds2 = targets.map((t) => `b${t.displayId}`).join(", ");
8086
+ return `Block(s) ${displayIds2} content (${blockMessages.length} messages, ${fileContent.length} chars) written to ${targetPath}. Block(s) stay compressed \u2014 context unchanged. Use read tool to access specific parts.`;
7886
8087
  }
7887
8088
  const activeMessagesBefore = snapshotActiveMessages(messagesState);
7888
8089
  const activeBlockIdsBefore = new Set(messagesState.activeBlockIds);
7889
- deactivateCompressionTarget(messagesState, target);
8090
+ for (const target of targets) {
8091
+ deactivateCompressionTarget(messagesState, target);
8092
+ }
7890
8093
  syncCompressionBlocks(ctx.state, ctx.logger, rawMessages);
7891
8094
  const { restoredMessageCount, restoredTokens } = computeRestoredMessages(
7892
8095
  messagesState,
@@ -7909,9 +8112,11 @@ ${content}`;
7909
8112
  activeMessagesBefore,
7910
8113
  messagesState
7911
8114
  );
8115
+ const displayIds = targets.map((t) => `b${t.displayId}`).join(", ");
7912
8116
  const lines = [];
8117
+ const headerNoun = targets.length === 1 ? "block" : "blocks";
7913
8118
  lines.push(
7914
- `Decompressed block b${target.displayId}. Restored ${restoredMessageCount} message(s) (~${formatTokenCount(restoredTokens)}).`
8119
+ `Decompressed ${headerNoun} ${displayIds}. Restored ${restoredMessageCount} message(s) (~${formatTokenCount(restoredTokens)}).`
7915
8120
  );
7916
8121
  if (contextUsageBefore !== void 0 && contextUsageAfter !== void 0) {
7917
8122
  lines.push(`Context usage: ${contextUsageBefore}% \u2192 ${contextUsageAfter}%.`);
@@ -7926,8 +8131,8 @@ ${content}`;
7926
8131
  lines.push(restoredContentPreview);
7927
8132
  }
7928
8133
  ctx.logger.info("Decompress tool completed", {
7929
- targetBlockId: target.displayId,
7930
- targetRunId: target.runId,
8134
+ mode: typeof args.startId === "string" ? "range" : "block",
8135
+ targetBlockIds: targets.map((t) => t.displayId),
7931
8136
  restoredMessageCount,
7932
8137
  restoredTokens,
7933
8138
  reactivatedBlockIds
@@ -8594,7 +8799,7 @@ TOOLS
8594
8799
 
8595
8800
  You have five context-management tools:
8596
8801
 
8597
- - \`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: "..." }] })\`.
8598
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" })\`.
8599
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" })\`.
8600
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 })\`.
@@ -8693,6 +8898,16 @@ Rules:
8693
8898
  BATCHING
8694
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\`.
8695
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
+
8696
8911
  KEEP AND REF MARKERS
8697
8912
  When writing a summary, you may embed markers that reference specific messages in the compressed range. The system resolves them automatically:
8698
8913
 
@@ -10387,46 +10602,9 @@ function createSystemPromptHandler(state, logger, config, prompts) {
10387
10602
  };
10388
10603
  }
10389
10604
  function runMajorGC(state, config, logger, messages) {
10390
- const maxBlockAge = config.gc.maxBlockAge ?? 15;
10391
- let agedOutCount = 0;
10392
- let agedOutTokens = 0;
10393
- const now = Date.now();
10394
- for (const [blockId, block] of state.prune.messages.blocksById) {
10395
- if (!block.active) continue;
10396
- const age = block.survivedCount ?? 0;
10397
- if (age > maxBlockAge) {
10398
- block.active = false;
10399
- block.deactivatedAt = now;
10400
- block.deactivatedByBlockId = void 0;
10401
- state.prune.messages.activeBlockIds.delete(Number(blockId));
10402
- const anchorMapped = state.prune.messages.activeByAnchorMessageId.get(block.anchorMessageId);
10403
- if (anchorMapped === Number(blockId)) {
10404
- state.prune.messages.activeByAnchorMessageId.delete(block.anchorMessageId);
10405
- }
10406
- agedOutCount++;
10407
- agedOutTokens += block.summaryTokens ?? Math.round(block.summary.length / 4);
10408
- }
10409
- }
10410
- if (agedOutCount > 0) {
10411
- logger.info("Major GC: deactivated aged-out blocks", {
10412
- agedOutCount,
10413
- agedOutTokens,
10414
- maxBlockAge
10415
- });
10416
- saveSessionState(state, logger).catch(() => {
10417
- });
10418
- }
10419
10605
  if (!state.modelContextLimit) return;
10420
10606
  const currentTokens = getCurrentTokenUsage(state, messages);
10421
- const oversizedThreshold = config.gc.maxOldGenSummaryLength * 2;
10422
- let hasOversizedBlocks = false;
10423
- for (const [, block] of state.prune.messages.blocksById) {
10424
- if (block.active && block.summary.length > oversizedThreshold) {
10425
- hasOversizedBlocks = true;
10426
- break;
10427
- }
10428
- }
10429
- if (!shouldRunMajorGC(currentTokens, state.modelContextLimit, config.gc) && !hasOversizedBlocks) return;
10607
+ if (!shouldRunMajorGC(currentTokens, state.modelContextLimit, config.gc)) return;
10430
10608
  const oldBlocks = [];
10431
10609
  for (const [blockId, block] of state.prune.messages.blocksById) {
10432
10610
  if (!block.active) continue;