opencode-acp 1.12.6 → 1.12.7-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.7-dev.1 — Smart Recommendation Filter + Dangerous Parameter + Deploy Version Guard (PR #142, dev)
426
+
427
+ **Problem**: The recommendation filter had three issues. (1) It used a hardcoded 5× growth threshold for single-message ranges, which was too large (25% of context) and could leak context. (2) The filter showed tiny ranges (71 tokens) alongside meaningful ones. (3) The last segment was always shown with a position-based "recent" annotation, but compressing it requires explicit opt-in — a contradiction between recommending and blocking. Additionally, `dev-deploy.sh` deploys were silently overwritten by npm on opencode restart when the npm version was newer than the local version.
428
+
429
+ **Fix**: (1) Rewrote `filterRecommendedRanges` with a single coherent design: last segment excluded if < 2× growth threshold; included with `dangerous: true` flag if ≥ 2×. Gate: "effective compressible" = non-last-segment tokens + max(0, last-segment − 2× floor); suppress all if < growth threshold. (2) Protected ranges no longer affect filtering logic. (3) Replaced state-tracking soft-block with stateless `dangerous?: boolean` parameter on the compress tool schema — model must explicitly opt-in to compress the tail. Net −70 lines. (4) Added debug filter decision logging via `sendIgnoredMessage` (model-invisible, user-visible when `debug: true`). (5) `dev-deploy.sh` now queries `npm view` and auto-bumps the deployed version above npm's 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`, `scripts/dev-deploy.sh`. Tests: `tests/smart-nudge-gating.test.ts` (rewritten, 17 tests), `tests/soft-block.test.ts` (5 tests), `tests/inject.test.ts` (updated). 711 tests 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-dev.1 — 智能推荐过滤 + Dangerous 参数 + 部署版本守卫(PR #142, dev)
399
+
400
+ **问题**:推荐过滤器有三个问题。(1)使用硬编码的 5× 增长阈值(上下文的 25%),太大且容易泄露上下文。(2)过滤器会显示极小的 range(71 tokens)。(3)最后一段总是显示位置标注 "recent",但压缩它需要显式确认——推荐和阻止矛盾。此外,`dev-deploy.sh` 的部署在 opencode 重启时被 npm 静默覆盖(当 npm 版本比本地新时)。
401
+
402
+ **修复**:(1)重写 `filterRecommendedRanges`:最后一段 < 2× 增长阈值 → 剔除;≥ 2× → 保留并标记 `dangerous: true`。门槛:"有效可压缩" = 非最后段 + max(0, 最后段 − 2× 阈值);< 增长阈值则全部抑制。(2)受保护内容不再影响过滤逻辑。(3)用无状态的 `dangerous?: boolean` 参数替换状态追踪 soft-block——模型必须显式 opt-in 才能压缩尾部。净减 70 行。(4)通过 `sendIgnoredMessage` 注入过滤决策调试信息(模型不可见,`debug: true` 时用户可见)。(5)`dev-deploy.sh` 查询 `npm view` 并自动将部署版本 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`、`scripts/dev-deploy.sh`。测试:`tests/smart-nudge-gating.test.ts`(重写,17 测试)、`tests/soft-block.test.ts`(5 测试)、`tests/inject.test.ts`(更新)。711 测试通过。
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) {
@@ -5245,6 +5247,31 @@ async function finalizeSession(ctx, toolCtx, rawMessages, entries, batchTopic) {
5245
5247
  contextTokensBefore
5246
5248
  );
5247
5249
  }
5250
+ function getLastVisibleMessageId(rawMessages, state) {
5251
+ for (let i = rawMessages.length - 1; i >= 0; i--) {
5252
+ const msg = rawMessages[i];
5253
+ const id = msg?.info?.id;
5254
+ if (!id || typeof id !== "string") continue;
5255
+ if (isSyntheticMessage(msg)) continue;
5256
+ if (state.prune.messages.byMessageId.has(id)) continue;
5257
+ return id;
5258
+ }
5259
+ return null;
5260
+ }
5261
+ function checkLastSegmentDangerous(ctx, allPlanMessageIds, rawMessages, dangerous) {
5262
+ if (ctx.config.compress.lastSegmentSoftBlock === false) return null;
5263
+ const lastVisibleId = getLastVisibleMessageId(rawMessages, ctx.state);
5264
+ if (!lastVisibleId) return null;
5265
+ const coversLast = allPlanMessageIds.some((ids) => ids.includes(lastVisibleId));
5266
+ if (!coversLast) return null;
5267
+ if (dangerous) return null;
5268
+ return new Error(
5269
+ `This range includes the most recent message (${lastVisibleId}), which is likely still needed for the current task step.
5270
+
5271
+ If you are certain this content is genuinely consumed and must be compressed, re-issue the call with \`dangerous: true\`.
5272
+ Otherwise, compress older ranges that do not include the tail of the conversation.`
5273
+ );
5274
+ }
5248
5275
 
5249
5276
  // lib/compress/keep-markers.ts
5250
5277
  var KEEP_REGEX = /\[\[KEEP:(m\d+)\]\]/g;
@@ -5375,6 +5402,9 @@ function buildSchema(maxSummaryLengthHard) {
5375
5402
  ).describe("Batch of individual message summaries to create in one tool call"),
5376
5403
  summaryMaxChars: tool2.schema.number().optional().describe(
5377
5404
  `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.`
5405
+ ),
5406
+ dangerous: tool2.schema.boolean().optional().describe(
5407
+ "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
5408
  )
5379
5409
  };
5380
5410
  }
@@ -5433,6 +5463,14 @@ function createCompressMessageTool(ctx) {
5433
5463
  );
5434
5464
  }
5435
5465
  }
5466
+ const dangerous = args.dangerous === true;
5467
+ const lastSegmentError = checkLastSegmentDangerous(
5468
+ ctx,
5469
+ plans.map((p) => p.selection.messageIds),
5470
+ rawMessages,
5471
+ dangerous
5472
+ );
5473
+ if (lastSegmentError) throw lastSegmentError;
5436
5474
  const notifications = [];
5437
5475
  const preparedPlans = [];
5438
5476
  for (const plan of plans) {
@@ -5529,6 +5567,9 @@ function buildSchema2(maxSummaryLengthHard) {
5529
5567
  ),
5530
5568
  summaryMaxChars: tool3.schema.number().optional().describe(
5531
5569
  `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.`
5570
+ ),
5571
+ dangerous: tool3.schema.boolean().optional().describe(
5572
+ "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
5573
  )
5533
5574
  };
5534
5575
  }
@@ -5594,6 +5635,14 @@ function createCompressRangeTool(ctx) {
5594
5635
  );
5595
5636
  }
5596
5637
  }
5638
+ const dangerous = args.dangerous === true;
5639
+ const lastSegmentError = checkLastSegmentDangerous(
5640
+ ctx,
5641
+ filteredPlans.map((p) => p.selection.messageIds),
5642
+ rawMessages,
5643
+ dangerous
5644
+ );
5645
+ if (lastSegmentError) throw lastSegmentError;
5597
5646
  const notifications = [];
5598
5647
  const preparedPlans = [];
5599
5648
  let totalCompressedMessages = 0;
@@ -6945,12 +6994,71 @@ function buildCompressibleRanges(messages, state, protectedTools = [], protected
6945
6994
  protected: protectedGroups
6946
6995
  };
6947
6996
  }
6997
+ function filterRecommendedRanges(compressible, _protectedRanges, options) {
6998
+ const { modelContextLimit, growthRatio, logger } = options;
6999
+ const log = logger?.debug.bind(logger);
7000
+ if (!modelContextLimit || modelContextLimit <= 0) {
7001
+ log?.("filterRecommendedRanges: modelContextLimit unknown, passthrough", {
7002
+ inputCount: compressible.length
7003
+ });
7004
+ if (compressible.length === 0) return [];
7005
+ const passthrough = [...compressible];
7006
+ passthrough[passthrough.length - 1] = {
7007
+ ...passthrough[passthrough.length - 1],
7008
+ dangerous: true
7009
+ };
7010
+ return passthrough;
7011
+ }
7012
+ if (compressible.length === 0) {
7013
+ log?.("filterRecommendedRanges: no compressible ranges, returning empty");
7014
+ return [];
7015
+ }
7016
+ const growthThreshold = modelContextLimit * growthRatio;
7017
+ const lastSegmentFloor = growthThreshold * 2;
7018
+ const lastIndex = compressible.length - 1;
7019
+ const lastRange = compressible[lastIndex];
7020
+ const lastSegmentIncluded = lastRange.tokens >= lastSegmentFloor;
7021
+ let effectiveCompressible = 0;
7022
+ const result = [];
7023
+ for (let i = 0; i < compressible.length; i++) {
7024
+ const r = compressible[i];
7025
+ if (i === lastIndex) {
7026
+ const excess = Math.max(0, r.tokens - lastSegmentFloor);
7027
+ effectiveCompressible += excess;
7028
+ if (lastSegmentIncluded) {
7029
+ result.push({ ...r, dangerous: true });
7030
+ }
7031
+ } else {
7032
+ effectiveCompressible += r.tokens;
7033
+ result.push(r);
7034
+ }
7035
+ }
7036
+ const suppressed = effectiveCompressible < growthThreshold;
7037
+ log?.("filterRecommendedRanges: decision", {
7038
+ inputRanges: compressible.length,
7039
+ inputTokens: compressible.reduce((s, r) => s + r.tokens, 0),
7040
+ growthThreshold,
7041
+ lastSegmentFloor,
7042
+ lastSegment: {
7043
+ ref: `${lastRange.startRef}\u2013${lastRange.endRef}`,
7044
+ tokens: lastRange.tokens,
7045
+ included: lastSegmentIncluded
7046
+ },
7047
+ effectiveCompressible,
7048
+ suppressed,
7049
+ outputRanges: suppressed ? 0 : result.length
7050
+ });
7051
+ if (suppressed) {
7052
+ return [];
7053
+ }
7054
+ return result;
7055
+ }
6948
7056
  function formatCompressibleRanges(ranges, protectedRanges) {
6949
7057
  const fmt = (n) => n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n);
6950
7058
  if (!protectedRanges || protectedRanges.length === 0) {
6951
7059
  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)" : "";
7060
+ const lines2 = ranges.map((r) => {
7061
+ const suffix = r.dangerous ? " \u26A0\uFE0F NOT recommended unless you are certain. If you MUST compress this, pass `dangerous: true`." : "";
6954
7062
  return ` ${r.startRef}\u2013${r.endRef} ${r.count} msgs ${fmt(r.tokens)} [tool ${r.toolPct}% | text ${r.textPct}%]${suffix}`;
6955
7063
  });
6956
7064
  return `Compressible ranges (oldest first):
@@ -6971,7 +7079,8 @@ ${lines2.join("\n")}`;
6971
7079
  compressibleCount: r.count,
6972
7080
  protectedTokens: 0,
6973
7081
  protectedCount: 0,
6974
- protectedTools: []
7082
+ protectedTools: [],
7083
+ dangerous: r.dangerous ?? false
6975
7084
  });
6976
7085
  }
6977
7086
  for (const r of protectedRanges) {
@@ -6988,7 +7097,8 @@ ${lines2.join("\n")}`;
6988
7097
  compressibleCount: 0,
6989
7098
  protectedTokens: r.tokens,
6990
7099
  protectedCount: r.count,
6991
- protectedTools: [...r.tools]
7100
+ protectedTools: [...r.tools],
7101
+ dangerous: false
6992
7102
  });
6993
7103
  }
6994
7104
  entries.sort((a, b) => a.startNum - b.startNum);
@@ -7004,6 +7114,7 @@ ${lines2.join("\n")}`;
7004
7114
  last.compressibleCount += entry.compressibleCount;
7005
7115
  last.protectedTokens += entry.protectedTokens;
7006
7116
  last.protectedCount += entry.protectedCount;
7117
+ if (entry.dangerous) last.dangerous = true;
7007
7118
  for (const t of entry.protectedTools) {
7008
7119
  if (!last.protectedTools.includes(t)) last.protectedTools.push(t);
7009
7120
  }
@@ -7011,8 +7122,8 @@ ${lines2.join("\n")}`;
7011
7122
  merged.push({ ...entry });
7012
7123
  }
7013
7124
  }
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)" : "";
7125
+ const lines = merged.map((e) => {
7126
+ const suffix = e.dangerous && e.compressibleTokens > 0 ? " \u26A0\uFE0F NOT recommended unless you are certain. If you MUST compress this, pass `dangerous: true`." : "";
7016
7127
  if (e.protectedTokens > 0 && e.compressibleTokens === 0) {
7017
7128
  return ` ${e.startRef}\u2013${e.endRef} ${e.count} msgs ${fmt(e.tokens)} [PROTECTED: ${e.protectedTools.join(", ")} \u2014 not compressible]${suffix}`;
7018
7129
  }
@@ -7268,10 +7379,34 @@ Breakdown: ${fmt(composition.toolTokens)} tool (${pct2(composition.toolTokens)}%
7268
7379
  config.compress.protectedTools,
7269
7380
  config.protectedFilePatterns
7270
7381
  );
7271
- if (contextRanges.compressible.length > 0) {
7382
+ const recommendedRanges = filterRecommendedRanges(
7383
+ contextRanges.compressible,
7384
+ contextRanges.protected,
7385
+ { modelContextLimit, growthRatio: 0.05, logger }
7386
+ );
7387
+ if (debugNotify && contextRanges.compressible.length > 0 && modelContextLimit) {
7388
+ const growthThreshold = modelContextLimit * 0.05;
7389
+ const lastSegmentFloor = growthThreshold * 2;
7390
+ const compressible = contextRanges.compressible;
7391
+ const lastRange = compressible[compressible.length - 1];
7392
+ const lastIncluded = lastRange.tokens >= lastSegmentFloor;
7393
+ const nonLastTokens = compressible.slice(0, -1).reduce((s, r) => s + r.tokens, 0);
7394
+ const effective = nonLastTokens + Math.max(0, lastRange.tokens - lastSegmentFloor);
7395
+ const suppressed = effective < growthThreshold;
7396
+ const fmt2 = (n) => n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n);
7397
+ const lines = [
7398
+ `[ACP Debug] Recommendation filter decision:`,
7399
+ ` Input: ${compressible.length} range(s), ${fmt2(compressible.reduce((s, r) => s + r.tokens, 0))} tokens`,
7400
+ ` Thresholds: growth=${fmt2(growthThreshold)}, lastSegmentFloor=${fmt2(lastSegmentFloor)}`,
7401
+ ` Last segment: ${lastRange.startRef}\u2013${lastRange.endRef} ${fmt2(lastRange.tokens)} tokens \u2192 ${lastIncluded ? "included (dangerous)" : "excluded (< floor)"}`,
7402
+ ` Effective compressible: ${fmt2(effective)} \u2192 ${suppressed ? "SUPPRESSED (< growth threshold)" : `${recommendedRanges.length} range(s) recommended`}`
7403
+ ];
7404
+ debugNotify(lines.join("\n"));
7405
+ }
7406
+ if (recommendedRanges.length > 0) {
7272
7407
  breakdown += `
7273
7408
 
7274
- ${formatCompressibleRanges(contextRanges.compressible, contextRanges.protected)}`;
7409
+ ${formatCompressibleRanges(recommendedRanges, contextRanges.protected)}`;
7275
7410
  breakdown += `
7276
7411
  \u{1F4A1} Compress all ranges in one call (pass multiple content entries: \`content: [{...}, {...}]\`).`;
7277
7412
  }