opencode-acp 1.12.9 → 1.12.10-dev.1

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-dev.1 — Decompress Range Mode + Token Classification + Protected Label Accuracy + Nudge Suppression + Discrete Intervals (PRs #73, #155, #157, #158, #159)
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.
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.
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).
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-dev.1 — Decompress 范围模式 + Token 分类 + Protected 标签精确性 + Nudge 抑制 + 离散间隔(PR #73, #155, #157, #158, #159)
399
+
400
+ **问题**:五个问题,涉及 decompress 易用性、token 统计和 nudge 质量。(1)`decompress` 需要先 `acp_status` 再逐块 decompress 的循环才能恢复多个压缩块。(2)自 v1.12.9(compress-as-anchor)起,compress 工具的 `summary` 内容被错误分类为 `toolTokens` 而非 `summaryTokens`,导致上下文分布中 tool% 虚高、summary% 虚低 —— 误导模型的压缩决策。(3)`[PROTECTED: ...]` 标签列出受保护消息中的所有工具(例如 `[PROTECTED: grep, skill]`),让模型误以为 `grep` 等非保护工具也是保护原因。(4)当所有可见内容都是受保护内容时,nudge 仍然以空推荐列表注入 —— 浪费上下文且让模型困惑。(5)当 nudge 被抑制(过滤器移除所有推荐)时,下一轮检查每轮都重新评估,因为 baseline 从不前进,导致重复浪费计算。
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% 检查间隔而非每轮重新评估。
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 测试覆盖抑制后压缩)。
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
@@ -6765,10 +6765,23 @@ function estimateContextComposition(messages, state, protectedTools = [], protec
6765
6765
  const raw = JSON.stringify(part);
6766
6766
  const tokens = Math.round(raw.length / 4);
6767
6767
  msgTotal += tokens;
6768
- toolTokens += tokens;
6769
- msgTool += tokens;
6770
6768
  const toolName = part?.tool || "unknown";
6771
- toolTypeMap.set(toolName, (toolTypeMap.get(toolName) || 0) + tokens);
6769
+ let summaryPartTokens = 0;
6770
+ if (toolName === "compress") {
6771
+ const input = part?.state?.input;
6772
+ if (input?.content && Array.isArray(input.content)) {
6773
+ for (const entry of input.content) {
6774
+ if (typeof entry?.summary === "string") {
6775
+ summaryPartTokens += Math.round(entry.summary.length / 4);
6776
+ }
6777
+ }
6778
+ }
6779
+ }
6780
+ const toolPartTokens = Math.max(0, tokens - summaryPartTokens);
6781
+ toolTokens += toolPartTokens;
6782
+ msgTool += toolPartTokens;
6783
+ summaryTokens += summaryPartTokens;
6784
+ toolTypeMap.set(toolName, (toolTypeMap.get(toolName) || 0) + toolPartTokens);
6772
6785
  if (!msgToolName) msgToolName = toolName;
6773
6786
  }
6774
6787
  }
@@ -6824,7 +6837,20 @@ function buildCompressibleRanges(messages, state, protectedTools = [], protected
6824
6837
  } else if (part.type !== "text" && part.type !== "reasoning") {
6825
6838
  tokens2 += Math.round(JSON.stringify(part).length / 4);
6826
6839
  const toolName = part?.tool;
6827
- if (toolName) tools.add(toolName);
6840
+ const callID = part?.callID;
6841
+ if (toolName && callID) {
6842
+ if (isToolNameProtected(toolName, protectedTools)) {
6843
+ tools.add(toolName);
6844
+ } else if (protectedFilePatterns.length > 0) {
6845
+ const filePaths = getFilePathsFromParameters(
6846
+ toolName,
6847
+ part?.state?.input
6848
+ );
6849
+ if (isFilePathProtected(filePaths, protectedFilePatterns)) {
6850
+ tools.add(toolName);
6851
+ }
6852
+ }
6853
+ }
6828
6854
  }
6829
6855
  }
6830
6856
  protectedMsgInfo.push({ ref, refNum: rn, tokens: tokens2, tools: [...tools] });
@@ -7297,7 +7323,13 @@ var injectCompressNudges = (state, config, logger, messages, prompts, compressio
7297
7323
  logger.debug(lines.join("\n"));
7298
7324
  }
7299
7325
  const filterSuppressed = contextRanges.compressible.length > 0 && !hasRecommendations;
7300
- const shouldInject = nudgeAllowed && (!filterSuppressed || emergencyOverride);
7326
+ const allProtected = contextRanges.compressible.length === 0 && contextRanges.protected.length > 0;
7327
+ const nothingToCompress = filterSuppressed || allProtected;
7328
+ const shouldInject = nudgeAllowed && (!nothingToCompress || emergencyOverride);
7329
+ if (nudgeAllowed && nothingToCompress && !emergencyOverride && currentTokens !== void 0) {
7330
+ state.nudges.lastPerMessageNudgeTokens = currentTokens;
7331
+ state.nudges.lastNudgeShownTokens = void 0;
7332
+ }
7301
7333
  state.nudges.shouldInjectThisTurn = shouldInject;
7302
7334
  let tipsText = null;
7303
7335
  if (shouldInject) {
@@ -7656,6 +7688,37 @@ function parseBlockIdArg(arg) {
7656
7688
  const parsed = Number.parseInt(normalized, 10);
7657
7689
  return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
7658
7690
  }
7691
+ function resolveDecompressMode(args) {
7692
+ const hasBlockId = typeof args.blockId === "string" && args.blockId.trim() !== "";
7693
+ const hasStartId = typeof args.startId === "string" && args.startId.trim() !== "";
7694
+ const hasEndId = typeof args.endId === "string" && args.endId.trim() !== "";
7695
+ if (hasBlockId && (hasStartId || hasEndId)) {
7696
+ return { ok: false, error: "Cannot specify both blockId and startId/endId. Choose one mode." };
7697
+ }
7698
+ if (!hasBlockId && !(hasStartId && hasEndId)) {
7699
+ return { ok: false, error: "Must specify either blockId, or both startId and endId." };
7700
+ }
7701
+ return { ok: true, mode: hasBlockId ? "block" : "range" };
7702
+ }
7703
+ function findActiveBlocksOverlappingMessages(messagesState, messageIds) {
7704
+ if (messageIds.size === 0) {
7705
+ return [];
7706
+ }
7707
+ const matched = /* @__PURE__ */ new Map();
7708
+ for (const [blockId, block] of messagesState.blocksById) {
7709
+ if (!block.active) {
7710
+ continue;
7711
+ }
7712
+ const effectiveIds = block.effectiveMessageIds ?? [];
7713
+ for (const msgId of effectiveIds) {
7714
+ if (messageIds.has(msgId)) {
7715
+ matched.set(blockId, block);
7716
+ break;
7717
+ }
7718
+ }
7719
+ }
7720
+ return Array.from(matched.values()).sort((a, b) => a.blockId - b.blockId);
7721
+ }
7659
7722
  function findActiveParentBlockId(messagesState, block) {
7660
7723
  const queue = [...block.parentBlockIds];
7661
7724
  const visited = /* @__PURE__ */ new Set();
@@ -7806,12 +7869,120 @@ async function prepareDecompressSession(ctx, toolCtx) {
7806
7869
  async function finalizeDecompressSession(ctx) {
7807
7870
  await saveSessionState(ctx.state, ctx.logger);
7808
7871
  }
7809
- var TOOL_DESCRIPTION = `Restores previously compressed content identified by a block ID.
7872
+ function resolveTargets(args, state, rawMessages, logger) {
7873
+ const mode = resolveDecompressMode(args);
7874
+ if (!mode.ok) {
7875
+ return { ok: false, error: `Error: ${mode.error}` };
7876
+ }
7877
+ const messagesState = state.prune.messages;
7878
+ if (mode.mode === "block") {
7879
+ return resolveSingleBlockTarget(messagesState, args.blockId);
7880
+ }
7881
+ return resolveRangeTarget(state, rawMessages, args.startId, args.endId, logger);
7882
+ }
7883
+ function resolveSingleBlockTarget(messagesState, blockIdArg) {
7884
+ const targetBlockId = parseBlockIdArg(blockIdArg);
7885
+ if (targetBlockId === null) {
7886
+ return {
7887
+ ok: false,
7888
+ error: `Error: Invalid block ID "${blockIdArg}". Use format "b0", "b1", etc.`
7889
+ };
7890
+ }
7891
+ const target = resolveCompressionTarget(messagesState, targetBlockId);
7892
+ if (!target) {
7893
+ return {
7894
+ ok: false,
7895
+ error: `Error: Block ${targetBlockId} does not exist. No compression found with that ID.`
7896
+ };
7897
+ }
7898
+ const activeBlocks = target.blocks.filter((block) => block.active);
7899
+ if (activeBlocks.length === 0) {
7900
+ const activeAncestorBlockId = findActiveAncestorBlockId(messagesState, target);
7901
+ if (activeAncestorBlockId !== null) {
7902
+ return {
7903
+ ok: false,
7904
+ error: `Error: Block ${target.displayId} is nested inside active block ${activeAncestorBlockId}. Decompress block ${activeAncestorBlockId} first.`
7905
+ };
7906
+ }
7907
+ return {
7908
+ ok: false,
7909
+ error: `Error: Block ${target.displayId} is not active. It may have already been decompressed.`
7910
+ };
7911
+ }
7912
+ return { ok: true, targets: [target] };
7913
+ }
7914
+ function resolveRangeTarget(state, rawMessages, startId, endId, logger) {
7915
+ const searchContext = buildSearchContext(state, rawMessages);
7916
+ let startReference;
7917
+ let endReference;
7918
+ try {
7919
+ const resolved = resolveBoundaryIds(searchContext, state, startId, endId);
7920
+ startReference = resolved.startReference;
7921
+ endReference = resolved.endReference;
7922
+ } catch (err) {
7923
+ return { ok: false, error: `Error: ${err.message}` };
7924
+ }
7925
+ let selection;
7926
+ try {
7927
+ selection = resolveSelection(searchContext, startReference, endReference);
7928
+ } catch (err) {
7929
+ return { ok: false, error: `Error: ${err.message}` };
7930
+ }
7931
+ if (selection.messageIds.length === 0) {
7932
+ return {
7933
+ ok: false,
7934
+ error: `Error: No messages found in range ${startId}..${endId}.`
7935
+ };
7936
+ }
7937
+ const messageIdSet = new Set(selection.messageIds);
7938
+ const messagesState = state.prune.messages;
7939
+ const overlappingBlocks = findActiveBlocksOverlappingMessages(messagesState, messageIdSet);
7940
+ if (overlappingBlocks.length === 0) {
7941
+ return {
7942
+ ok: false,
7943
+ 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.`
7944
+ };
7945
+ }
7946
+ const targetMap = /* @__PURE__ */ new Map();
7947
+ for (const block of overlappingBlocks) {
7948
+ const target = resolveCompressionTarget(messagesState, block.blockId);
7949
+ if (target) {
7950
+ targetMap.set(target.displayId, target);
7951
+ }
7952
+ }
7953
+ const targets = Array.from(targetMap.values());
7954
+ logger.info("range decompress resolved", {
7955
+ range: `${startId}..${endId}`,
7956
+ matchedBlocks: overlappingBlocks.map((b) => b.blockId),
7957
+ targets: targets.map((t) => t.displayId)
7958
+ });
7959
+ return { ok: true, targets };
7960
+ }
7961
+ var TOOL_DESCRIPTION = `Restores previously compressed content.
7810
7962
 
7811
- Use this tool when you need exact details from a compressed block that the summary cannot provide.
7963
+ Use this tool when you need exact details from compressed content that the summary cannot provide.
7812
7964
  The tool returns a condensed preview of the restored content so you can reason about it immediately.
7813
7965
 
7814
- Argument: blockId \u2014 the block reference to decompress (e.g., "b0", "b2")
7966
+ TWO MODES:
7967
+
7968
+ 1. Block mode (default): decompress a single block by ID.
7969
+ - blockId: block reference to decompress (e.g., "b0", "b2")
7970
+
7971
+ 2. Range mode: decompress ALL blocks overlapping a message range. Use this to restore
7972
+ content across multiple blocks without calling acp_status + decompress repeatedly.
7973
+ - startId: starting message or block ref (e.g., "m00150")
7974
+ - endId: ending message or block ref (e.g., "m00200")
7975
+
7976
+ Range mode finds every active block whose effectiveMessageIds touch the range and
7977
+ batch-restores them. Partial overlap decompresses the whole block (content cannot be
7978
+ partially restored). Nested blocks are handled automatically.
7979
+
7980
+ ARGUMENTS:
7981
+ - blockId?: string \u2014 use this OR startId+endId (mutually exclusive)
7982
+ - startId?: string \u2014 range start (message or block ref)
7983
+ - endId?: string \u2014 range end (message or block ref)
7984
+ - toFile?: string \u2014 if provided, writes restored content to this file path (must be under
7985
+ /tmp or ~/.cache/opencode/) instead of inflating context. Block(s) stay compressed.
7815
7986
 
7816
7987
  IMPORTANT:
7817
7988
  - Decompressing inflates context. Check context usage before decompressing.
@@ -7820,10 +7991,22 @@ IMPORTANT:
7820
7991
  - Do NOT call this tool in parallel with compress \u2014 their state mutations may conflict.`;
7821
7992
  function buildSchema3() {
7822
7993
  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'")
7994
+ blockId: tool4.schema.string().optional().describe('Block reference to decompress (e.g., "b0", "b2"). Mutually exclusive with startId/endId.'),
7995
+ startId: tool4.schema.string().optional().describe('Range start: message ref (e.g., "m00150") or block ref (e.g., "b2"). Used with endId.'),
7996
+ endId: tool4.schema.string().optional().describe('Range end: message ref (e.g., "m00200") or block ref (e.g., "b5"). Used with startId.'),
7997
+ 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
7998
  };
7826
7999
  }
8000
+ function extractMessageId(m) {
8001
+ return m.id ?? m.messageId ?? "";
8002
+ }
8003
+ function extractMessageText(m) {
8004
+ const msg = m;
8005
+ const role = msg.role || msg.type || "unknown";
8006
+ const content = typeof msg.content === "string" ? msg.content : typeof msg.text === "string" ? msg.text : JSON.stringify(msg.content || msg.text || "");
8007
+ return `[${role}]
8008
+ ${content}`;
8009
+ }
7827
8010
  function createDecompressTool(ctx) {
7828
8011
  return tool4({
7829
8012
  description: TOOL_DESCRIPTION,
@@ -7833,22 +8016,19 @@ function createDecompressTool(ctx) {
7833
8016
  const contextUsageBefore = ctx.state.modelContextLimit ? Math.round(
7834
8017
  getCurrentTokenUsage(ctx.state, rawMessages) / ctx.state.modelContextLimit * 100
7835
8018
  ) : 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.`;
8019
+ const resolved = resolveTargets(args, ctx.state, rawMessages, ctx.logger);
8020
+ if (!resolved.ok) {
8021
+ return resolved.error;
7839
8022
  }
8023
+ const targets = resolved.targets;
7840
8024
  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.`;
8025
+ const activeBlocks = [];
8026
+ for (const target of targets) {
8027
+ for (const block of target.blocks) {
8028
+ if (block.active) {
8029
+ activeBlocks.push(block);
8030
+ }
7850
8031
  }
7851
- return `Error: Block ${target.displayId} is not active. It may have already been decompressed.`;
7852
8032
  }
7853
8033
  if (args.toFile) {
7854
8034
  const targetPath = args.toFile;
@@ -7858,35 +8038,33 @@ function createDecompressTool(ctx) {
7858
8038
  os.tmpdir() + "/",
7859
8039
  path.join(os.homedir(), ".cache", "opencode") + "/"
7860
8040
  ];
7861
- const resolved = path.resolve(targetPath);
8041
+ const resolvedPath = path.resolve(targetPath);
7862
8042
  const isAllowed = allowedDirs.some((dir) => {
7863
- const rel = path.relative(dir, resolved);
8043
+ const rel = path.relative(dir, resolvedPath);
7864
8044
  return rel === "" || !rel.startsWith("..") && !path.isAbsolute(rel);
7865
8045
  });
7866
8046
  if (!isAllowed) {
7867
8047
  return `Error: toFile path must be under ${os.tmpdir()} or ~/.cache/opencode/. Got: ${targetPath}`;
7868
8048
  }
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
- });
8049
+ const msgIdSet = /* @__PURE__ */ new Set();
8050
+ for (const block of activeBlocks) {
8051
+ for (const id of block.effectiveMessageIds ?? []) {
8052
+ msgIdSet.add(id);
8053
+ }
8054
+ }
8055
+ const blockMessages = rawMessages.filter((m) => msgIdSet.has(extractMessageId(m)));
8056
+ const lines2 = blockMessages.map(extractMessageText);
7882
8057
  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.`;
8058
+ const fileContent = lines2.length > 0 ? lines2.join("\n\n---\n\n") : activeBlocks[0]?.summary ?? "(no content available)";
8059
+ await writeFile3(targetPath, fileContent, "utf-8");
8060
+ const displayIds2 = targets.map((t) => `b${t.displayId}`).join(", ");
8061
+ 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
8062
  }
7887
8063
  const activeMessagesBefore = snapshotActiveMessages(messagesState);
7888
8064
  const activeBlockIdsBefore = new Set(messagesState.activeBlockIds);
7889
- deactivateCompressionTarget(messagesState, target);
8065
+ for (const target of targets) {
8066
+ deactivateCompressionTarget(messagesState, target);
8067
+ }
7890
8068
  syncCompressionBlocks(ctx.state, ctx.logger, rawMessages);
7891
8069
  const { restoredMessageCount, restoredTokens } = computeRestoredMessages(
7892
8070
  messagesState,
@@ -7909,9 +8087,11 @@ ${content}`;
7909
8087
  activeMessagesBefore,
7910
8088
  messagesState
7911
8089
  );
8090
+ const displayIds = targets.map((t) => `b${t.displayId}`).join(", ");
7912
8091
  const lines = [];
8092
+ const headerNoun = targets.length === 1 ? "block" : "blocks";
7913
8093
  lines.push(
7914
- `Decompressed block b${target.displayId}. Restored ${restoredMessageCount} message(s) (~${formatTokenCount(restoredTokens)}).`
8094
+ `Decompressed ${headerNoun} ${displayIds}. Restored ${restoredMessageCount} message(s) (~${formatTokenCount(restoredTokens)}).`
7915
8095
  );
7916
8096
  if (contextUsageBefore !== void 0 && contextUsageAfter !== void 0) {
7917
8097
  lines.push(`Context usage: ${contextUsageBefore}% \u2192 ${contextUsageAfter}%.`);
@@ -7926,8 +8106,8 @@ ${content}`;
7926
8106
  lines.push(restoredContentPreview);
7927
8107
  }
7928
8108
  ctx.logger.info("Decompress tool completed", {
7929
- targetBlockId: target.displayId,
7930
- targetRunId: target.runId,
8109
+ mode: typeof args.startId === "string" ? "range" : "block",
8110
+ targetBlockIds: targets.map((t) => t.displayId),
7931
8111
  restoredMessageCount,
7932
8112
  restoredTokens,
7933
8113
  reactivatedBlockIds