opencode-acp 1.12.6 → 1.12.7

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.7 — Smart Recommendation Filter + Dangerous Parameter + Ref-Leak Fix + Phantom Turn Fix (PRs #142, #147, #150)
426
+
427
+ **Problem**: Four issues. (1) The recommendation filter used a hardcoded 5× growth threshold for single-message ranges (25% of context), leaked context, showed tiny ranges, and contradicted itself by recommending the last segment while blocking it. (2) Nudge text was injected even when the filter suppressed all ranges — wasting context with an empty recommendation list. (3) Compression block metadata leaked message refs (`m01309–m02150`) via `acp_context_recap` tool input, `acp_status` output, and `recap` tool output — the model copied these into compress calls on already-compressed ranges, creating phantom blocks (#93, #135). (4) `sendIgnoredMessage` during the transform hook persisted an ignored user message that resolved async after the model's response — the loop's `lastUser` detection picked it up → phantom LLM call with no new input → confusion → hallucination → feedback loop ("待命" spam).
428
+
429
+ **Fix**: (1) Rewrote `filterRecommendedRanges`: last segment excluded if < 2× growth threshold; included with `dangerous: true` flag if ≥ 2×. Stateless `dangerous?: boolean` parameter on compress tool schema replaces state-tracking soft-block. Net −70 lines. (2) Suppress nudge text injection when filter has no recommendations. (3) Stopped leaking message refs: `acp_context_recap` tool input now shows `messages: <count>` instead of `range: "(mN–mN)"`; `acp_status` and `recap` tool output show `N msgs` instead of ref ranges. (4) Debug nudge notification uses `client.tui.showToast()` (transient, non-persisting) + `logger.debug()` (file log) instead of `sendIgnoredMessage` — breaks the phantom-turn feedback loop entirely. `dev-deploy.sh` auto-bumps version above npm latest to prevent overwrite on restart.
430
+
431
+ Files: `lib/messages/inject/utils.ts`, `lib/messages/inject/inject.ts`, `lib/compress/pipeline.ts`, `lib/compress/range.ts`, `lib/compress/message.ts`, `lib/messages/prune.ts`, `lib/messages/utils.ts`, `lib/compress/recap.ts`, `lib/compress/status.ts`, `lib/ui/notification.ts`, `lib/prompts/system.ts`, `lib/hooks.ts`, `scripts/dev-deploy.sh`. Tests: 725 pass.
432
+
425
433
  ### v1.12.6 — Stale contextLimitAnchors Fix (PR #143)
426
434
 
427
435
  **Problem**: `contextLimitAnchors` were populated when `overMaxLimit=true` but only cleared on a compress tool call in the current turn (`currentTurnHasCompress`). If context dropped below `maxLimit` via another mechanism (OpenCode compaction, external message deletion), the anchors stayed stale → `applyAnchoredNudges` kept injecting the "⚠️ Context limit reached" template at low context levels (as low as 10% usage).
package/README.zh-CN.md CHANGED
@@ -395,6 +395,14 @@ ACP 在首次启动时自动将配置从 `dcp.jsonc` 迁移到 `acp.jsonc`,将
395
395
 
396
396
  ## 更新日志
397
397
 
398
+ ### v1.12.7 — 智能推荐过滤 + Dangerous 参数 + Ref 泄漏修复 + Phantom Turn 修复(PR #142, #147, #150)
399
+
400
+ **问题**:四个问题。(1)推荐过滤器使用硬编码的 5× 增长阈值(上下文的 25%),太大且容易泄露上下文;推荐最后一段的同时又阻止它,自相矛盾。(2)过滤器抑制所有 range 后仍然注入 nudge 文本——用空推荐列表浪费上下文。(3)压缩块元数据通过 `acp_context_recap` 工具输入、`acp_status` 输出和 `recap` 工具输出泄漏消息 ref(`m01309–m02150`)——模型复制这些 ref 到已压缩范围的 compress 调用中,产生幽灵块(#93, #135)。(4)`sendIgnoredMessage` 在 transform hook 中持久化 ignored 用户消息,异步在模型回复后完成——loop 的 `lastUser` 检测拾取它 → 无新输入的 phantom LLM 调用 → 困惑 → 幻觉 → 反馈循环("待命" 刷屏)。
401
+
402
+ **修复**:(1)重写 `filterRecommendedRanges`:最后一段 < 2× 增长阈值 → 剔除;≥ 2× → 保留并标记 `dangerous: true`。无状态 `dangerous?: boolean` 参数替代状态追踪 soft-block。净减 70 行。(2)过滤器无推荐时抑制 nudge 文本注入。(3)停止泄漏消息 ref:`acp_context_recap` 工具输入改为 `messages: <数量>`;`acp_status` 和 `recap` 工具输出改为 `N msgs`。(4)Debug nudge 通知改用 `client.tui.showToast()`(瞬态,不持久化)+ `logger.debug()`(文件日志),彻底打破 phantom-turn 反馈循环。`dev-deploy.sh` 自动 bump 版本到 npm 最新之上防止重启覆盖。
403
+
404
+ 文件:`lib/messages/inject/utils.ts`、`lib/messages/inject/inject.ts`、`lib/compress/pipeline.ts`、`lib/compress/range.ts`、`lib/compress/message.ts`、`lib/messages/prune.ts`、`lib/messages/utils.ts`、`lib/compress/recap.ts`、`lib/compress/status.ts`、`lib/ui/notification.ts`、`lib/prompts/system.ts`、`lib/hooks.ts`、`scripts/dev-deploy.sh`。测试:725 通过。
405
+
398
406
  ### v1.12.6 — Stale contextLimitAnchors 修复(PR #143)
399
407
 
400
408
  **问题**:`contextLimitAnchors` 仅在 `overMaxLimit=true` 时添加,但只在当前 turn 有 compress 调用时 (`currentTurnHasCompress`) 才清除。如果上下文通过其他机制(OpenCode compaction、外部消息删除)降到 `maxLimit` 以下,anchors 保持 stale → `applyAnchoredNudges` 持续注入 "⚠️ Context limit reached" 模板,即使实际上下文很低(低至 10%)。
package/dist/index.js CHANGED
@@ -1592,7 +1592,8 @@ var defaultConfig = {
1592
1592
  minNudgeGrowthFloor: 5e3,
1593
1593
  emergencyThresholdPercent: "98%",
1594
1594
  maxVisibleSegments: 50,
1595
- keepEmbedMaxChars: 2e3
1595
+ keepEmbedMaxChars: 2e3,
1596
+ lastSegmentSoftBlock: true
1596
1597
  },
1597
1598
  strategies: {
1598
1599
  deduplication: {
@@ -1753,7 +1754,8 @@ function mergeCompress(base, override) {
1753
1754
  minNudgeGrowthFloor: override.minNudgeGrowthFloor ?? base.minNudgeGrowthFloor,
1754
1755
  emergencyThresholdPercent: override.emergencyThresholdPercent ?? base.emergencyThresholdPercent,
1755
1756
  maxVisibleSegments: override.maxVisibleSegments ?? base.maxVisibleSegments,
1756
- keepEmbedMaxChars: override.keepEmbedMaxChars ?? base.keepEmbedMaxChars
1757
+ keepEmbedMaxChars: override.keepEmbedMaxChars ?? base.keepEmbedMaxChars,
1758
+ lastSegmentSoftBlock: override.lastSegmentSoftBlock ?? base.lastSegmentSoftBlock
1757
1759
  };
1758
1760
  }
1759
1761
  function mergeCommands(base, override) {
@@ -4960,14 +4962,9 @@ function formatEntryRanges(entries, state) {
4960
4962
  for (const entry of entries) {
4961
4963
  const block = state.prune.messages.blocksById.get(entry.blockId);
4962
4964
  if (!block) continue;
4963
- const startRef = block.startId;
4964
- const endRef = block.endId;
4965
- if (!startRef || !endRef) continue;
4966
- if (startRef === endRef) {
4967
- parts.push(`b${entry.blockId}: ${startRef}`);
4968
- } else {
4969
- parts.push(`b${entry.blockId}: ${startRef}\u2013${endRef}`);
4970
- }
4965
+ const count = block.effectiveMessageIds?.length || 0;
4966
+ if (count === 0) continue;
4967
+ parts.push(`b${entry.blockId}: ${count} msg${count !== 1 ? "s" : ""}`);
4971
4968
  }
4972
4969
  return parts.length > 0 ? parts.join(", ") : null;
4973
4970
  }
@@ -5245,6 +5242,31 @@ async function finalizeSession(ctx, toolCtx, rawMessages, entries, batchTopic) {
5245
5242
  contextTokensBefore
5246
5243
  );
5247
5244
  }
5245
+ function getLastVisibleMessageId(rawMessages, state) {
5246
+ for (let i = rawMessages.length - 1; i >= 0; i--) {
5247
+ const msg = rawMessages[i];
5248
+ const id = msg?.info?.id;
5249
+ if (!id || typeof id !== "string") continue;
5250
+ if (isSyntheticMessage(msg)) continue;
5251
+ if (state.prune.messages.byMessageId.has(id)) continue;
5252
+ return id;
5253
+ }
5254
+ return null;
5255
+ }
5256
+ function checkLastSegmentDangerous(ctx, allPlanMessageIds, rawMessages, dangerous) {
5257
+ if (ctx.config.compress.lastSegmentSoftBlock === false) return null;
5258
+ const lastVisibleId = getLastVisibleMessageId(rawMessages, ctx.state);
5259
+ if (!lastVisibleId) return null;
5260
+ const coversLast = allPlanMessageIds.some((ids) => ids.includes(lastVisibleId));
5261
+ if (!coversLast) return null;
5262
+ if (dangerous) return null;
5263
+ return new Error(
5264
+ `This range includes the most recent message (${lastVisibleId}), which is likely still needed for the current task step.
5265
+
5266
+ If you are certain this content is genuinely consumed and must be compressed, re-issue the call with \`dangerous: true\`.
5267
+ Otherwise, compress older ranges that do not include the tail of the conversation.`
5268
+ );
5269
+ }
5248
5270
 
5249
5271
  // lib/compress/keep-markers.ts
5250
5272
  var KEEP_REGEX = /\[\[KEEP:(m\d+)\]\]/g;
@@ -5375,6 +5397,9 @@ function buildSchema(maxSummaryLengthHard) {
5375
5397
  ).describe("Batch of individual message summaries to create in one tool call"),
5376
5398
  summaryMaxChars: tool2.schema.number().optional().describe(
5377
5399
  `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.`
5400
+ ),
5401
+ dangerous: tool2.schema.boolean().optional().describe(
5402
+ "Set to true ONLY when you are certain the most recent message(s) must be compressed. Required when a range includes the tail of the conversation."
5378
5403
  )
5379
5404
  };
5380
5405
  }
@@ -5433,6 +5458,14 @@ function createCompressMessageTool(ctx) {
5433
5458
  );
5434
5459
  }
5435
5460
  }
5461
+ const dangerous = args.dangerous === true;
5462
+ const lastSegmentError = checkLastSegmentDangerous(
5463
+ ctx,
5464
+ plans.map((p) => p.selection.messageIds),
5465
+ rawMessages,
5466
+ dangerous
5467
+ );
5468
+ if (lastSegmentError) throw lastSegmentError;
5436
5469
  const notifications = [];
5437
5470
  const preparedPlans = [];
5438
5471
  for (const plan of plans) {
@@ -5529,6 +5562,9 @@ function buildSchema2(maxSummaryLengthHard) {
5529
5562
  ),
5530
5563
  summaryMaxChars: tool3.schema.number().optional().describe(
5531
5564
  `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.`
5565
+ ),
5566
+ dangerous: tool3.schema.boolean().optional().describe(
5567
+ "Set to true ONLY when you are certain the most recent message(s) must be compressed. Required when a range includes the tail of the conversation."
5532
5568
  )
5533
5569
  };
5534
5570
  }
@@ -5594,6 +5630,14 @@ function createCompressRangeTool(ctx) {
5594
5630
  );
5595
5631
  }
5596
5632
  }
5633
+ const dangerous = args.dangerous === true;
5634
+ const lastSegmentError = checkLastSegmentDangerous(
5635
+ ctx,
5636
+ filteredPlans.map((p) => p.selection.messageIds),
5637
+ rawMessages,
5638
+ dangerous
5639
+ );
5640
+ if (lastSegmentError) throw lastSegmentError;
5597
5641
  const notifications = [];
5598
5642
  const preparedPlans = [];
5599
5643
  let totalCompressedMessages = 0;
@@ -5793,7 +5837,7 @@ var createSyntheticMessage = (baseMessage, content, stableSeed, role = "user") =
5793
5837
  return { info, parts };
5794
5838
  };
5795
5839
  var createSyntheticUserMessage = (baseMessage, content, stableSeed) => createSyntheticMessage(baseMessage, content, stableSeed, "user");
5796
- var createSyntheticToolRecap = (baseMessage, summary, blockId, range, stableSeed) => {
5840
+ var createSyntheticToolRecap = (baseMessage, summary, blockId, messageCount, stableSeed) => {
5797
5841
  const baseInfo = baseMessage.info;
5798
5842
  const now = Date.now();
5799
5843
  const messageId = generateStableId("msg_acp_recap", stableSeed);
@@ -5810,7 +5854,7 @@ var createSyntheticToolRecap = (baseMessage, summary, blockId, range, stableSeed
5810
5854
  status: "completed",
5811
5855
  input: {
5812
5856
  blockId,
5813
- ...range ? { range } : {}
5857
+ ...messageCount !== void 0 ? { messages: messageCount } : {}
5814
5858
  },
5815
5859
  output: summary,
5816
5860
  title: `ACP Context Recap (block ${blockId})`,
@@ -5961,10 +6005,9 @@ var dropEmptyMessages = (messages) => {
5961
6005
  };
5962
6006
 
5963
6007
  // lib/messages/prune.ts
5964
- var computeBlockRange = (startId, endId) => {
5965
- if (!startId || !endId) return void 0;
5966
- if (startId === endId) return `(${startId})`;
5967
- return `(${startId}\u2013${endId})`;
6008
+ var computeBlockCoverage = (effectiveMessageIds) => {
6009
+ if (!effectiveMessageIds || effectiveMessageIds.length === 0) return void 0;
6010
+ return effectiveMessageIds.length;
5968
6011
  };
5969
6012
  var prune = (state, logger, config, messages) => {
5970
6013
  filterCompressedRanges(state, logger, config, messages);
@@ -6019,14 +6062,14 @@ var filterCompressedRanges = (state, logger, config, messages) => {
6019
6062
  } else {
6020
6063
  const cleaned = stripStaleMessageRefs(rawSummaryContent);
6021
6064
  const summaryContent = config.compress.mode === "message" ? replaceBlockIdsWithBlocked(cleaned) : cleaned;
6022
- const blockRange = computeBlockRange(summary.startId, summary.endId);
6065
+ const blockCoverage = computeBlockCoverage(summary.effectiveMessageIds);
6023
6066
  const summarySeed = `${summary.blockId}:${summary.anchorMessageId}`;
6024
6067
  result.push(
6025
6068
  createSyntheticToolRecap(
6026
6069
  msg,
6027
6070
  summaryContent,
6028
6071
  summary.blockId,
6029
- blockRange,
6072
+ blockCoverage,
6030
6073
  summarySeed
6031
6074
  )
6032
6075
  );
@@ -6945,12 +6988,71 @@ function buildCompressibleRanges(messages, state, protectedTools = [], protected
6945
6988
  protected: protectedGroups
6946
6989
  };
6947
6990
  }
6991
+ function filterRecommendedRanges(compressible, _protectedRanges, options) {
6992
+ const { modelContextLimit, growthRatio, logger } = options;
6993
+ const log = logger?.debug.bind(logger);
6994
+ if (!modelContextLimit || modelContextLimit <= 0) {
6995
+ log?.("filterRecommendedRanges: modelContextLimit unknown, passthrough", {
6996
+ inputCount: compressible.length
6997
+ });
6998
+ if (compressible.length === 0) return [];
6999
+ const passthrough = [...compressible];
7000
+ passthrough[passthrough.length - 1] = {
7001
+ ...passthrough[passthrough.length - 1],
7002
+ dangerous: true
7003
+ };
7004
+ return passthrough;
7005
+ }
7006
+ if (compressible.length === 0) {
7007
+ log?.("filterRecommendedRanges: no compressible ranges, returning empty");
7008
+ return [];
7009
+ }
7010
+ const growthThreshold = modelContextLimit * growthRatio;
7011
+ const lastSegmentFloor = growthThreshold * 2;
7012
+ const lastIndex = compressible.length - 1;
7013
+ const lastRange = compressible[lastIndex];
7014
+ const lastSegmentIncluded = lastRange.tokens >= lastSegmentFloor;
7015
+ let effectiveCompressible = 0;
7016
+ const result = [];
7017
+ for (let i = 0; i < compressible.length; i++) {
7018
+ const r = compressible[i];
7019
+ if (i === lastIndex) {
7020
+ const excess = Math.max(0, r.tokens - lastSegmentFloor);
7021
+ effectiveCompressible += excess;
7022
+ if (lastSegmentIncluded) {
7023
+ result.push({ ...r, dangerous: true });
7024
+ }
7025
+ } else {
7026
+ effectiveCompressible += r.tokens;
7027
+ result.push(r);
7028
+ }
7029
+ }
7030
+ const suppressed = effectiveCompressible < growthThreshold;
7031
+ log?.("filterRecommendedRanges: decision", {
7032
+ inputRanges: compressible.length,
7033
+ inputTokens: compressible.reduce((s, r) => s + r.tokens, 0),
7034
+ growthThreshold,
7035
+ lastSegmentFloor,
7036
+ lastSegment: {
7037
+ ref: `${lastRange.startRef}\u2013${lastRange.endRef}`,
7038
+ tokens: lastRange.tokens,
7039
+ included: lastSegmentIncluded
7040
+ },
7041
+ effectiveCompressible,
7042
+ suppressed,
7043
+ outputRanges: suppressed ? 0 : result.length
7044
+ });
7045
+ if (suppressed) {
7046
+ return [];
7047
+ }
7048
+ return result;
7049
+ }
6948
7050
  function formatCompressibleRanges(ranges, protectedRanges) {
6949
7051
  const fmt = (n) => n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n);
6950
7052
  if (!protectedRanges || protectedRanges.length === 0) {
6951
7053
  if (ranges.length === 0) return "";
6952
- const lines2 = ranges.map((r, i) => {
6953
- const suffix = i === ranges.length - 1 ? " (recent \u2014 may still be in active use)" : "";
7054
+ const lines2 = ranges.map((r) => {
7055
+ const suffix = r.dangerous ? " \u26A0\uFE0F NOT recommended unless you are certain. If you MUST compress this, pass `dangerous: true`." : "";
6954
7056
  return ` ${r.startRef}\u2013${r.endRef} ${r.count} msgs ${fmt(r.tokens)} [tool ${r.toolPct}% | text ${r.textPct}%]${suffix}`;
6955
7057
  });
6956
7058
  return `Compressible ranges (oldest first):
@@ -6971,7 +7073,8 @@ ${lines2.join("\n")}`;
6971
7073
  compressibleCount: r.count,
6972
7074
  protectedTokens: 0,
6973
7075
  protectedCount: 0,
6974
- protectedTools: []
7076
+ protectedTools: [],
7077
+ dangerous: r.dangerous ?? false
6975
7078
  });
6976
7079
  }
6977
7080
  for (const r of protectedRanges) {
@@ -6988,7 +7091,8 @@ ${lines2.join("\n")}`;
6988
7091
  compressibleCount: 0,
6989
7092
  protectedTokens: r.tokens,
6990
7093
  protectedCount: r.count,
6991
- protectedTools: [...r.tools]
7094
+ protectedTools: [...r.tools],
7095
+ dangerous: false
6992
7096
  });
6993
7097
  }
6994
7098
  entries.sort((a, b) => a.startNum - b.startNum);
@@ -7004,6 +7108,7 @@ ${lines2.join("\n")}`;
7004
7108
  last.compressibleCount += entry.compressibleCount;
7005
7109
  last.protectedTokens += entry.protectedTokens;
7006
7110
  last.protectedCount += entry.protectedCount;
7111
+ if (entry.dangerous) last.dangerous = true;
7007
7112
  for (const t of entry.protectedTools) {
7008
7113
  if (!last.protectedTools.includes(t)) last.protectedTools.push(t);
7009
7114
  }
@@ -7011,8 +7116,8 @@ ${lines2.join("\n")}`;
7011
7116
  merged.push({ ...entry });
7012
7117
  }
7013
7118
  }
7014
- const lines = merged.map((e, i) => {
7015
- const suffix = i === merged.length - 1 && e.compressibleTokens > 0 ? " (recent \u2014 may still be in active use)" : "";
7119
+ const lines = merged.map((e) => {
7120
+ const suffix = e.dangerous && e.compressibleTokens > 0 ? " \u26A0\uFE0F NOT recommended unless you are certain. If you MUST compress this, pass `dangerous: true`." : "";
7016
7121
  if (e.protectedTokens > 0 && e.compressibleTokens === 0) {
7017
7122
  return ` ${e.startRef}\u2013${e.endRef} ${e.count} msgs ${fmt(e.tokens)} [PROTECTED: ${e.protectedTools.join(", ")} \u2014 not compressible]${suffix}`;
7018
7123
  }
@@ -7227,7 +7332,6 @@ var injectCompressNudges = (state, config, logger, messages, prompts, compressio
7227
7332
  });
7228
7333
  const growthSinceBaseline = currentTokens !== void 0 && growthReference !== void 0 ? currentTokens - growthReference : void 0;
7229
7334
  const nudgeAllowed = emergencyOverride || decision.shouldNudge && growthSinceBaseline !== void 0 && growthSinceBaseline >= growthFloor;
7230
- state.nudges.shouldInjectThisTurn = nudgeAllowed;
7231
7335
  const effectiveTipsVariant = emergencyOverride ? "maxLimit" : decision.tipsVariant;
7232
7336
  if (nudgeAllowed) {
7233
7337
  applyAnchoredNudges(state, config, messages, prompts, compressionPriorities, currentTokens, modelContextLimit, suffixMessage);
@@ -7242,8 +7346,42 @@ var injectCompressNudges = (state, config, logger, messages, prompts, compressio
7242
7346
  config.compress.protectedTools,
7243
7347
  config.protectedFilePatterns
7244
7348
  );
7349
+ const contextRanges = buildCompressibleRanges(
7350
+ messages,
7351
+ state,
7352
+ config.compress.protectedTools,
7353
+ config.protectedFilePatterns
7354
+ );
7355
+ const recommendedRanges = filterRecommendedRanges(
7356
+ contextRanges.compressible,
7357
+ contextRanges.protected,
7358
+ { modelContextLimit, growthRatio: 0.05, logger }
7359
+ );
7360
+ const hasRecommendations = recommendedRanges.length > 0;
7361
+ if (config.debug && contextRanges.compressible.length > 0 && modelContextLimit) {
7362
+ const growthThreshold = modelContextLimit * 0.05;
7363
+ const lastSegmentFloor = growthThreshold * 2;
7364
+ const compressible = contextRanges.compressible;
7365
+ const lastRange = compressible[compressible.length - 1];
7366
+ const lastIncluded = lastRange.tokens >= lastSegmentFloor;
7367
+ const nonLastTokens = compressible.slice(0, -1).reduce((s, r) => s + r.tokens, 0);
7368
+ const effective = nonLastTokens + Math.max(0, lastRange.tokens - lastSegmentFloor);
7369
+ const suppressed = effective < growthThreshold;
7370
+ const fmt = (n) => n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n);
7371
+ const lines = [
7372
+ `[ACP Debug] Recommendation filter decision:`,
7373
+ ` Input: ${compressible.length} range(s), ${fmt(compressible.reduce((s, r) => s + r.tokens, 0))} tokens`,
7374
+ ` Thresholds: growth=${fmt(growthThreshold)}, lastSegmentFloor=${fmt(lastSegmentFloor)}`,
7375
+ ` Last segment: ${lastRange.startRef}\u2013${lastRange.endRef} ${fmt(lastRange.tokens)} tokens \u2192 ${lastIncluded ? "included (dangerous)" : "excluded (< floor)"}`,
7376
+ ` Effective compressible: ${fmt(effective)} \u2192 ${suppressed ? "SUPPRESSED (< growth threshold)" : `${recommendedRanges.length} range(s) recommended`}`
7377
+ ];
7378
+ logger.debug(lines.join("\n"));
7379
+ }
7380
+ const filterSuppressed = contextRanges.compressible.length > 0 && !hasRecommendations;
7381
+ const shouldInject = nudgeAllowed && (!filterSuppressed || emergencyOverride);
7382
+ state.nudges.shouldInjectThisTurn = shouldInject;
7245
7383
  let tipsText = null;
7246
- if (nudgeAllowed) {
7384
+ if (shouldInject) {
7247
7385
  injectContextUsage(suffixMessage, config, currentTokens, modelContextLimit);
7248
7386
  if (suffixMessage && composition.total > 0) {
7249
7387
  const fmt = (n) => n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n);
@@ -7262,16 +7400,10 @@ Breakdown: ${fmt(composition.toolTokens)} tool (${pct2(composition.toolTokens)}%
7262
7400
  breakdown += `
7263
7401
  \u26A0\uFE0F ${fmt(composition.protectedTokens)} tokens are protected (environment-managed tools) \u2014 not compressible. Effective compressible: ~${fmt(compressibleTokens)}.`;
7264
7402
  }
7265
- const contextRanges = buildCompressibleRanges(
7266
- messages,
7267
- state,
7268
- config.compress.protectedTools,
7269
- config.protectedFilePatterns
7270
- );
7271
- if (contextRanges.compressible.length > 0) {
7403
+ if (recommendedRanges.length > 0) {
7272
7404
  breakdown += `
7273
7405
 
7274
- ${formatCompressibleRanges(contextRanges.compressible, contextRanges.protected)}`;
7406
+ ${formatCompressibleRanges(recommendedRanges, contextRanges.protected)}`;
7275
7407
  breakdown += `
7276
7408
  \u{1F4A1} Compress all ranges in one call (pass multiple content entries: \`content: [{...}, {...}]\`).`;
7277
7409
  }
@@ -7908,11 +8040,8 @@ function pct(n, total) {
7908
8040
  return Math.max(1, Math.round(n / total * 100));
7909
8041
  }
7910
8042
  function formatIdRange(block) {
7911
- const start = (block.startId || "").trim();
7912
- const end = (block.endId || "").trim();
7913
- if (!start || !end) return "\u2014";
7914
- if (start === end) return start;
7915
- return `${start}\u2013${end}`;
8043
+ const count = block.effectiveMessageIds?.length || 0;
8044
+ return count > 0 ? `${count} msg${count !== 1 ? "s" : ""}` : "\u2014";
7916
8045
  }
7917
8046
  function collectVisibleMessages(rawMessages, ctx) {
7918
8047
  const pruneMap = ctx.state.prune.messages.byMessageId;
@@ -8203,12 +8332,9 @@ function createAcpStatusTool(ctx) {
8203
8332
 
8204
8333
  // lib/compress/recap.ts
8205
8334
  import { tool as tool6 } from "@opencode-ai/plugin";
8206
- function formatRange(startId, endId) {
8207
- const start = (startId || "").trim();
8208
- const end = (endId || "").trim();
8209
- if (!start || !end) return "\u2014";
8210
- if (start === end) return start;
8211
- return `${start}\u2013${end}`;
8335
+ function formatCoverage(block) {
8336
+ const count = block.effectiveMessageIds?.length || 0;
8337
+ return count > 0 ? `${count} message${count !== 1 ? "s" : ""}` : "\u2014";
8212
8338
  }
8213
8339
  var RECAP_TOOL_DESCRIPTION = `Read-only retrieval of compression block summaries.
8214
8340
 
@@ -8236,7 +8362,7 @@ function createAcpContextRecapTool(ctx) {
8236
8362
  if (!block.active) {
8237
8363
  return `Block b${args.blockId} is inactive (deactivated by GC or nested compression).`;
8238
8364
  }
8239
- const range = formatRange(block.startId, block.endId);
8365
+ const range = formatCoverage(block);
8240
8366
  return `[Compressed conversation section]
8241
8367
  ${block.summary}
8242
8368
 
@@ -8247,7 +8373,7 @@ ${block.summary}
8247
8373
  for (const id of activeIds) {
8248
8374
  const block = msgState.blocksById.get(id);
8249
8375
  if (!block || !block.active) continue;
8250
- const range = formatRange(block.startId, block.endId);
8376
+ const range = formatCoverage(block);
8251
8377
  const summaryPreview = block.summary.slice(0, 200);
8252
8378
  lines.push(`
8253
8379
  b${id} | ${range} | "${block.topic || "(none)"}"`);
@@ -8591,7 +8717,7 @@ Periodically, as context grows, the system appends a short status line in a synt
8591
8717
 
8592
8718
  This line is INFORMATION, not an instruction. Seeing it does not mean you should compress. Compress only when one of the WHEN TO COMPRESS conditions actually holds. Between these lines, context is not under additional pressure \u2014 you do not need to seek things to compress.
8593
8719
 
8594
- If you are unsure which \`mNNNNN\` refs are still compressible, or which blocks have already consumed which ranges, call \`acp_status\` first. It returns the visible context breakdown (tool/code/text/summary tokens with largest items) and the compressed block list (block IDs, sizes, message-ID ranges each covers).
8720
+ If you are unsure which \`mNNNNN\` refs are still compressible, or which blocks have already consumed which ranges, call \`acp_status\` first. It returns the visible context breakdown (tool/code/text/summary tokens with largest items) and the compressed block list (block IDs, sizes, message counts each covers).
8595
8721
 
8596
8722
  CONTEXT BREAKDOWN
8597
8723
 
@@ -10458,15 +10584,17 @@ function createChatMessageTransformHandler(client, state, logger, config, prompt
10458
10584
  output.messages,
10459
10585
  prompts.getRuntimePrompts(),
10460
10586
  compressionPriorities,
10461
- config.debug && state.sessionId ? (text) => {
10462
- sendIgnoredMessage(
10463
- client,
10464
- state.sessionId,
10465
- `[ACP Debug] Nudge injected:
10466
- ${text}`,
10467
- {},
10468
- logger
10469
- ).catch(() => {
10587
+ config.debug ? (text) => {
10588
+ logger.debug(`[ACP Debug] Nudge injected:
10589
+ ${text}`);
10590
+ client.tui.showToast({
10591
+ body: {
10592
+ title: "ACP: Nudge Injected",
10593
+ message: text.slice(0, 500),
10594
+ variant: "info",
10595
+ duration: 5e3
10596
+ }
10597
+ }).catch(() => {
10470
10598
  });
10471
10599
  } : void 0,
10472
10600
  prePruneTokens