opencode-acp 1.3.1 → 1.4.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/dist/index.js CHANGED
@@ -905,6 +905,10 @@ var VALID_CONFIG_KEYS = /* @__PURE__ */ new Set([
905
905
  "gc.maxBlockAge",
906
906
  "gc.maxOldGenSummaryLength",
907
907
  "gc.majorGcThresholdPercent",
908
+ "gc.batchCleanup",
909
+ "gc.batchCleanup.lowThreshold",
910
+ "gc.batchCleanup.highThreshold",
911
+ "gc.batchCleanup.forceThreshold",
908
912
  "strategies",
909
913
  "strategies.deduplication",
910
914
  "strategies.deduplication.enabled",
@@ -1263,6 +1267,36 @@ function validateConfigTypes(config) {
1263
1267
  });
1264
1268
  }
1265
1269
  }
1270
+ const validateBatchThreshold = (key, value) => {
1271
+ const isValidNumber = typeof value === "number";
1272
+ const isPercentString = typeof value === "string" && /^\d+(?:\.\d+)?%$/.test(value);
1273
+ if (!isValidNumber && !isPercentString) {
1274
+ errors.push({
1275
+ key,
1276
+ expected: 'number | "${number}%"',
1277
+ actual: JSON.stringify(value)
1278
+ });
1279
+ }
1280
+ };
1281
+ if (gc.batchCleanup !== void 0) {
1282
+ if (typeof gc.batchCleanup !== "object" || gc.batchCleanup === null || Array.isArray(gc.batchCleanup)) {
1283
+ errors.push({
1284
+ key: "gc.batchCleanup",
1285
+ expected: "object",
1286
+ actual: typeof gc.batchCleanup
1287
+ });
1288
+ } else {
1289
+ if (gc.batchCleanup.lowThreshold !== void 0) {
1290
+ validateBatchThreshold("gc.batchCleanup.lowThreshold", gc.batchCleanup.lowThreshold);
1291
+ }
1292
+ if (gc.batchCleanup.highThreshold !== void 0) {
1293
+ validateBatchThreshold("gc.batchCleanup.highThreshold", gc.batchCleanup.highThreshold);
1294
+ }
1295
+ if (gc.batchCleanup.forceThreshold !== void 0) {
1296
+ validateBatchThreshold("gc.batchCleanup.forceThreshold", gc.batchCleanup.forceThreshold);
1297
+ }
1298
+ }
1299
+ }
1266
1300
  }
1267
1301
  }
1268
1302
  const strategies = config.strategies;
@@ -1351,6 +1385,8 @@ var DEFAULT_PROTECTED_TOOLS = [
1351
1385
  "todoread",
1352
1386
  "compress",
1353
1387
  "decompress",
1388
+ "mark_block",
1389
+ "unmark_block",
1354
1390
  "batch",
1355
1391
  "plan_enter",
1356
1392
  "plan_exit",
@@ -1447,7 +1483,12 @@ var defaultConfig = {
1447
1483
  promotionThreshold: 5,
1448
1484
  maxBlockAge: 15,
1449
1485
  maxOldGenSummaryLength: 3e3,
1450
- majorGcThresholdPercent: "100%"
1486
+ majorGcThresholdPercent: "100%",
1487
+ batchCleanup: {
1488
+ lowThreshold: "60%",
1489
+ highThreshold: "75%",
1490
+ forceThreshold: "90%"
1491
+ }
1451
1492
  }
1452
1493
  };
1453
1494
  var GLOBAL_CONFIG_DIR = process.env.XDG_CONFIG_HOME ? join(process.env.XDG_CONFIG_HOME, "opencode") : join(homedir(), ".config", "opencode");
@@ -1507,7 +1548,7 @@ function createDefaultConfig() {
1507
1548
  console.log("[ACP] Migrated config from dcp.json to acp.jsonc");
1508
1549
  } else {
1509
1550
  const configContent = `{
1510
- "$schema": "https://raw.githubusercontent.com/Opencode-DCP/opencode-dynamic-context-pruning/master/dcp.schema.json"
1551
+ "$schema": "https://raw.githubusercontent.com/ranxianglei/opencode-acp/master/dcp.schema.json"
1511
1552
  }
1512
1553
  `;
1513
1554
  writeFileSync(GLOBAL_CONFIG_PATH_JSONC, configContent, "utf-8");
@@ -1631,7 +1672,20 @@ function deepCloneConfig(config) {
1631
1672
  protectedTools: [...config.strategies.purgeErrors.protectedTools]
1632
1673
  }
1633
1674
  },
1634
- gc: { ...config.gc }
1675
+ gc: {
1676
+ ...config.gc,
1677
+ batchCleanup: { ...config.gc.batchCleanup }
1678
+ }
1679
+ };
1680
+ }
1681
+ function mergeGC(base, override) {
1682
+ if (!override) {
1683
+ return base;
1684
+ }
1685
+ return {
1686
+ ...base,
1687
+ ...override,
1688
+ batchCleanup: { ...base.batchCleanup, ...override.batchCleanup ?? {} }
1635
1689
  };
1636
1690
  }
1637
1691
  function mergeLayer(config, data) {
@@ -1652,7 +1706,7 @@ function mergeLayer(config, data) {
1652
1706
  .../* @__PURE__ */ new Set([...config.protectedFilePatterns, ...data.protectedFilePatterns ?? []])
1653
1707
  ],
1654
1708
  compress: mergeCompress(config.compress, data.compress),
1655
- gc: { ...config.gc, ...data.gc },
1709
+ gc: mergeGC(config.gc, data.gc),
1656
1710
  strategies: mergeStrategies(config.strategies, data.strategies)
1657
1711
  };
1658
1712
  }
@@ -2747,7 +2801,8 @@ function serializePruneMessagesState(messagesState) {
2747
2801
  activeBlockIds: Array.from(messagesState.activeBlockIds),
2748
2802
  activeByAnchorMessageId: Object.fromEntries(messagesState.activeByAnchorMessageId),
2749
2803
  nextBlockId: messagesState.nextBlockId,
2750
- nextRunId: messagesState.nextRunId
2804
+ nextRunId: messagesState.nextRunId,
2805
+ markedForCleanup: Array.from(messagesState.markedForCleanup)
2751
2806
  };
2752
2807
  }
2753
2808
  async function isSubAgentSession(client, sessionID) {
@@ -2804,7 +2859,8 @@ function createPruneMessagesState() {
2804
2859
  activeBlockIds: /* @__PURE__ */ new Set(),
2805
2860
  activeByAnchorMessageId: /* @__PURE__ */ new Map(),
2806
2861
  nextBlockId: 1,
2807
- nextRunId: 1
2862
+ nextRunId: 1,
2863
+ markedForCleanup: /* @__PURE__ */ new Set()
2808
2864
  };
2809
2865
  }
2810
2866
  function loadPruneMessagesState(persisted) {
@@ -2915,6 +2971,13 @@ function loadPruneMessagesState(persisted) {
2915
2971
  state.nextRunId = block.runId + 1;
2916
2972
  }
2917
2973
  }
2974
+ if (Array.isArray(persisted.markedForCleanup)) {
2975
+ for (const id of persisted.markedForCleanup) {
2976
+ if (Number.isInteger(id) && id > 0 && state.blocksById.has(id)) {
2977
+ state.markedForCleanup.add(id);
2978
+ }
2979
+ }
2980
+ }
2918
2981
  return state;
2919
2982
  }
2920
2983
  function collectTurnNudgeAnchors(messages) {
@@ -3467,20 +3530,20 @@ function matchesGlob(inputPath, pattern) {
3467
3530
  regex += "$";
3468
3531
  return new RegExp(regex).test(input);
3469
3532
  }
3470
- function getFilePathsFromParameters(tool4, parameters) {
3533
+ function getFilePathsFromParameters(tool5, parameters) {
3471
3534
  if (typeof parameters !== "object" || parameters === null) {
3472
3535
  return [];
3473
3536
  }
3474
3537
  const paths = [];
3475
3538
  const params = parameters;
3476
- if (tool4 === "apply_patch" && typeof params.patchText === "string") {
3539
+ if (tool5 === "apply_patch" && typeof params.patchText === "string") {
3477
3540
  const pathRegex = /\*\*\* (?:Add|Delete|Update) File: ([^\n\r]+)/g;
3478
3541
  let match;
3479
3542
  while ((match = pathRegex.exec(params.patchText)) !== null) {
3480
3543
  paths.push(match[1].trim());
3481
3544
  }
3482
3545
  }
3483
- if (tool4 === "multiedit") {
3546
+ if (tool5 === "multiedit") {
3484
3547
  if (typeof params.filePath === "string") {
3485
3548
  paths.push(params.filePath);
3486
3549
  }
@@ -3575,13 +3638,13 @@ var deduplicate = (state, logger, config, messages) => {
3575
3638
  logger.debug(`Marked ${newPruneIds.length} duplicate tool calls for pruning`);
3576
3639
  }
3577
3640
  };
3578
- function createToolSignature(tool4, parameters) {
3641
+ function createToolSignature(tool5, parameters) {
3579
3642
  if (!parameters) {
3580
- return tool4;
3643
+ return tool5;
3581
3644
  }
3582
3645
  const normalized = normalizeParameters(parameters);
3583
3646
  const sorted = sortObjectKeys(normalized);
3584
- return `${tool4}::${JSON.stringify(sorted)}`;
3647
+ return `${tool5}::${JSON.stringify(sorted)}`;
3585
3648
  }
3586
3649
  function normalizeParameters(params) {
3587
3650
  if (typeof params !== "object" || params === null) return params;
@@ -3656,9 +3719,9 @@ var purgeErrors = (state, logger, config, messages) => {
3656
3719
  };
3657
3720
 
3658
3721
  // lib/ui/utils.ts
3659
- function extractParameterKey(tool4, parameters) {
3722
+ function extractParameterKey(tool5, parameters) {
3660
3723
  if (!parameters) return "";
3661
- if (tool4 === "read" && parameters.filePath) {
3724
+ if (tool5 === "read" && parameters.filePath) {
3662
3725
  const offset = parameters.offset;
3663
3726
  const limit = parameters.limit;
3664
3727
  if (offset !== void 0 && limit !== void 0) {
@@ -3672,10 +3735,10 @@ function extractParameterKey(tool4, parameters) {
3672
3735
  }
3673
3736
  return parameters.filePath;
3674
3737
  }
3675
- if ((tool4 === "write" || tool4 === "edit" || tool4 === "multiedit") && parameters.filePath) {
3738
+ if ((tool5 === "write" || tool5 === "edit" || tool5 === "multiedit") && parameters.filePath) {
3676
3739
  return parameters.filePath;
3677
3740
  }
3678
- if (tool4 === "apply_patch" && typeof parameters.patchText === "string") {
3741
+ if (tool5 === "apply_patch" && typeof parameters.patchText === "string") {
3679
3742
  const pathRegex = /\*\*\* (?:Add|Delete|Update) File: ([^\n\r]+)/g;
3680
3743
  const paths = [];
3681
3744
  let match;
@@ -3692,51 +3755,51 @@ function extractParameterKey(tool4, parameters) {
3692
3755
  }
3693
3756
  return "patch";
3694
3757
  }
3695
- if (tool4 === "list") {
3758
+ if (tool5 === "list") {
3696
3759
  return parameters.path || "(current directory)";
3697
3760
  }
3698
- if (tool4 === "glob") {
3761
+ if (tool5 === "glob") {
3699
3762
  if (parameters.pattern) {
3700
3763
  const pathInfo = parameters.path ? ` in ${parameters.path}` : "";
3701
3764
  return `"${parameters.pattern}"${pathInfo}`;
3702
3765
  }
3703
3766
  return "(unknown pattern)";
3704
3767
  }
3705
- if (tool4 === "grep") {
3768
+ if (tool5 === "grep") {
3706
3769
  if (parameters.pattern) {
3707
3770
  const pathInfo = parameters.path ? ` in ${parameters.path}` : "";
3708
3771
  return `"${parameters.pattern}"${pathInfo}`;
3709
3772
  }
3710
3773
  return "(unknown pattern)";
3711
3774
  }
3712
- if (tool4 === "bash") {
3775
+ if (tool5 === "bash") {
3713
3776
  if (parameters.description) return parameters.description;
3714
3777
  if (parameters.command) {
3715
3778
  return parameters.command.length > 50 ? parameters.command.substring(0, 50) + "..." : parameters.command;
3716
3779
  }
3717
3780
  }
3718
- if (tool4 === "webfetch" && parameters.url) {
3781
+ if (tool5 === "webfetch" && parameters.url) {
3719
3782
  return parameters.url;
3720
3783
  }
3721
- if (tool4 === "websearch" && parameters.query) {
3784
+ if (tool5 === "websearch" && parameters.query) {
3722
3785
  return `"${parameters.query}"`;
3723
3786
  }
3724
- if (tool4 === "codesearch" && parameters.query) {
3787
+ if (tool5 === "codesearch" && parameters.query) {
3725
3788
  return `"${parameters.query}"`;
3726
3789
  }
3727
- if (tool4 === "todowrite") {
3790
+ if (tool5 === "todowrite") {
3728
3791
  return `${parameters.todos?.length || 0} todos`;
3729
3792
  }
3730
- if (tool4 === "todoread") {
3793
+ if (tool5 === "todoread") {
3731
3794
  return "read todo list";
3732
3795
  }
3733
- if (tool4 === "task" && parameters.description) {
3796
+ if (tool5 === "task" && parameters.description) {
3734
3797
  return parameters.description;
3735
3798
  }
3736
- if (tool4 === "skill" && parameters.name) {
3799
+ if (tool5 === "skill" && parameters.name) {
3737
3800
  return parameters.name;
3738
3801
  }
3739
- if (tool4 === "lsp") {
3802
+ if (tool5 === "lsp") {
3740
3803
  const op = parameters.operation || "lsp";
3741
3804
  const path = parameters.filePath || "";
3742
3805
  const line = parameters.line;
@@ -3749,7 +3812,7 @@ function extractParameterKey(tool4, parameters) {
3749
3812
  }
3750
3813
  return op;
3751
3814
  }
3752
- if (tool4 === "question") {
3815
+ if (tool5 === "question") {
3753
3816
  const questions = parameters.questions;
3754
3817
  if (Array.isArray(questions) && questions.length > 0) {
3755
3818
  const headers = questions.map((q) => q.header || "").filter(Boolean).slice(0, 3);
@@ -4717,6 +4780,12 @@ import { tool as tool3 } from "@opencode-ai/plugin";
4717
4780
  // lib/messages/utils.ts
4718
4781
  import { createHash } from "crypto";
4719
4782
  var SUMMARY_ID_HASH_LENGTH = 16;
4783
+ var MERGED_SUMMARY_HEADER = (blockId) => `[ACP compressed context summary (block ${blockId}) \u2014 prior conversation recap]
4784
+ `;
4785
+ var MERGED_SUMMARY_FOOTER = `
4786
+ [End ACP compressed context summary]
4787
+
4788
+ `;
4720
4789
  var DCP_BLOCK_ID_TAG_REGEX = /(<dcp-message-id(?=[\s>])[^>]*>)b\d+(<\/dcp-message-id>)/g;
4721
4790
  var DCP_MESSAGE_REF_TAG_REGEX = /<dcp-message-id>m\d+<\/dcp-message-id>/g;
4722
4791
  var DCP_PAIRED_TAG_REGEX = /<dcp[^>]*>[\s\S]*?<\/dcp[^>]*>/gi;
@@ -4751,6 +4820,34 @@ var createSyntheticUserMessage = (baseMessage, content, stableSeed) => {
4751
4820
  ]
4752
4821
  };
4753
4822
  };
4823
+ var prependCompressionSummary = (message, summary, blockId) => {
4824
+ const parts = Array.isArray(message.parts) ? message.parts : [];
4825
+ const header = MERGED_SUMMARY_HEADER(blockId);
4826
+ const marker = MERGED_SUMMARY_HEADER(blockId).trimEnd();
4827
+ for (const part of parts) {
4828
+ if (part.type !== "text") {
4829
+ continue;
4830
+ }
4831
+ const textPart = part;
4832
+ const existing = typeof textPart.text === "string" ? textPart.text : "";
4833
+ if (existing.includes(marker)) {
4834
+ return true;
4835
+ }
4836
+ textPart.text = `${header}${summary}${MERGED_SUMMARY_FOOTER}${existing}`;
4837
+ return true;
4838
+ }
4839
+ const sessionID = message.info.sessionID ?? "";
4840
+ const messageId = message.info.id;
4841
+ parts.unshift({
4842
+ id: generateStableId("prt_dcp_prepend", `${blockId}:${messageId}`),
4843
+ sessionID,
4844
+ messageID: messageId,
4845
+ type: "text",
4846
+ text: `${header}${summary}${MERGED_SUMMARY_FOOTER}`
4847
+ });
4848
+ message.parts = parts;
4849
+ return true;
4850
+ };
4754
4851
  var createSyntheticTextPart = (baseMessage, content, stableSeed) => {
4755
4852
  const userInfo = baseMessage.info;
4756
4853
  const deterministicSeed = stableSeed?.trim() || userInfo.id;
@@ -4950,7 +5047,8 @@ var filterCompressedRanges = (state, logger, config, messages) => {
4950
5047
  return;
4951
5048
  }
4952
5049
  const result = [];
4953
- for (const msg of messages) {
5050
+ for (let i = 0; i < messages.length; i++) {
5051
+ const msg = messages[i];
4954
5052
  const msgId = msg.info.id;
4955
5053
  const blockId = state.prune.messages.activeByAnchorMessageId.get(msgId);
4956
5054
  const summary = blockId !== void 0 ? state.prune.messages.blocksById.get(blockId) : void 0;
@@ -4962,46 +5060,52 @@ var filterCompressedRanges = (state, logger, config, messages) => {
4962
5060
  blockId: summary.blockId
4963
5061
  });
4964
5062
  } else {
4965
- const msgIndex = messages.indexOf(msg);
4966
- const userMessage = getLastUserMessage(messages, msgIndex);
4967
5063
  const _cleaned = stripStaleMessageRefs(rawSummaryContent);
4968
- if (userMessage) {
4969
- const userInfo = userMessage.info;
4970
- const summaryContent = config.compress.mode === "message" ? replaceBlockIdsWithBlocked(_cleaned) : _cleaned;
4971
- const summarySeed = `${summary.blockId}:${summary.anchorMessageId}`;
4972
- result.push(
4973
- createSyntheticUserMessage(userMessage, summaryContent, summarySeed)
4974
- );
4975
- logger.info("Injected compress summary", {
5064
+ const summaryContent = config.compress.mode === "message" ? replaceBlockIdsWithBlocked(_cleaned) : _cleaned;
5065
+ const nextSurviving = findNextSurvivingMessage(messages, i, state);
5066
+ const merged = nextSurviving !== null && nextSurviving.info.role === "user" && prependCompressionSummary(nextSurviving, summaryContent, summary.blockId);
5067
+ if (merged) {
5068
+ logger.info("Merged compress summary into following user message", {
4976
5069
  anchorMessageId: msgId,
5070
+ targetMessageId: nextSurviving.info.id,
4977
5071
  summaryLength: summaryContent.length
4978
5072
  });
4979
5073
  } else {
4980
- const anchorInfo = msg.info;
4981
- const fallbackBase = {
4982
- info: {
4983
- id: anchorInfo.id || msgId,
4984
- sessionID: anchorInfo.sessionID || "",
4985
- role: "user",
4986
- agent: anchorInfo.agent || "code",
4987
- model: anchorInfo.model || {
4988
- providerID: "",
4989
- modelID: "",
4990
- variant: void 0
4991
- },
4992
- time: { created: anchorInfo.time?.created || Date.now() }
4993
- },
4994
- parts: []
4995
- };
4996
- const summaryContent = config.compress.mode === "message" ? replaceBlockIdsWithBlocked(_cleaned) : _cleaned;
5074
+ const userMessage = getLastUserMessage(messages, i);
4997
5075
  const summarySeed = `${summary.blockId}:${summary.anchorMessageId}`;
4998
- result.push(
4999
- createSyntheticUserMessage(fallbackBase, summaryContent, summarySeed)
5000
- );
5001
- logger.info("Injected compress summary (fallback, no preceding user message)", {
5002
- anchorMessageId: msgId,
5003
- summaryLength: summaryContent.length
5004
- });
5076
+ if (userMessage) {
5077
+ result.push(
5078
+ createSyntheticUserMessage(userMessage, summaryContent, summarySeed)
5079
+ );
5080
+ logger.info("Injected compress summary", {
5081
+ anchorMessageId: msgId,
5082
+ summaryLength: summaryContent.length
5083
+ });
5084
+ } else {
5085
+ const anchorInfo = msg.info;
5086
+ const fallbackBase = {
5087
+ info: {
5088
+ id: anchorInfo.id || msgId,
5089
+ sessionID: anchorInfo.sessionID || "",
5090
+ role: "user",
5091
+ agent: anchorInfo.agent || "code",
5092
+ model: anchorInfo.model || {
5093
+ providerID: "",
5094
+ modelID: "",
5095
+ variant: void 0
5096
+ },
5097
+ time: { created: anchorInfo.time?.created || Date.now() }
5098
+ },
5099
+ parts: []
5100
+ };
5101
+ result.push(
5102
+ createSyntheticUserMessage(fallbackBase, summaryContent, summarySeed)
5103
+ );
5104
+ logger.info("Injected compress summary (fallback, no preceding user message)", {
5105
+ anchorMessageId: msgId,
5106
+ summaryLength: summaryContent.length
5107
+ });
5108
+ }
5005
5109
  }
5006
5110
  }
5007
5111
  }
@@ -5014,6 +5118,17 @@ var filterCompressedRanges = (state, logger, config, messages) => {
5014
5118
  messages.length = 0;
5015
5119
  messages.push(...result);
5016
5120
  };
5121
+ var findNextSurvivingMessage = (messages, startIndex, state) => {
5122
+ for (let j = startIndex; j < messages.length; j++) {
5123
+ const candidate = messages[j];
5124
+ const entry = state.prune.messages.byMessageId.get(candidate.info.id);
5125
+ if (entry && entry.activeBlockIds.length > 0) {
5126
+ continue;
5127
+ }
5128
+ return candidate;
5129
+ }
5130
+ return null;
5131
+ };
5017
5132
 
5018
5133
  // lib/messages/sync.ts
5019
5134
  function sortBlocksByCreation(a, b) {
@@ -5162,8 +5277,8 @@ var resolveEffectiveCompressPermission = (basePermission, hostPermissions, agent
5162
5277
  agentName ? hostPermissions.agents[agentName] : void 0
5163
5278
  ) ? "deny" : basePermission;
5164
5279
  };
5165
- var hasExplicitToolPermission = (permissionConfig, tool4) => {
5166
- return permissionConfig ? Object.prototype.hasOwnProperty.call(permissionConfig, tool4) : false;
5280
+ var hasExplicitToolPermission = (permissionConfig, tool5) => {
5281
+ return permissionConfig ? Object.prototype.hasOwnProperty.call(permissionConfig, tool5) : false;
5167
5282
  };
5168
5283
 
5169
5284
  // lib/compress-permission.ts
@@ -6275,6 +6390,109 @@ function createDecompressTool(ctx) {
6275
6390
  });
6276
6391
  }
6277
6392
 
6393
+ // lib/compress/mark-block.ts
6394
+ import { tool as tool4 } from "@opencode-ai/plugin";
6395
+ async function prepareMarkSession(ctx, toolCtx) {
6396
+ await toolCtx.ask({
6397
+ permission: "compress",
6398
+ patterns: ["*"],
6399
+ always: ["*"],
6400
+ metadata: {}
6401
+ });
6402
+ toolCtx.metadata({ title: "Mark block" });
6403
+ const rawMessages = await fetchSessionMessages(ctx.client, toolCtx.sessionID);
6404
+ await ensureSessionInitialized(
6405
+ ctx.client,
6406
+ ctx.state,
6407
+ toolCtx.sessionID,
6408
+ ctx.logger,
6409
+ rawMessages,
6410
+ ctx.config.manualMode.enabled
6411
+ );
6412
+ assignMessageRefs(ctx.state, rawMessages);
6413
+ }
6414
+ var MARK_DESCRIPTION = `Marks a compressed block for batch merge-cleanup.
6415
+
6416
+ Use this for blocks whose detailed content you no longer need, but whose summaries
6417
+ you want to keep in context for now (to preserve prompt cache). Marked blocks stay
6418
+ fully active with zero immediate effect on context or cache. When context pressure
6419
+ rises, all marked blocks are merge-compressed together into a single summary in one
6420
+ cache break, instead of being handled one at a time.
6421
+
6422
+ Argument: blockId \u2014 the block reference to mark (e.g., "b1", "b3")
6423
+
6424
+ Use mark_block instead of compress when you want deferred cleanup: the block keeps
6425
+ serving cache hits now and gets consolidated later only if context gets tight.`;
6426
+ var UNMARK_DESCRIPTION = `Removes the batch cleanup mark from a compressed block.
6427
+
6428
+ Reverses mark_block. The block returns to normal handling and will not be
6429
+ auto-merged during batch cleanup.
6430
+
6431
+ Argument: blockId \u2014 the block reference to unmark (e.g., "b1", "b3")`;
6432
+ function buildSchema4() {
6433
+ return {
6434
+ blockId: tool4.schema.string().describe('Block reference to mark (e.g., "b1", "b3")')
6435
+ };
6436
+ }
6437
+ function buildUnmarkSchema() {
6438
+ return {
6439
+ blockId: tool4.schema.string().describe('Block reference to unmark (e.g., "b1", "b3")')
6440
+ };
6441
+ }
6442
+ function createMarkBlockTool(ctx) {
6443
+ return tool4({
6444
+ description: MARK_DESCRIPTION,
6445
+ args: buildSchema4(),
6446
+ async execute(args, toolCtx) {
6447
+ await prepareMarkSession(ctx, toolCtx);
6448
+ const targetBlockId = parseBlockRef(String(args.blockId));
6449
+ if (targetBlockId === null) {
6450
+ return `Error: Invalid block ID "${args.blockId}". Use format "b0", "b1", etc.`;
6451
+ }
6452
+ const messagesState = ctx.state.prune.messages;
6453
+ const block = messagesState.blocksById.get(targetBlockId);
6454
+ if (!block) {
6455
+ return `Error: Block ${formatBlockRef(targetBlockId)} does not exist.`;
6456
+ }
6457
+ if (!block.active) {
6458
+ return `Error: Block ${formatBlockRef(targetBlockId)} is not active.`;
6459
+ }
6460
+ messagesState.markedForCleanup.add(targetBlockId);
6461
+ await saveSessionState(ctx.state, ctx.logger);
6462
+ const ref = formatBlockRef(targetBlockId);
6463
+ const markedCount = messagesState.markedForCleanup.size;
6464
+ ctx.logger.info("mark_block: block marked for cleanup", {
6465
+ blockId: targetBlockId,
6466
+ markedCount
6467
+ });
6468
+ return `Block ${ref} marked for cleanup. It will be merge-compressed together with other marked blocks when context pressure rises. No immediate effect on context or cache. (${markedCount} block(s) currently marked.)`;
6469
+ }
6470
+ });
6471
+ }
6472
+ function createUnmarkBlockTool(ctx) {
6473
+ return tool4({
6474
+ description: UNMARK_DESCRIPTION,
6475
+ args: buildUnmarkSchema(),
6476
+ async execute(args, toolCtx) {
6477
+ await prepareMarkSession(ctx, toolCtx);
6478
+ const targetBlockId = parseBlockRef(String(args.blockId));
6479
+ if (targetBlockId === null) {
6480
+ return `Error: Invalid block ID "${args.blockId}". Use format "b0", "b1", etc.`;
6481
+ }
6482
+ const messagesState = ctx.state.prune.messages;
6483
+ if (!messagesState.markedForCleanup.has(targetBlockId)) {
6484
+ return `Block ${formatBlockRef(targetBlockId)} was not marked for cleanup.`;
6485
+ }
6486
+ messagesState.markedForCleanup.delete(targetBlockId);
6487
+ await saveSessionState(ctx.state, ctx.logger);
6488
+ ctx.logger.info("unmark_block: block unmarked", {
6489
+ blockId: targetBlockId
6490
+ });
6491
+ return `Block ${formatBlockRef(targetBlockId)} unmarked. It will no longer be auto-merged during batch cleanup.`;
6492
+ }
6493
+ });
6494
+ }
6495
+
6278
6496
  // lib/logger.ts
6279
6497
  import { writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
6280
6498
  import { join as join3 } from "path";
@@ -6483,7 +6701,7 @@ var SYSTEM = `
6483
6701
 
6484
6702
  You operate in a context-constrained environment. Context management helps preserve retrieval quality, but your primary goal is completing the task at hand. Do not let context management distract from the actual work.
6485
6703
 
6486
- The tools you have for context management are \`compress\` and \`decompress\`. \`compress\` replaces older conversation content with technical summaries you produce. \`decompress\` restores previously compressed content when you need exact details.
6704
+ The tools you have for context management are \`compress\`, \`decompress\`, \`mark_block\`, and \`unmark_block\`. \`compress\` replaces older conversation content with technical summaries you produce. \`decompress\` restores previously compressed content when you need exact details. \`mark_block\` flags a compressed block for deferred batch merge-cleanup \u2014 it has zero immediate effect on context or cache, but marked blocks are merge-compressed together in a single cache break when context pressure rises. Use it for blocks you no longer need in detail but want to keep cached for now. \`unmark_block\` removes that flag.
6487
6705
 
6488
6706
  \`<dcp-message-id>\` and \`<dcp-system-reminder>\` tags are environment-injected metadata. Do not output them.
6489
6707
 
@@ -7514,7 +7732,7 @@ var COMPRESS_TRIGGER_PROMPT = [
7514
7732
  "Follow the active compress mode, preserve all critical implementation details, and choose safe targets.",
7515
7733
  "Return after compress with a brief explanation of what content was compressed."
7516
7734
  ].join("\n\n");
7517
- function getTriggerPrompt(tool4, state, config, userFocus) {
7735
+ function getTriggerPrompt(tool5, state, config, userFocus) {
7518
7736
  const base = COMPRESS_TRIGGER_PROMPT;
7519
7737
  const compressedBlockGuidance = config.compress.mode === "message" ? "" : buildCompressedBlockGuidance(state, config.gc);
7520
7738
  const sections = [base, compressedBlockGuidance];
@@ -7543,8 +7761,8 @@ async function handleManualToggleCommand(ctx, modeArg) {
7543
7761
  );
7544
7762
  logger.info("Manual mode toggled", { manualMode: state.manualMode });
7545
7763
  }
7546
- async function handleManualTriggerCommand(ctx, tool4, userFocus) {
7547
- return getTriggerPrompt(tool4, ctx.state, ctx.config, userFocus);
7764
+ async function handleManualTriggerCommand(ctx, tool5, userFocus) {
7765
+ return getTriggerPrompt(tool5, ctx.state, ctx.config, userFocus);
7548
7766
  }
7549
7767
  function applyPendingManualTrigger(state, messages, logger) {
7550
7768
  const pending = state.pendingManualTrigger;
@@ -8073,6 +8291,264 @@ function parseGcThreshold(limit, modelContextLimit) {
8073
8291
  return Math.round(Math.max(0, Math.min(100, Math.round(percent))) / 100 * modelContextLimit);
8074
8292
  }
8075
8293
 
8294
+ // lib/gc/merge.ts
8295
+ var DEFAULT_BATCH_CLEANUP = {
8296
+ lowThreshold: "60%",
8297
+ highThreshold: "75%",
8298
+ forceThreshold: "90%"
8299
+ };
8300
+ function resolveBatchCleanup(gc) {
8301
+ return gc.batchCleanup ?? DEFAULT_BATCH_CLEANUP;
8302
+ }
8303
+ function percentToTokens(value, modelContextLimit) {
8304
+ if (typeof value === "number") return value;
8305
+ const percent = parseFloat(value.slice(0, -1));
8306
+ if (isNaN(percent)) return modelContextLimit;
8307
+ const clamped = Math.max(0, Math.min(100, Math.round(percent)));
8308
+ return Math.round(clamped / 100 * modelContextLimit);
8309
+ }
8310
+ function collectActiveOldGenBlocks(state, maxOldGenSummaryLength) {
8311
+ const blocks = [];
8312
+ const ids = Array.from(state.prune.messages.activeBlockIds).sort((a, b) => a - b);
8313
+ for (const id of ids) {
8314
+ const block = state.prune.messages.blocksById.get(id);
8315
+ if (!block || !block.active) continue;
8316
+ if (block.generation === "old" || block.generation === void 0 || block.summary.length > maxOldGenSummaryLength) {
8317
+ blocks.push(block);
8318
+ }
8319
+ }
8320
+ return blocks;
8321
+ }
8322
+ function collectActiveMarkedBlocks(state) {
8323
+ const ids = Array.from(state.prune.messages.markedForCleanup).sort((a, b) => a - b);
8324
+ const blocks = [];
8325
+ for (const id of ids) {
8326
+ const block = state.prune.messages.blocksById.get(id);
8327
+ if (!block || !block.active) continue;
8328
+ blocks.push(block);
8329
+ }
8330
+ return blocks;
8331
+ }
8332
+ function extractSummaryBody(summary) {
8333
+ let body = summary;
8334
+ const headerPrefix = COMPRESSED_BLOCK_HEADER + "\n";
8335
+ if (body.startsWith(headerPrefix)) {
8336
+ body = body.slice(headerPrefix.length);
8337
+ }
8338
+ body = body.replace(/\n<dcp-message-id[^>]*>b\d+<\/dcp-message-id>$/, "");
8339
+ return body.trim();
8340
+ }
8341
+ function truncateMergedSummary(merged, maxLength) {
8342
+ if (merged.length <= maxLength) return merged;
8343
+ const blocks = merged.split("\n---\n");
8344
+ const headers = blocks.map((b) => b.split("\n")[0] ?? "").filter((h) => h.trim().length > 0);
8345
+ const marker = "\n...\n[merged and truncated by batch cleanup]";
8346
+ const budget = Math.max(0, maxLength - marker.length);
8347
+ const headerJoin = headers.join("\n");
8348
+ if (headerJoin.length <= budget) {
8349
+ return headerJoin + marker;
8350
+ }
8351
+ return headerJoin.slice(0, budget) + marker;
8352
+ }
8353
+ function mergeMarkedBlocks(state, markedIds, maxMergedLength) {
8354
+ const sortedIds = [...new Set(markedIds)].filter(
8355
+ (id) => Number.isInteger(id) && id > 0
8356
+ ).sort((a, b) => a - b);
8357
+ const sourceBlocks = [];
8358
+ for (const id of sortedIds) {
8359
+ const block = state.prune.messages.blocksById.get(id);
8360
+ if (!block || !block.active) continue;
8361
+ if (!sourceBlocks.some((b) => b.blockId === id)) {
8362
+ sourceBlocks.push(block);
8363
+ }
8364
+ }
8365
+ if (sourceBlocks.length < 2) {
8366
+ return { mergedCount: 0, savedTokens: 0 };
8367
+ }
8368
+ const messagesState = state.prune.messages;
8369
+ const newBlockId = allocateBlockId(state);
8370
+ const newRunId = allocateRunId(state);
8371
+ const bodies = sourceBlocks.map((block) => extractSummaryBody(block.summary));
8372
+ const mergedRaw = bodies.join("\n---\n");
8373
+ const mergedBody = truncateMergedSummary(mergedRaw, maxMergedLength);
8374
+ const newSummary = wrapCompressedSummary(newBlockId, mergedBody);
8375
+ const newSummaryTokens = countTokens2(newSummary);
8376
+ const oldest = sourceBlocks[0];
8377
+ const newest = sourceBlocks[sourceBlocks.length - 1];
8378
+ const effectiveMessageIds = /* @__PURE__ */ new Set();
8379
+ const effectiveToolIds = /* @__PURE__ */ new Set();
8380
+ for (const block of sourceBlocks) {
8381
+ for (const id of block.effectiveMessageIds) effectiveMessageIds.add(id);
8382
+ for (const id of block.effectiveToolIds) effectiveToolIds.add(id);
8383
+ }
8384
+ const sourceIds = sourceBlocks.map((b) => b.blockId);
8385
+ const createdAt = Date.now();
8386
+ const mergedBlock = {
8387
+ blockId: newBlockId,
8388
+ runId: newRunId,
8389
+ active: true,
8390
+ deactivatedByUser: false,
8391
+ compressedTokens: 0,
8392
+ summaryTokens: newSummaryTokens,
8393
+ durationMs: 0,
8394
+ mode: "range",
8395
+ topic: "Batch merge cleanup",
8396
+ batchTopic: "Batch merge cleanup",
8397
+ startId: oldest.startId,
8398
+ endId: newest.endId,
8399
+ anchorMessageId: oldest.anchorMessageId,
8400
+ compressMessageId: "",
8401
+ compressCallId: void 0,
8402
+ includedBlockIds: [...sourceIds],
8403
+ consumedBlockIds: [...sourceIds],
8404
+ parentBlockIds: [],
8405
+ directMessageIds: [],
8406
+ directToolIds: [],
8407
+ effectiveMessageIds: [...effectiveMessageIds],
8408
+ effectiveToolIds: [...effectiveToolIds],
8409
+ createdAt,
8410
+ summary: newSummary,
8411
+ survivedCount: 0,
8412
+ generation: "old"
8413
+ };
8414
+ const now = Date.now();
8415
+ for (const block of sourceBlocks) {
8416
+ block.active = false;
8417
+ block.deactivatedAt = now;
8418
+ block.deactivatedByBlockId = newBlockId;
8419
+ if (!block.parentBlockIds.includes(newBlockId)) {
8420
+ block.parentBlockIds.push(newBlockId);
8421
+ }
8422
+ messagesState.activeBlockIds.delete(block.blockId);
8423
+ const mappedId = messagesState.activeByAnchorMessageId.get(block.anchorMessageId);
8424
+ if (mappedId === block.blockId) {
8425
+ messagesState.activeByAnchorMessageId.delete(block.anchorMessageId);
8426
+ }
8427
+ }
8428
+ messagesState.blocksById.set(newBlockId, mergedBlock);
8429
+ messagesState.activeBlockIds.add(newBlockId);
8430
+ messagesState.activeByAnchorMessageId.set(mergedBlock.anchorMessageId, newBlockId);
8431
+ for (const messageId of effectiveMessageIds) {
8432
+ const entry = messagesState.byMessageId.get(messageId);
8433
+ if (!entry) continue;
8434
+ entry.activeBlockIds = entry.activeBlockIds.filter((id) => !sourceIds.includes(id));
8435
+ if (!entry.activeBlockIds.includes(newBlockId)) {
8436
+ entry.activeBlockIds.push(newBlockId);
8437
+ }
8438
+ if (!entry.allBlockIds.includes(newBlockId)) {
8439
+ entry.allBlockIds.push(newBlockId);
8440
+ }
8441
+ }
8442
+ for (const id of sourceIds) {
8443
+ messagesState.markedForCleanup.delete(id);
8444
+ }
8445
+ const sourceTokens = sourceBlocks.reduce(
8446
+ (sum, block) => sum + (block.summaryTokens || Math.round(block.summary.length / 4)),
8447
+ 0
8448
+ );
8449
+ const savedTokens = Math.max(0, sourceTokens - newSummaryTokens);
8450
+ return { mergedCount: sourceBlocks.length, savedTokens };
8451
+ }
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(
8457
+ (sum, block) => sum + (block.summaryTokens || Math.round(block.summary.length / 4)),
8458
+ 0
8459
+ );
8460
+ const estimatedMergedTokens = Math.round(maxMergedLength / 4);
8461
+ const estimatedSavings = Math.max(0, sourceTokens - estimatedMergedTokens);
8462
+ 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."
8467
+ ].join(" ");
8468
+ }
8469
+ function runBatchCleanup(state, config, logger, messages) {
8470
+ const noop = {
8471
+ tier: 0,
8472
+ action: "none",
8473
+ mergedCount: 0,
8474
+ savedTokens: 0
8475
+ };
8476
+ if (!state.modelContextLimit || state.modelContextLimit <= 0) {
8477
+ return noop;
8478
+ }
8479
+ const currentTokens = getCurrentTokenUsage(state, messages);
8480
+ const limit = state.modelContextLimit;
8481
+ const batchCleanup = resolveBatchCleanup(config.gc);
8482
+ const maxMergedLength = config.gc.maxOldGenSummaryLength;
8483
+ const forceTokens = percentToTokens(batchCleanup.forceThreshold, limit);
8484
+ const highTokens = percentToTokens(batchCleanup.highThreshold, limit);
8485
+ const lowTokens = percentToTokens(batchCleanup.lowThreshold, limit);
8486
+ if (currentTokens >= forceTokens) {
8487
+ const oldGenBlocks = collectActiveOldGenBlocks(state, maxMergedLength);
8488
+ if (oldGenBlocks.length < 2) {
8489
+ return noop;
8490
+ }
8491
+ const ids = oldGenBlocks.map((b) => b.blockId);
8492
+ const result = mergeMarkedBlocks(state, ids, maxMergedLength);
8493
+ if (result.mergedCount === 0) {
8494
+ return noop;
8495
+ }
8496
+ logger.info("Batch cleanup tier 3 (force): merged old-gen blocks", {
8497
+ mergedCount: result.mergedCount,
8498
+ savedTokens: result.savedTokens,
8499
+ currentTokens,
8500
+ forceThreshold: batchCleanup.forceThreshold
8501
+ });
8502
+ return {
8503
+ tier: 3,
8504
+ action: "merge",
8505
+ mergedCount: result.mergedCount,
8506
+ savedTokens: result.savedTokens
8507
+ };
8508
+ }
8509
+ if (currentTokens >= highTokens) {
8510
+ 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;
8518
+ }
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
+ }
8532
+ if (currentTokens >= lowTokens) {
8533
+ const nudgeText = buildNudgeText(state, maxMergedLength);
8534
+ if (!nudgeText) {
8535
+ return noop;
8536
+ }
8537
+ logger.info("Batch cleanup tier 1 (low): nudge injected", {
8538
+ currentTokens,
8539
+ lowThreshold: batchCleanup.lowThreshold
8540
+ });
8541
+ return {
8542
+ tier: 1,
8543
+ action: "nudge",
8544
+ mergedCount: 0,
8545
+ savedTokens: 0,
8546
+ nudgeText
8547
+ };
8548
+ }
8549
+ return noop;
8550
+ }
8551
+
8076
8552
  // lib/hooks.ts
8077
8553
  var INTERNAL_AGENT_SIGNATURES = [
8078
8554
  "You are a title generator",
@@ -8080,6 +8556,15 @@ var INTERNAL_AGENT_SIGNATURES = [
8080
8556
  "You are an anchored context summarization assistant for coding sessions",
8081
8557
  "Summarize what was done in this conversation"
8082
8558
  ];
8559
+ var INTERNAL_AGENT_NAMES = /* @__PURE__ */ new Set(["title", "summary", "compaction"]);
8560
+ function isInternalAgentRequest(messages) {
8561
+ const lastUserMessage = getLastUserMessage(messages);
8562
+ if (!lastUserMessage) {
8563
+ return false;
8564
+ }
8565
+ const agent = lastUserMessage.info.agent;
8566
+ return typeof agent === "string" && INTERNAL_AGENT_NAMES.has(agent);
8567
+ }
8083
8568
  function createSystemPromptHandler(state, logger, config, prompts) {
8084
8569
  return async (input, output) => {
8085
8570
  if (input.model?.limit?.context) {
@@ -8172,6 +8657,11 @@ function runMajorGC(state, config, logger, messages) {
8172
8657
  void saveSessionState(state, logger);
8173
8658
  }
8174
8659
  }
8660
+ function appendBatchCleanupNudge(messages, nudgeText) {
8661
+ const lastUser = getLastUserMessage(messages);
8662
+ if (!lastUser) return;
8663
+ appendToLastTextPart(lastUser, nudgeText);
8664
+ }
8175
8665
  function createChatMessageTransformHandler(client, state, logger, config, prompts, hostPermissions) {
8176
8666
  return async (input, output) => {
8177
8667
  const receivedMessages = Array.isArray(output.messages) ? output.messages.length : 0;
@@ -8182,6 +8672,10 @@ function createChatMessageTransformHandler(client, state, logger, config, prompt
8182
8672
  usable: messages.length
8183
8673
  });
8184
8674
  }
8675
+ if (isInternalAgentRequest(messages)) {
8676
+ logger.debug("Skipping message transform for internal agent request");
8677
+ return;
8678
+ }
8185
8679
  await checkSession(client, state, logger, output.messages, config.manualMode.enabled);
8186
8680
  syncCompressPermissionState(state, config, hostPermissions, output.messages);
8187
8681
  if (state.isSubAgent && !config.experimental.allowSubAgents) {
@@ -8198,6 +8692,13 @@ function createChatMessageTransformHandler(client, state, logger, config, prompt
8198
8692
  syncToolCache(state, config, logger, output.messages);
8199
8693
  buildToolIdList(state, output.messages);
8200
8694
  runMajorGC(state, config, logger, output.messages);
8695
+ const batchResult = runBatchCleanup(state, config, logger, output.messages);
8696
+ if (batchResult.tier === 1 && batchResult.nudgeText) {
8697
+ appendBatchCleanupNudge(output.messages, batchResult.nudgeText);
8698
+ }
8699
+ if (batchResult.mergedCount > 0) {
8700
+ void saveSessionState(state, logger);
8701
+ }
8201
8702
  prune(state, logger, config, output.messages);
8202
8703
  assignMessageRefs(state, output.messages);
8203
8704
  await injectExtendedSubAgentResults(
@@ -8616,7 +9117,9 @@ var server = (async (ctx) => {
8616
9117
  tool: {
8617
9118
  ...config.compress.permission !== "deny" && {
8618
9119
  compress: config.compress.mode === "message" ? createCompressMessageTool(compressToolContext) : createCompressRangeTool(compressToolContext),
8619
- decompress: createDecompressTool(compressToolContext)
9120
+ decompress: createDecompressTool(compressToolContext),
9121
+ mark_block: createMarkBlockTool(compressToolContext),
9122
+ unmark_block: createUnmarkBlockTool(compressToolContext)
8620
9123
  }
8621
9124
  },
8622
9125
  config: async (opencodeConfig) => {
@@ -8632,7 +9135,7 @@ var server = (async (ctx) => {
8632
9135
  }
8633
9136
  const toolsToAdd = [];
8634
9137
  if (config.compress.permission !== "deny" && !config.experimental.allowSubAgents) {
8635
- toolsToAdd.push("compress", "decompress");
9138
+ toolsToAdd.push("compress", "decompress", "mark_block", "unmark_block");
8636
9139
  }
8637
9140
  if (toolsToAdd.length > 0) {
8638
9141
  const existingPrimaryTools = opencodeConfig.experimental?.primary_tools ?? [];