opencode-acp 1.9.0 → 1.9.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
@@ -421,6 +421,18 @@ For the complete list with root cause analysis, see the [bug tracker](https://gi
421
421
 
422
422
  ## Changelog
423
423
 
424
+ ### v1.9.1 — Disjoint Visible-Range Segments & Nudge Wording (issue #9 root cause)
425
+
426
+ **Problem**: Even after v1.9.0, the model kept calling `compress` against IDs that a prior block had consumed. The root cause was that the suffix advertised a single contiguous span "first visible → last visible" that **straddled compression holes** — so the model's first guess for an `endId` landed inside an already-summarized range. Separately, the suffix's `(+X tokens since last nudge)` growth line was being misread as an *overflow* warning, triggering panic compressions of large-but-still-needed ranges.
427
+
428
+ **Fix 1 — disjoint visible-id segments** (PR #57): `injectVisibleIdRange` no longer emits one "first-to-last" span. It builds the actual surviving segments in ascending ref order and truncates to the largest tool-bearing / high-token segments when the count overflows (`compress.maxVisibleSegments`, default `50`, now plumbed through config defaults + merge + validation + schema). The suffix now reads e.g. `[Visible (top 2 of 3 segments, 803 msgs): m00001–m00929, m00944–m00950 | +1 smaller segment (~1.2K tokens, 6 msgs) omitted]`, so the model sees exactly which ranges are compressible and never targets a hole. The formatting logic is extracted into pure, exported, unit-tested functions (`buildVisibleSegments`, `formatVisibleGuidance`).
429
+
430
+ **Fix 2 — nudge wording** (PR #58): The incremental-compression guidance line (`💡 Compress incrementally: target the ranges above...`) moved to *after* the largest-ranges list and is reworded to stress that **size alone is not a reason to compress** — a large range that is still needed in full must be kept. Soft efficiency nudges (`growth` / `minLimit` variants) are now prefixed with an explicit *"This is an efficiency nudge to compress early and keep context lean — not an overflow warning. A separate, stronger alert will appear if the context is actually full."* so the growth delta isn't mistaken for an overflow alarm. The `maxLimit` path keeps its stronger alert and is intentionally excluded from the efficiency framing.
431
+
432
+ **Compatibility**: No persisted-state schema changes. New optional config field `compress.maxVisibleSegments` (number, default `50`); old configs keep working.
433
+
434
+ ---
435
+
424
436
  ### v1.9.0 — Visible-Range Guidance & Compression Failure Recovery
425
437
 
426
438
  **Problem**: On large-context models (1M+) the model repeatedly called `compress(startId=m00930, endId=m00943)` against IDs that a prior block had already consumed. It had no stable view of which `mNNNNN` refs were still compressible, the failure error gave no recovery info, `acp_status` was registered but never mentioned in the prompt, and the suffix nudge reported a bare percentage with no indication of *where* the tokens were actually spent.
package/README.zh-CN.md CHANGED
@@ -393,6 +393,18 @@ ACP 在首次启动时自动将配置从 `dcp.jsonc` 迁移到 `acp.jsonc`,将
393
393
 
394
394
  ## 更新日志
395
395
 
396
+ ### v1.9.1 — 不相交可见范围段 & 提醒措辞修正(issue #9 根因)
397
+
398
+ **问题**:即便有了 v1.9.0,模型仍反复对已被先前块消费的 ID 调用 `compress`。根因是 suffix 一直广播一条"从首条可见到最后一条可见"的**跨越压缩空洞的连续 span** —— 模型对 `endId` 的第一反应往往是落在已经被摘要的范围里。此外,suffix 的 `(+X tokens since last nudge)` 增长行被误读为**溢出警告**,触发对"大但仍然需要"范围的恐慌性压缩。
399
+
400
+ **修复 1 — 不连续可见 ID 段**(PR #57):`injectVisibleIdRange` 不再输出一条"首到尾"span。改为按引用升序构建真正存活的不相交段,并在段数溢出时截断到最大的含工具 / 高 token 段(`compress.maxVisibleSegments`,默认 `50`,已通过 config defaults + merge + validation + schema 全链路接入)。suffix 现在形如 `[Visible (top 2 of 3 segments, 803 msgs): m00001–m00929, m00944–m00950 | +1 smaller segment (~1.2K tokens, 6 msgs) omitted]`,模型能精确看到哪些范围可压缩、绝不会被引导去打空洞。格式化逻辑抽取为纯函数并导出、可单测(`buildVisibleSegments`、`formatVisibleGuidance`)。
401
+
402
+ **修复 2 — 提醒措辞**(PR #58):增量压缩指引行(`💡 Compress incrementally: target the ranges above...`)移到 largest-ranges 列表**之后**,并改写以强调**仅凭大小不是压缩理由** —— 仍然需要完整保留的大范围必须保留。软性效率提醒(`growth` / `minLimit` 变体)现在前置一条明确说明 *"This is an efficiency nudge to compress early and keep context lean — not an overflow warning. A separate, stronger alert will appear if the context is actually full."*,使增长量不被误读为溢出警报。`maxLimit` 路径保留更强的告警,并有意排除在效率措辞之外。
403
+
404
+ **兼容性**:无持久化 state schema 变更。新增可选配置字段 `compress.maxVisibleSegments`(数字,默认 `50`);旧配置继续工作。
405
+
406
+ ---
407
+
396
408
  ### v1.9.0 — 可见范围引导 & 压缩失败恢复
397
409
 
398
410
  **问题**:在大上下文模型(1M+)上,模型反复调用 `compress(startId=m00930, endId=m00943)`,而这些 ID 已被之前的块消费。模型对哪些 `mNNNNN` 引用仍可压缩没有稳定视图,失败错误不提供恢复信息,`acp_status` 工具已注册但从未在提示中提及,suffix nudge 只报告一个裸百分比,完全不说明 token 实际花在了哪里。
package/dist/index.js CHANGED
@@ -903,6 +903,7 @@ var VALID_CONFIG_KEYS = /* @__PURE__ */ new Set([
903
903
  "compress.protectUserMessages",
904
904
  "compress.maxSummaryLengthHard",
905
905
  "compress.minCompressRange",
906
+ "compress.maxVisibleSegments",
906
907
  "gc",
907
908
  "gc.algorithm",
908
909
  "gc.promotionThreshold",
@@ -1184,6 +1185,20 @@ function validateConfigTypes(config) {
1184
1185
  actual: `${compress.minCompressRange}`
1185
1186
  });
1186
1187
  }
1188
+ if (compress.maxVisibleSegments !== void 0 && typeof compress.maxVisibleSegments !== "number") {
1189
+ errors.push({
1190
+ key: "compress.maxVisibleSegments",
1191
+ expected: "number",
1192
+ actual: typeof compress.maxVisibleSegments
1193
+ });
1194
+ }
1195
+ if (typeof compress.maxVisibleSegments === "number" && compress.maxVisibleSegments < 1) {
1196
+ errors.push({
1197
+ key: "compress.maxVisibleSegments",
1198
+ expected: "positive number (>= 1)",
1199
+ actual: `${compress.maxVisibleSegments}`
1200
+ });
1201
+ }
1187
1202
  if (typeof compress.iterationNudgeThreshold === "number" && compress.iterationNudgeThreshold < 1) {
1188
1203
  errors.push({
1189
1204
  key: "compress.iterationNudgeThreshold",
@@ -1498,7 +1513,8 @@ var defaultConfig = {
1498
1513
  protectTags: false,
1499
1514
  protectUserMessages: false,
1500
1515
  maxSummaryLengthHard: 1e4,
1501
- minCompressRange: 2e3
1516
+ minCompressRange: 2e3,
1517
+ maxVisibleSegments: 50
1502
1518
  },
1503
1519
  strategies: {
1504
1520
  deduplication: {
@@ -1653,7 +1669,8 @@ function mergeCompress(base, override) {
1653
1669
  protectTags: override.protectTags ?? base.protectTags,
1654
1670
  protectUserMessages: override.protectUserMessages ?? base.protectUserMessages,
1655
1671
  maxSummaryLengthHard: override.maxSummaryLengthHard ?? base.maxSummaryLengthHard,
1656
- minCompressRange: override.minCompressRange ?? base.minCompressRange
1672
+ minCompressRange: override.minCompressRange ?? base.minCompressRange,
1673
+ maxVisibleSegments: override.maxVisibleSegments ?? base.maxVisibleSegments
1657
1674
  };
1658
1675
  }
1659
1676
  function mergeCommands(base, override) {
@@ -6307,15 +6324,15 @@ var injectCompressNudges = (state, config, logger, messages, prompts, compressio
6307
6324
  const growth = currentTokens !== void 0 && state.nudges.lastPerMessageNudgeTokens !== void 0 ? currentTokens - state.nudges.lastPerMessageNudgeTokens : 0;
6308
6325
  const growthStr = growth > 0 ? ` (+${fmt(growth)} since last nudge)` : "";
6309
6326
  const plainTextTokens = composition.textTokens;
6310
- let breakdown = `
6327
+ const efficiencyNote = decision.tipsVariant !== "maxLimit" ? `
6328
+ This is an efficiency nudge to compress early and keep context lean \u2014 not an overflow warning. A separate, stronger alert will appear if the context is actually full.` : "";
6329
+ let breakdown = `${efficiencyNote}
6311
6330
  Breakdown: ${fmt(composition.toolTokens)} tool (${pct(composition.toolTokens)}%) | ${fmt(composition.summaryTokens)} summaries (${pct(composition.summaryTokens)}%) | ${fmt(composition.codeTokens)} code (${pct(composition.codeTokens)}%) | ${fmt(plainTextTokens)} text (${pct(plainTextTokens)}%)${growthStr}`;
6312
6331
  const topBlocks = Array.from(state.prune.messages.blocksById.values()).filter((b) => b.active).sort((a, b) => b.compressedTokens - a.compressedTokens).slice(0, 3);
6313
6332
  if (topBlocks.length > 0) {
6314
6333
  breakdown += `
6315
6334
  Top blocks: ${topBlocks.map((b) => `b${b.blockId} ${fmt(b.compressedTokens)}\u2192${fmt(b.summaryTokens)}`).join(", ")}`;
6316
6335
  }
6317
- breakdown += `
6318
- \u{1F4A1} Compress incrementally \u2014 compress the largest consumed ranges first.`;
6319
6336
  if (composition.largestToolRanges.length > 0) {
6320
6337
  breakdown += `
6321
6338
  Largest tool outputs: ${composition.largestToolRanges.map((r) => `${r.ref} (${fmt(r.tokens)})`).join(", ")}`;
@@ -6328,6 +6345,8 @@ Largest code messages: ${composition.largestCodeRanges.map((r) => `${r.ref} (${f
6328
6345
  breakdown += `
6329
6346
  Largest text messages: ${composition.largestMessageRanges.map((r) => `${r.ref} (${fmt(r.tokens)})`).join(", ")}`;
6330
6347
  }
6348
+ breakdown += `
6349
+ \u{1F4A1} Compress incrementally: target the ranges above whose content you have already extracted for this step. Size alone is not a reason to compress \u2014 if a large range is still needed in full, keep it.`;
6331
6350
  appendToLastTextPart(suffixMessage, breakdown);
6332
6351
  }
6333
6352
  if (decision.tipsVariant === "maxLimit") {
@@ -6352,7 +6371,7 @@ Largest text messages: ${composition.largestMessageRanges.map((r) => `${r.ref} (
6352
6371
  if (tipsText && suffixMessage) {
6353
6372
  appendToLastTextPart(suffixMessage, tipsText);
6354
6373
  }
6355
- injectVisibleIdRange(state, messages, suffixMessage);
6374
+ injectVisibleIdRange(state, config, messages, suffixMessage);
6356
6375
  }
6357
6376
  if (toolOutputReminder && suffixMessage) {
6358
6377
  if (!decision.shouldNudge) {
@@ -6393,22 +6412,78 @@ function injectContextUsage(target, config, currentTokens, modelContextLimit) {
6393
6412
  }
6394
6413
  target.parts.push(createSyntheticTextPart(target, usageTag));
6395
6414
  }
6396
- function injectVisibleIdRange(state, messages, target) {
6415
+ function refNumber(ref) {
6416
+ const n = parseInt(ref.slice(1), 10);
6417
+ return Number.isNaN(n) ? -1 : n;
6418
+ }
6419
+ function buildVisibleSegments(state, messages) {
6420
+ const refInfo = /* @__PURE__ */ new Map();
6421
+ for (const msg of messages) {
6422
+ const ref = state.messageIds.byRawId.get(msg.info.id);
6423
+ if (!ref) continue;
6424
+ let tokens = 0;
6425
+ let hasTool = false;
6426
+ for (const part of msg.parts || []) {
6427
+ if (part.type === "text" && typeof part.text === "string") {
6428
+ tokens += Math.round(part.text.length / 4);
6429
+ } else if (part.type !== "text" && part.type !== "reasoning") {
6430
+ tokens += Math.round(JSON.stringify(part).length / 4);
6431
+ hasTool = true;
6432
+ }
6433
+ }
6434
+ refInfo.set(ref, { tokens, hasTool });
6435
+ }
6436
+ if (refInfo.size === 0) return [];
6437
+ const refs = Array.from(refInfo.keys()).sort((a, b) => refNumber(a) - refNumber(b));
6438
+ const segments = [];
6439
+ let cur = null;
6440
+ let prevNum = -2;
6441
+ for (const ref of refs) {
6442
+ const num = refNumber(ref);
6443
+ const info = refInfo.get(ref);
6444
+ if (cur && num === prevNum + 1) {
6445
+ cur.endRef = ref;
6446
+ cur.count++;
6447
+ cur.tokens += info.tokens;
6448
+ if (info.hasTool) cur.hasTool = true;
6449
+ } else {
6450
+ if (cur) segments.push(cur);
6451
+ cur = { startRef: ref, endRef: ref, count: 1, tokens: info.tokens, hasTool: info.hasTool };
6452
+ }
6453
+ prevNum = num;
6454
+ }
6455
+ if (cur) segments.push(cur);
6456
+ return segments;
6457
+ }
6458
+ function formatSegment(seg) {
6459
+ return seg.startRef === seg.endRef ? seg.startRef : `${seg.startRef}\u2013${seg.endRef}`;
6460
+ }
6461
+ function formatVisibleGuidance(segments, maxSegs) {
6462
+ if (segments.length === 0) return "";
6463
+ const totalMsgs = segments.reduce((s, seg) => s + seg.count, 0);
6464
+ const totalSegs = segments.length;
6465
+ const fmt = (n) => n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n);
6466
+ if (totalSegs <= maxSegs) {
6467
+ return `[Visible: ${segments.map(formatSegment).join(", ")} (${totalMsgs} msg${totalMsgs === 1 ? "" : "s"}, ${totalSegs} segment${totalSegs === 1 ? "" : "s"})]`;
6468
+ }
6469
+ const keepSet = new Set(
6470
+ [...segments].sort((a, b) => {
6471
+ if (a.hasTool !== b.hasTool) return a.hasTool ? -1 : 1;
6472
+ return b.tokens - a.tokens;
6473
+ }).slice(0, maxSegs)
6474
+ );
6475
+ const shown = segments.filter((s) => keepSet.has(s));
6476
+ const omitted = segments.filter((s) => !keepSet.has(s));
6477
+ const omittedTokens = omitted.reduce((sum, s) => sum + s.tokens, 0);
6478
+ const omittedMsgs = omitted.reduce((sum, s) => sum + s.count, 0);
6479
+ return `[Visible (top ${shown.length} of ${totalSegs} segments, ${totalMsgs} msgs): ${shown.map(formatSegment).join(", ")} | +${omitted.length} smaller segment${omitted.length === 1 ? "" : "s"} (~${fmt(omittedTokens)} tokens, ${omittedMsgs} msg${omittedMsgs === 1 ? "" : "s"}) omitted]`;
6480
+ }
6481
+ function injectVisibleIdRange(state, config, messages, target) {
6397
6482
  if (!target) return;
6398
- const visibleRefs = [];
6399
- for (const message of messages) {
6400
- const ref = state.messageIds.byRawId.get(message.info.id);
6401
- if (ref) {
6402
- visibleRefs.push(ref);
6403
- }
6404
- }
6405
- if (visibleRefs.length === 0) return;
6406
- visibleRefs.sort();
6407
- const first = visibleRefs[0];
6408
- const last = visibleRefs[visibleRefs.length - 1];
6409
- const rangeTag = `
6410
-
6411
- [Visible messages: ${first} to ${last} (${visibleRefs.length} messages)]`;
6483
+ const segments = buildVisibleSegments(state, messages);
6484
+ if (segments.length === 0) return;
6485
+ const maxSegs = config.compress?.maxVisibleSegments ?? 50;
6486
+ const rangeTag = "\n\n" + formatVisibleGuidance(segments, maxSegs);
6412
6487
  for (const part of target.parts) {
6413
6488
  if (part.type === "text") {
6414
6489
  appendToTextPart(part, rangeTag);