opencode-acp 1.4.1 → 1.5.0

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
@@ -389,7 +389,7 @@ ACP auto-migrates config from `dcp.jsonc` to `acp.jsonc` and prompts from `dcp-p
389
389
  ---
390
390
 
391
391
  <details>
392
- <summary><strong>Bug Fixes (37 total)</strong> -- applied on top of DCP v3.1.11</summary>
392
+ <summary><strong>Bug Fixes (38 total)</strong> -- applied on top of DCP v3.1.11</summary>
393
393
 
394
394
  | # | Severity | Summary |
395
395
  |---|----------|---------|
@@ -419,6 +419,7 @@ ACP auto-migrates config from `dcp.jsonc` to `acp.jsonc` and prompts from `dcp-p
419
419
  | 35 | HIGH | Aging warnings shown at low context usage (<50%) -- triggers unnecessary compress, wastes tokens |
420
420
  | 36 | HIGH | Compression summary emitted as a standalone user message before the user's real turn -- model reads its own prior assistant output as user input, causing dialog role confusion / self-Q&A loops |
421
421
  | 37 | HIGH | Message-transform pipeline runs on OpenCode's hidden title/summary/compaction agent requests -- corrupts the request and shared session state, breaking session title generation |
422
+ | 38 | CRITICAL | pruneToolOutputs/pruneToolInputs/pruneToolErrors mutate existing messages in-place -- invalidates LLM prefix cache, causing 89% of fresh input tokens to be wasted on cache-invalidating re-sends |
422
423
 
423
424
  For the complete list with root cause analysis, see the [bug tracker](https://github.com/ranxianglei/opencode-acp/issues).
424
425
 
package/dist/index.js CHANGED
@@ -1485,7 +1485,7 @@ var defaultConfig = {
1485
1485
  maxOldGenSummaryLength: 3e3,
1486
1486
  majorGcThresholdPercent: "100%",
1487
1487
  batchCleanup: {
1488
- lowThreshold: "60%",
1488
+ lowThreshold: "55%",
1489
1489
  highThreshold: "75%",
1490
1490
  forceThreshold: "90%"
1491
1491
  }
@@ -4958,89 +4958,8 @@ var stripHallucinations = (messages) => {
4958
4958
  };
4959
4959
 
4960
4960
  // lib/messages/prune.ts
4961
- var PRUNED_TOOL_OUTPUT_REPLACEMENT = "[Output removed to save context - information superseded or no longer needed]";
4962
- var PRUNED_TOOL_ERROR_INPUT_REPLACEMENT = "[input removed due to failed tool call]";
4963
- var PRUNED_QUESTION_INPUT_REPLACEMENT = "[questions removed - see output for user's answers]";
4964
4961
  var prune = (state, logger, config, messages) => {
4965
4962
  filterCompressedRanges(state, logger, config, messages);
4966
- pruneToolOutputs(state, logger, messages);
4967
- pruneToolInputs(state, logger, messages);
4968
- pruneToolErrors(state, logger, messages);
4969
- };
4970
- var pruneToolOutputs = (state, logger, messages) => {
4971
- for (const msg of messages) {
4972
- if (isMessageCompacted(state, msg)) {
4973
- continue;
4974
- }
4975
- const parts = Array.isArray(msg.parts) ? msg.parts : [];
4976
- for (const part of parts) {
4977
- if (part.type !== "tool") {
4978
- continue;
4979
- }
4980
- if (!state.prune.tools.has(part.callID)) {
4981
- continue;
4982
- }
4983
- if (part.state.status !== "completed") {
4984
- continue;
4985
- }
4986
- if (part.tool === "question" || part.tool === "edit" || part.tool === "write") {
4987
- continue;
4988
- }
4989
- part.state.output = PRUNED_TOOL_OUTPUT_REPLACEMENT;
4990
- }
4991
- }
4992
- };
4993
- var pruneToolInputs = (state, logger, messages) => {
4994
- for (const msg of messages) {
4995
- if (isMessageCompacted(state, msg)) {
4996
- continue;
4997
- }
4998
- const parts = Array.isArray(msg.parts) ? msg.parts : [];
4999
- for (const part of parts) {
5000
- if (part.type !== "tool") {
5001
- continue;
5002
- }
5003
- if (!state.prune.tools.has(part.callID)) {
5004
- continue;
5005
- }
5006
- if (part.state.status !== "completed") {
5007
- continue;
5008
- }
5009
- if (part.tool !== "question") {
5010
- continue;
5011
- }
5012
- if (part.state.input?.questions !== void 0) {
5013
- part.state.input.questions = PRUNED_QUESTION_INPUT_REPLACEMENT;
5014
- }
5015
- }
5016
- }
5017
- };
5018
- var pruneToolErrors = (state, logger, messages) => {
5019
- for (const msg of messages) {
5020
- if (isMessageCompacted(state, msg)) {
5021
- continue;
5022
- }
5023
- const parts = Array.isArray(msg.parts) ? msg.parts : [];
5024
- for (const part of parts) {
5025
- if (part.type !== "tool") {
5026
- continue;
5027
- }
5028
- if (!state.prune.tools.has(part.callID)) {
5029
- continue;
5030
- }
5031
- if (part.state.status !== "error") {
5032
- continue;
5033
- }
5034
- const input = part.state.input;
5035
- if (input && typeof input === "object") {
5036
- for (const key of Object.keys(input)) {
5037
- if (typeof input[key] === "string") {
5038
- input[key] = PRUNED_TOOL_ERROR_INPUT_REPLACEMENT;
5039
- }
5040
- }
5041
- }
5042
- }
5043
- }
5044
4963
  };
5045
4964
  var filterCompressedRanges = (state, logger, config, messages) => {
5046
4965
  if (state.prune.messages.byMessageId.size === 0 && state.prune.messages.activeByAnchorMessageId.size === 0) {
@@ -5299,11 +5218,18 @@ function buildCompressedBlockGuidance(state, gcConfig, context) {
5299
5218
  const activeBlockIds = Array.from(state.prune.messages.activeBlockIds).filter((id) => Number.isInteger(id) && id > 0).sort((a, b) => a - b);
5300
5219
  const refs = activeBlockIds.map((id) => `b${id}`);
5301
5220
  const blockCount = refs.length;
5302
- const blockList = blockCount > 0 ? refs.join(", ") : "none";
5221
+ let blockList;
5222
+ if (blockCount <= 20) {
5223
+ blockList = blockCount > 0 ? refs.join(", ") : "none";
5224
+ } else {
5225
+ const recent = refs.slice(-20).join(", ");
5226
+ blockList = `${recent} (+${blockCount - 20} older, use decompress to access by ID)`;
5227
+ }
5303
5228
  const lines = [
5304
5229
  "Compressed block context:",
5305
5230
  `- Active compressed blocks: ${blockCount} (${blockList})`,
5306
- "- If your selected compression range includes any listed block, include each required placeholder exactly once in the summary using `(bN)`."
5231
+ "- If your selected compression range includes any listed block, include each required placeholder exactly once in the summary using `(bN)`.",
5232
+ "- \u{1F4A1} When you've finished using tool outputs, compress them \u2014 you can decompress later if needed. Lean context improves accuracy."
5307
5233
  ];
5308
5234
  const usageRatio = context?.currentTokens && context?.modelContextLimit ? context.currentTokens / context.modelContextLimit : 0;
5309
5235
  if (gcConfig && usageRatio > 0.5) {
@@ -5656,14 +5582,14 @@ function buildContextUsageGuidance(config, currentTokens, modelContextLimit) {
5656
5582
  const formatK = (n) => n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n);
5657
5583
  const minPct = resolveThresholdPercent(config.compress.minContextLimit, modelContextLimit) ?? 45;
5658
5584
  const maxPct = resolveThresholdPercent(config.compress.maxContextLimit, modelContextLimit) ?? 55;
5659
- const base = `Context usage: ${formatK(currentTokens)} / ${formatK(modelContextLimit)} tokens (${percentage}%). ACP threshold: ${maxPct.toFixed(0)}%.`;
5585
+ const base = `Context usage: ${formatK(currentTokens)} / ${formatK(modelContextLimit)} tokens (${percentage}%).`;
5660
5586
  let guidance;
5661
5587
  if (pct < minPct) {
5662
- guidance = " Context is ample \u2014 focus on your task. Only compress obvious waste (large terminal outputs, duplicated content).";
5588
+ guidance = " \u{1F4A1} Be frugal with context \u2014 compress tool outputs you've finished using into summaries. You can decompress later; nothing is permanently lost. Lean context means better accuracy. Extract and keep what matters: user intent, key decisions, file paths, and important findings \u2014 even if buried in large messages. Compress everything else, including verbose parts of any message.";
5663
5589
  } else if (pct < maxPct) {
5664
- guidance = " Context is moderate \u2014 compress completed sections and high-token waste. Preserve key details.";
5590
+ guidance = " \u26A0\uFE0F Context is growing \u2014 compress completed sections and high-token waste now. Preserve key details.";
5665
5591
  } else {
5666
- guidance = " Context is high \u2014 compress aggressively but selectively. Preserve only what is essential.";
5592
+ guidance = " \u{1F525} Context is high \u2014 compress aggressively but selectively. Preserve only what is essential.";
5667
5593
  }
5668
5594
  return `
5669
5595
 
@@ -6709,15 +6635,15 @@ COMPRESSION PHILOSOPHY
6709
6635
 
6710
6636
  Compression replaces raw conversation content with dense summaries. When used correctly, it keeps your context sharp and focused. When used carelessly, it destroys information you need.
6711
6637
 
6712
- The key principle: compress based on context pressure, not habit. When context is ample, compress rarely or not at all. When context is tight, compress aggressively but selectively. The runtime context usage indicator tells you the current pressure level.
6638
+ The key principle: compress proactively to keep context lean, but selectively. Large tool outputs (shell, diffs, logs) can be compressed into summaries at any time \u2014 you can decompress later if needed. Extract and keep what matters: user intent, key decisions, file paths, and important findings \u2014 even if buried in large messages. Compress everything else, including verbose parts of user messages, large code dumps, and long discussions.
6713
6639
 
6714
6640
  Target the largest UNCOMPRESSED content first. Savings scale with original size \u2014 compressing a 5000-token tool output frees far more than re-shrinking an already-summarized 300-token block.
6715
6641
 
6716
6642
  CONTEXT PRESSURE LEVELS
6717
6643
 
6718
- - Ample: Context is well below the threshold. Do NOT compress unless there is obvious waste (huge terminal dumps, duplicated content). Focus entirely on your task.
6719
- - Moderate: Context is approaching the threshold. Compress completed sections proactively. Prioritize high-token waste over minor cleanup.
6720
- - High: Context has exceeded the threshold. Compress aggressively. Every compression should free meaningful tokens. Preserve only what is essential for the current task.
6644
+ - Normal: Be frugal \u2014 compress tool outputs you've finished using into summaries. You can decompress later. Extract and keep what matters from any message; compress verbose parts \u2014 including large logs in user messages or generated code.
6645
+ - Elevated: Context is growing. Compress completed sections and high-token waste more urgently.
6646
+ - Critical: Compress aggressively now. Every compression should free meaningful tokens. Preserve only what is essential for the current task.
6721
6647
 
6722
6648
  WHAT TO COMPRESS FIRST (high value, low risk)
6723
6649
 
@@ -6879,9 +6805,9 @@ General cleanup should be done periodically between other normal compression too
6879
6805
  // lib/prompts/context-limit-nudge.ts
6880
6806
  var CONTEXT_LIMIT_NUDGE = `
6881
6807
  <system-reminder>
6882
- \u26A0\uFE0F CRITICAL: Context limit reached. You MUST use the \`compress\` tool NOW.
6808
+ \u26A0\uFE0F Context limit reached \u2014 time to compress the largest ranges you no longer need. Prioritize completed tool outputs and resolved work. You can decompress specific blocks later if you need details. Keeping context lean helps you stay accurate.
6883
6809
 
6884
- If mid-atomic-operation, finish that step first, then compress immediately.
6810
+ If mid-atomic-operation, finish that step first, then compress.
6885
6811
 
6886
6812
  HOW TO CALL COMPRESS:
6887
6813
  {
@@ -6896,7 +6822,7 @@ HOW TO CALL COMPRESS:
6896
6822
  }
6897
6823
 
6898
6824
  \u26A0\uFE0F ID RULES \u2014 MOST COMMON CAUSE OF ERRORS:
6899
- - ONLY use IDs you can see in <dcp-message-id> tags in the messages ABOVE.
6825
+ - ONLY use IDs you can see in tags in the messages ABOVE.
6900
6826
  - Do NOT copy IDs from this example. Do NOT invent IDs.
6901
6827
  - Do NOT use IDs from compressed block summaries \u2014 they are stale.
6902
6828
  - startId must appear BEFORE endId in the conversation.
@@ -6912,14 +6838,14 @@ SUMMARY RULES:
6912
6838
  // lib/prompts/turn-nudge.ts
6913
6839
  var TURN_NUDGE = `
6914
6840
  <system-reminder>
6915
- Context is getting full. Compress closed/older conversation ranges now.
6841
+ Context is getting full. If you've finished reading tool outputs or exploration results, compress them \u2014 you can decompress later if needed. This keeps your focus on the current task and improves accuracy.
6916
6842
 
6917
6843
  {
6918
6844
  "topic": "Short Label",
6919
6845
  "content": [{ "startId": "<visible message ID>", "endId": "<visible message ID>", "summary": "..." }]
6920
6846
  }
6921
6847
 
6922
- \u26A0\uFE0F ONLY use IDs from <dcp-message-id> tags visible above. Do NOT invent or copy example IDs.
6848
+ \u26A0\uFE0F ONLY use IDs from tags visible above. Do NOT invent or copy example IDs.
6923
6849
  </system-reminder>
6924
6850
  `;
6925
6851
 
@@ -8293,10 +8219,12 @@ function parseGcThreshold(limit, modelContextLimit) {
8293
8219
 
8294
8220
  // lib/gc/merge.ts
8295
8221
  var DEFAULT_BATCH_CLEANUP = {
8296
- lowThreshold: "60%",
8222
+ lowThreshold: "55%",
8297
8223
  highThreshold: "75%",
8298
8224
  forceThreshold: "90%"
8299
8225
  };
8226
+ var ESCALATE_MIN_MARKED = 3;
8227
+ var ESCALATE_MIN_RATIO = 0.4;
8300
8228
  function resolveBatchCleanup(gc) {
8301
8229
  return gc.batchCleanup ?? DEFAULT_BATCH_CLEANUP;
8302
8230
  }
@@ -8320,11 +8248,15 @@ function collectActiveOldGenBlocks(state, maxOldGenSummaryLength) {
8320
8248
  return blocks;
8321
8249
  }
8322
8250
  function collectActiveMarkedBlocks(state) {
8323
- const ids = Array.from(state.prune.messages.markedForCleanup).sort((a, b) => a - b);
8251
+ const messagesState = state.prune.messages;
8252
+ const ids = Array.from(messagesState.markedForCleanup).sort((a, b) => a - b);
8324
8253
  const blocks = [];
8325
8254
  for (const id of ids) {
8326
- const block = state.prune.messages.blocksById.get(id);
8327
- if (!block || !block.active) continue;
8255
+ const block = messagesState.blocksById.get(id);
8256
+ if (!block || !block.active) {
8257
+ messagesState.markedForCleanup.delete(id);
8258
+ continue;
8259
+ }
8328
8260
  blocks.push(block);
8329
8261
  }
8330
8262
  return blocks;
@@ -8449,21 +8381,53 @@ function mergeMarkedBlocks(state, markedIds, maxMergedLength) {
8449
8381
  const savedTokens = Math.max(0, sourceTokens - newSummaryTokens);
8450
8382
  return { mergedCount: sourceBlocks.length, savedTokens };
8451
8383
  }
8452
- function buildNudgeText(state, maxMergedLength) {
8453
- const blocks = collectActiveMarkedBlocks(state);
8454
- if (blocks.length < 1) return void 0;
8455
- const refs = blocks.map((b) => formatBlockRef(b.blockId)).join(", ");
8456
- const sourceTokens = blocks.reduce(
8384
+ function estimateTokens(blocks) {
8385
+ return blocks.reduce(
8457
8386
  (sum, block) => sum + (block.summaryTokens || Math.round(block.summary.length / 4)),
8458
8387
  0
8459
8388
  );
8460
- const estimatedMergedTokens = Math.round(maxMergedLength / 4);
8461
- const estimatedSavings = Math.max(0, sourceTokens - estimatedMergedTokens);
8389
+ }
8390
+ function buildNudgeText(state, maxMergedLength) {
8391
+ const marked = collectActiveMarkedBlocks(state);
8392
+ const oldGen = collectActiveOldGenBlocks(state, maxMergedLength);
8393
+ if (oldGen.length === 0) return void 0;
8394
+ const oldGenIds = new Set(oldGen.map((b) => b.blockId));
8395
+ const markedOldGen = marked.filter((b) => oldGenIds.has(b.blockId));
8396
+ const markedOldGenCount = markedOldGen.length;
8397
+ const oldGenCount = oldGen.length;
8398
+ const ratio = markedOldGenCount / oldGenCount;
8399
+ const ratioPct = Math.round(ratio * 100);
8400
+ const escalateMinPct = Math.round(ESCALATE_MIN_RATIO * 100);
8401
+ if (markedOldGenCount >= ESCALATE_MIN_MARKED && ratio >= ESCALATE_MIN_RATIO) {
8402
+ const refs = marked.map((b) => formatBlockRef(b.blockId)).join(", ");
8403
+ const firstRef = formatBlockRef(marked[0].blockId);
8404
+ const lastRef = formatBlockRef(marked[marked.length - 1].blockId);
8405
+ const estimatedSavings = Math.max(0, estimateTokens(marked) - Math.round(maxMergedLength / 4));
8406
+ return [
8407
+ `\u{1F525} ${markedOldGenCount}/${oldGenCount} old-gen blocks marked (${ratioPct}%) \u2014 ready for batch cleanup.`,
8408
+ `Compressing ${refs} (range ${firstRef}\u2013${lastRef}) would free ~${estimatedSavings} tokens in one cache break.`,
8409
+ `Call compress with this range now to consolidate them.`
8410
+ ].join(" ");
8411
+ }
8412
+ if (marked.length >= 1) {
8413
+ const refs = marked.map((b) => formatBlockRef(b.blockId)).join(", ");
8414
+ const estimatedSavings = Math.max(0, estimateTokens(marked) - Math.round(maxMergedLength / 4));
8415
+ return [
8416
+ `\u26A0\uFE0F ${marked.length} block(s) marked for batch cleanup (${refs}).`,
8417
+ `Merge-compressing them would free ~${estimatedSavings} tokens.`,
8418
+ marked.length >= 2 ? "They will auto-merge when context pressure reaches the high threshold." : "A single marked block won't auto-merge on its own \u2014 use compress to consolidate it, or unmark_block if no longer needed.",
8419
+ `Mark more old-gen blocks (need \u2265${ESCALATE_MIN_MARKED} at \u2265${escalateMinPct}%) to trigger batch cleanup sooner.`,
8420
+ "To act now, use compress with a range covering these blocks."
8421
+ ].join(" ");
8422
+ }
8423
+ const shown = oldGen.slice(0, 5);
8424
+ const oldGenRefs = shown.map((b) => formatBlockRef(b.blockId)).join(", ");
8425
+ const more = oldGenCount > 5 ? ` (+${oldGenCount - 5} more)` : "";
8462
8426
  return [
8463
- `\u26A0\uFE0F ${blocks.length} block(s) marked for batch cleanup (${refs}).`,
8464
- `Merge-compressing them would free ~${estimatedSavings} tokens.`,
8465
- blocks.length >= 2 ? "They will auto-merge when context pressure reaches the high threshold." : "A single marked block won't auto-merge on its own \u2014 use compress to consolidate it, or unmark_block if no longer needed.",
8466
- "To act now, use compress with a range covering these blocks."
8427
+ `\u{1F4CB} Context pressure rising \u2014 ${oldGenCount} old-gen compressed block(s) occupy ~${estimateTokens(oldGen)} tokens (${oldGenRefs}${more}).`,
8428
+ `Review which blocks contain information you no longer need, and use mark_block to flag them.`,
8429
+ `Once enough are marked (\u2265${ESCALATE_MIN_MARKED} at \u2265${escalateMinPct}% of old-gen), they'll be batch-merged in one cache break to preserve cache hit rate.`,
8430
+ `Do NOT mark blocks you may still need.`
8467
8431
  ].join(" ");
8468
8432
  }
8469
8433
  function runBatchCleanup(state, config, logger, messages) {
@@ -8508,26 +8472,24 @@ function runBatchCleanup(state, config, logger, messages) {
8508
8472
  }
8509
8473
  if (currentTokens >= highTokens) {
8510
8474
  const marked = collectActiveMarkedBlocks(state);
8511
- if (marked.length < 2) {
8512
- return noop;
8513
- }
8514
- const ids = marked.map((b) => b.blockId);
8515
- const result = mergeMarkedBlocks(state, ids, maxMergedLength);
8516
- if (result.mergedCount === 0) {
8517
- return noop;
8475
+ if (marked.length >= 2) {
8476
+ const ids = marked.map((b) => b.blockId);
8477
+ const result = mergeMarkedBlocks(state, ids, maxMergedLength);
8478
+ if (result.mergedCount > 0) {
8479
+ logger.info("Batch cleanup tier 2 (high): merged marked blocks", {
8480
+ mergedCount: result.mergedCount,
8481
+ savedTokens: result.savedTokens,
8482
+ currentTokens,
8483
+ highThreshold: batchCleanup.highThreshold
8484
+ });
8485
+ return {
8486
+ tier: 2,
8487
+ action: "merge",
8488
+ mergedCount: result.mergedCount,
8489
+ savedTokens: result.savedTokens
8490
+ };
8491
+ }
8518
8492
  }
8519
- logger.info("Batch cleanup tier 2 (high): merged marked blocks", {
8520
- mergedCount: result.mergedCount,
8521
- savedTokens: result.savedTokens,
8522
- currentTokens,
8523
- highThreshold: batchCleanup.highThreshold
8524
- });
8525
- return {
8526
- tier: 2,
8527
- action: "merge",
8528
- mergedCount: result.mergedCount,
8529
- savedTokens: result.savedTokens
8530
- };
8531
8493
  }
8532
8494
  if (currentTokens >= lowTokens) {
8533
8495
  const nudgeText = buildNudgeText(state, maxMergedLength);