opencode-acp 1.5.0 → 1.6.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.
Files changed (37) hide show
  1. package/README.md +9 -17
  2. package/README.zh-CN.md +7 -17
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +349 -339
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/compress/index.d.ts +0 -1
  7. package/dist/lib/compress/index.d.ts.map +1 -1
  8. package/dist/lib/compress/message.d.ts.map +1 -1
  9. package/dist/lib/compress/range-utils.d.ts.map +1 -1
  10. package/dist/lib/compress/range.d.ts.map +1 -1
  11. package/dist/lib/config-validation.d.ts.map +1 -1
  12. package/dist/lib/config.d.ts +4 -0
  13. package/dist/lib/config.d.ts.map +1 -1
  14. package/dist/lib/gc/merge.d.ts.map +1 -1
  15. package/dist/lib/hooks.d.ts.map +1 -1
  16. package/dist/lib/messages/inject/inject.d.ts.map +1 -1
  17. package/dist/lib/messages/inject/utils.d.ts +1 -1
  18. package/dist/lib/messages/inject/utils.d.ts.map +1 -1
  19. package/dist/lib/messages/prune.d.ts.map +1 -1
  20. package/dist/lib/messages/utils.d.ts.map +1 -1
  21. package/dist/lib/prompts/compress-range.d.ts +1 -1
  22. package/dist/lib/prompts/compress-range.d.ts.map +1 -1
  23. package/dist/lib/prompts/extensions/nudge.d.ts +7 -0
  24. package/dist/lib/prompts/extensions/nudge.d.ts.map +1 -1
  25. package/dist/lib/prompts/system.d.ts +1 -1
  26. package/dist/lib/prompts/system.d.ts.map +1 -1
  27. package/dist/lib/state/persistence.d.ts +2 -0
  28. package/dist/lib/state/persistence.d.ts.map +1 -1
  29. package/dist/lib/state/state.d.ts.map +1 -1
  30. package/dist/lib/state/types.d.ts +2 -0
  31. package/dist/lib/state/types.d.ts.map +1 -1
  32. package/dist/lib/state/utils.d.ts.map +1 -1
  33. package/dist/lib/token-utils.d.ts +1 -0
  34. package/dist/lib/token-utils.d.ts.map +1 -1
  35. package/package.json +1 -1
  36. package/dist/lib/compress/mark-block.d.ts +0 -5
  37. package/dist/lib/compress/mark-block.d.ts.map +0 -1
package/dist/index.js CHANGED
@@ -894,11 +894,15 @@ var VALID_CONFIG_KEYS = /* @__PURE__ */ new Set([
894
894
  "compress.modelMaxLimits",
895
895
  "compress.modelMinLimits",
896
896
  "compress.nudgeFrequency",
897
+ "compress.perMessageNudgeGrowthPercent",
897
898
  "compress.iterationNudgeThreshold",
898
899
  "compress.nudgeForce",
899
900
  "compress.protectedTools",
900
901
  "compress.protectTags",
901
902
  "compress.protectUserMessages",
903
+ "compress.maxSummaryLength",
904
+ "compress.maxSummaryLengthHard",
905
+ "compress.minCompressRange",
902
906
  "gc",
903
907
  "gc.algorithm",
904
908
  "gc.promotionThreshold",
@@ -1117,6 +1121,13 @@ function validateConfigTypes(config) {
1117
1121
  actual: `${compress.nudgeFrequency} (will be clamped to 1)`
1118
1122
  });
1119
1123
  }
1124
+ if (compress.perMessageNudgeGrowthPercent !== void 0 && typeof compress.perMessageNudgeGrowthPercent !== "number") {
1125
+ errors.push({
1126
+ key: "compress.perMessageNudgeGrowthPercent",
1127
+ expected: "number",
1128
+ actual: typeof compress.perMessageNudgeGrowthPercent
1129
+ });
1130
+ }
1120
1131
  if (compress.iterationNudgeThreshold !== void 0 && typeof compress.iterationNudgeThreshold !== "number") {
1121
1132
  errors.push({
1122
1133
  key: "compress.iterationNudgeThreshold",
@@ -1152,6 +1163,55 @@ function validateConfigTypes(config) {
1152
1163
  actual: typeof compress.protectUserMessages
1153
1164
  });
1154
1165
  }
1166
+ if (compress.maxSummaryLength !== void 0 && typeof compress.maxSummaryLength !== "number") {
1167
+ errors.push({
1168
+ key: "compress.maxSummaryLength",
1169
+ expected: "number",
1170
+ actual: typeof compress.maxSummaryLength
1171
+ });
1172
+ }
1173
+ if (typeof compress.maxSummaryLength === "number" && compress.maxSummaryLength < 1) {
1174
+ errors.push({
1175
+ key: "compress.maxSummaryLength",
1176
+ expected: "positive number (>= 1)",
1177
+ actual: `${compress.maxSummaryLength}`
1178
+ });
1179
+ }
1180
+ if (compress.maxSummaryLengthHard !== void 0 && typeof compress.maxSummaryLengthHard !== "number") {
1181
+ errors.push({
1182
+ key: "compress.maxSummaryLengthHard",
1183
+ expected: "number",
1184
+ actual: typeof compress.maxSummaryLengthHard
1185
+ });
1186
+ }
1187
+ if (typeof compress.maxSummaryLengthHard === "number" && compress.maxSummaryLengthHard < 1) {
1188
+ errors.push({
1189
+ key: "compress.maxSummaryLengthHard",
1190
+ expected: "positive number (>= 1)",
1191
+ actual: `${compress.maxSummaryLengthHard}`
1192
+ });
1193
+ }
1194
+ if (typeof compress.maxSummaryLength === "number" && typeof compress.maxSummaryLengthHard === "number" && compress.maxSummaryLengthHard < compress.maxSummaryLength) {
1195
+ errors.push({
1196
+ key: "compress.maxSummaryLengthHard",
1197
+ expected: `>= maxSummaryLength (${compress.maxSummaryLength})`,
1198
+ actual: `${compress.maxSummaryLengthHard}`
1199
+ });
1200
+ }
1201
+ if (compress.minCompressRange !== void 0 && typeof compress.minCompressRange !== "number") {
1202
+ errors.push({
1203
+ key: "compress.minCompressRange",
1204
+ expected: "number",
1205
+ actual: typeof compress.minCompressRange
1206
+ });
1207
+ }
1208
+ if (typeof compress.minCompressRange === "number" && compress.minCompressRange < 0) {
1209
+ errors.push({
1210
+ key: "compress.minCompressRange",
1211
+ expected: "non-negative number (>= 0)",
1212
+ actual: `${compress.minCompressRange}`
1213
+ });
1214
+ }
1155
1215
  if (typeof compress.iterationNudgeThreshold === "number" && compress.iterationNudgeThreshold < 1) {
1156
1216
  errors.push({
1157
1217
  key: "compress.iterationNudgeThreshold",
@@ -1385,8 +1445,6 @@ var DEFAULT_PROTECTED_TOOLS = [
1385
1445
  "todoread",
1386
1446
  "compress",
1387
1447
  "decompress",
1388
- "mark_block",
1389
- "unmark_block",
1390
1448
  "batch",
1391
1449
  "plan_enter",
1392
1450
  "plan_exit",
@@ -1461,11 +1519,15 @@ var defaultConfig = {
1461
1519
  maxContextLimit: "55%",
1462
1520
  minContextLimit: "45%",
1463
1521
  nudgeFrequency: 5,
1522
+ perMessageNudgeGrowthPercent: 3,
1464
1523
  iterationNudgeThreshold: 15,
1465
1524
  nudgeForce: "soft",
1466
1525
  protectedTools: [...COMPRESS_DEFAULT_PROTECTED_TOOLS],
1467
1526
  protectTags: false,
1468
- protectUserMessages: false
1527
+ protectUserMessages: false,
1528
+ maxSummaryLength: 200,
1529
+ maxSummaryLengthHard: 800,
1530
+ minCompressRange: 2e3
1469
1531
  },
1470
1532
  strategies: {
1471
1533
  deduplication: {
@@ -1612,11 +1674,15 @@ function mergeCompress(base, override) {
1612
1674
  modelMaxLimits: override.modelMaxLimits ?? base.modelMaxLimits,
1613
1675
  modelMinLimits: override.modelMinLimits ?? base.modelMinLimits,
1614
1676
  nudgeFrequency: override.nudgeFrequency ?? base.nudgeFrequency,
1677
+ perMessageNudgeGrowthPercent: override.perMessageNudgeGrowthPercent ?? base.perMessageNudgeGrowthPercent,
1615
1678
  iterationNudgeThreshold: override.iterationNudgeThreshold ?? base.iterationNudgeThreshold,
1616
1679
  nudgeForce: override.nudgeForce ?? base.nudgeForce,
1617
1680
  protectedTools: [.../* @__PURE__ */ new Set([...base.protectedTools, ...override.protectedTools ?? []])],
1618
1681
  protectTags: override.protectTags ?? base.protectTags,
1619
- protectUserMessages: override.protectUserMessages ?? base.protectUserMessages
1682
+ protectUserMessages: override.protectUserMessages ?? base.protectUserMessages,
1683
+ maxSummaryLength: override.maxSummaryLength ?? base.maxSummaryLength,
1684
+ maxSummaryLengthHard: override.maxSummaryLengthHard ?? base.maxSummaryLengthHard,
1685
+ minCompressRange: override.minCompressRange ?? base.minCompressRange
1620
1686
  };
1621
1687
  }
1622
1688
  function mergeCommands(base, override) {
@@ -1980,6 +2046,20 @@ function countAllMessageTokens(msg) {
1980
2046
  if (texts.length === 0) return 0;
1981
2047
  return estimateTokensBatch(texts);
1982
2048
  }
2049
+ function countMessageCharacters(msg) {
2050
+ const parts = Array.isArray(msg.parts) ? msg.parts : [];
2051
+ let total = 0;
2052
+ for (const part of parts) {
2053
+ if (part.type === "text" && typeof part.text === "string") {
2054
+ total += part.text.length;
2055
+ } else {
2056
+ for (const content of extractToolContent(part)) {
2057
+ total += content.length;
2058
+ }
2059
+ }
2060
+ }
2061
+ return total;
2062
+ }
1983
2063
 
1984
2064
  // lib/prompts/extensions/tool.ts
1985
2065
  var RANGE_FORMAT_EXTENSION = `
@@ -3019,7 +3099,9 @@ function resetOnCompaction(state) {
3019
3099
  state.nudges = {
3020
3100
  contextLimitAnchors: /* @__PURE__ */ new Set(),
3021
3101
  turnNudgeAnchors: /* @__PURE__ */ new Set(),
3022
- iterationNudgeAnchors: /* @__PURE__ */ new Set()
3102
+ iterationNudgeAnchors: /* @__PURE__ */ new Set(),
3103
+ lastPerMessageNudgeTurn: 0,
3104
+ lastPerMessageNudgeTokens: 0
3023
3105
  };
3024
3106
  state.messageIds = {
3025
3107
  byRawId: /* @__PURE__ */ new Map(),
@@ -3085,7 +3167,9 @@ async function saveSessionState(sessionState, logger, sessionName) {
3085
3167
  nudges: {
3086
3168
  contextLimitAnchors: Array.from(sessionState.nudges.contextLimitAnchors),
3087
3169
  turnNudgeAnchors: Array.from(sessionState.nudges.turnNudgeAnchors),
3088
- iterationNudgeAnchors: Array.from(sessionState.nudges.iterationNudgeAnchors)
3170
+ iterationNudgeAnchors: Array.from(sessionState.nudges.iterationNudgeAnchors),
3171
+ lastPerMessageNudgeTurn: sessionState.nudges.lastPerMessageNudgeTurn ?? 0,
3172
+ lastPerMessageNudgeTokens: sessionState.nudges.lastPerMessageNudgeTokens ?? 0
3089
3173
  },
3090
3174
  stats: sessionState.stats,
3091
3175
  lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
@@ -3299,7 +3383,9 @@ function createSessionState() {
3299
3383
  nudges: {
3300
3384
  contextLimitAnchors: /* @__PURE__ */ new Set(),
3301
3385
  turnNudgeAnchors: /* @__PURE__ */ new Set(),
3302
- iterationNudgeAnchors: /* @__PURE__ */ new Set()
3386
+ iterationNudgeAnchors: /* @__PURE__ */ new Set(),
3387
+ lastPerMessageNudgeTurn: 0,
3388
+ lastPerMessageNudgeTokens: 0
3303
3389
  },
3304
3390
  stats: {
3305
3391
  pruneTokenCounter: 0,
@@ -3336,7 +3422,9 @@ function resetSessionState(state) {
3336
3422
  state.nudges = {
3337
3423
  contextLimitAnchors: /* @__PURE__ */ new Set(),
3338
3424
  turnNudgeAnchors: /* @__PURE__ */ new Set(),
3339
- iterationNudgeAnchors: /* @__PURE__ */ new Set()
3425
+ iterationNudgeAnchors: /* @__PURE__ */ new Set(),
3426
+ lastPerMessageNudgeTurn: 0,
3427
+ lastPerMessageNudgeTokens: 0
3340
3428
  };
3341
3429
  state.stats = {
3342
3430
  pruneTokenCounter: 0,
@@ -3381,6 +3469,8 @@ async function ensureSessionInitialized(client, state, sessionId, logger, messag
3381
3469
  state.nudges.iterationNudgeAnchors = new Set(
3382
3470
  persisted.nudges.iterationNudgeAnchors || []
3383
3471
  );
3472
+ state.nudges.lastPerMessageNudgeTurn = persisted.nudges.lastPerMessageNudgeTurn ?? 0;
3473
+ state.nudges.lastPerMessageNudgeTokens = persisted.nudges.lastPerMessageNudgeTokens ?? 0;
3384
3474
  state.stats = {
3385
3475
  pruneTokenCounter: persisted.stats?.pruneTokenCounter || 0,
3386
3476
  totalPruneTokens: persisted.stats?.totalPruneTokens || 0
@@ -3530,20 +3620,20 @@ function matchesGlob(inputPath, pattern) {
3530
3620
  regex += "$";
3531
3621
  return new RegExp(regex).test(input);
3532
3622
  }
3533
- function getFilePathsFromParameters(tool5, parameters) {
3623
+ function getFilePathsFromParameters(tool4, parameters) {
3534
3624
  if (typeof parameters !== "object" || parameters === null) {
3535
3625
  return [];
3536
3626
  }
3537
3627
  const paths = [];
3538
3628
  const params = parameters;
3539
- if (tool5 === "apply_patch" && typeof params.patchText === "string") {
3629
+ if (tool4 === "apply_patch" && typeof params.patchText === "string") {
3540
3630
  const pathRegex = /\*\*\* (?:Add|Delete|Update) File: ([^\n\r]+)/g;
3541
3631
  let match;
3542
3632
  while ((match = pathRegex.exec(params.patchText)) !== null) {
3543
3633
  paths.push(match[1].trim());
3544
3634
  }
3545
3635
  }
3546
- if (tool5 === "multiedit") {
3636
+ if (tool4 === "multiedit") {
3547
3637
  if (typeof params.filePath === "string") {
3548
3638
  paths.push(params.filePath);
3549
3639
  }
@@ -3638,13 +3728,13 @@ var deduplicate = (state, logger, config, messages) => {
3638
3728
  logger.debug(`Marked ${newPruneIds.length} duplicate tool calls for pruning`);
3639
3729
  }
3640
3730
  };
3641
- function createToolSignature(tool5, parameters) {
3731
+ function createToolSignature(tool4, parameters) {
3642
3732
  if (!parameters) {
3643
- return tool5;
3733
+ return tool4;
3644
3734
  }
3645
3735
  const normalized = normalizeParameters(parameters);
3646
3736
  const sorted = sortObjectKeys(normalized);
3647
- return `${tool5}::${JSON.stringify(sorted)}`;
3737
+ return `${tool4}::${JSON.stringify(sorted)}`;
3648
3738
  }
3649
3739
  function normalizeParameters(params) {
3650
3740
  if (typeof params !== "object" || params === null) return params;
@@ -3719,9 +3809,9 @@ var purgeErrors = (state, logger, config, messages) => {
3719
3809
  };
3720
3810
 
3721
3811
  // lib/ui/utils.ts
3722
- function extractParameterKey(tool5, parameters) {
3812
+ function extractParameterKey(tool4, parameters) {
3723
3813
  if (!parameters) return "";
3724
- if (tool5 === "read" && parameters.filePath) {
3814
+ if (tool4 === "read" && parameters.filePath) {
3725
3815
  const offset = parameters.offset;
3726
3816
  const limit = parameters.limit;
3727
3817
  if (offset !== void 0 && limit !== void 0) {
@@ -3735,10 +3825,10 @@ function extractParameterKey(tool5, parameters) {
3735
3825
  }
3736
3826
  return parameters.filePath;
3737
3827
  }
3738
- if ((tool5 === "write" || tool5 === "edit" || tool5 === "multiedit") && parameters.filePath) {
3828
+ if ((tool4 === "write" || tool4 === "edit" || tool4 === "multiedit") && parameters.filePath) {
3739
3829
  return parameters.filePath;
3740
3830
  }
3741
- if (tool5 === "apply_patch" && typeof parameters.patchText === "string") {
3831
+ if (tool4 === "apply_patch" && typeof parameters.patchText === "string") {
3742
3832
  const pathRegex = /\*\*\* (?:Add|Delete|Update) File: ([^\n\r]+)/g;
3743
3833
  const paths = [];
3744
3834
  let match;
@@ -3755,51 +3845,51 @@ function extractParameterKey(tool5, parameters) {
3755
3845
  }
3756
3846
  return "patch";
3757
3847
  }
3758
- if (tool5 === "list") {
3848
+ if (tool4 === "list") {
3759
3849
  return parameters.path || "(current directory)";
3760
3850
  }
3761
- if (tool5 === "glob") {
3851
+ if (tool4 === "glob") {
3762
3852
  if (parameters.pattern) {
3763
3853
  const pathInfo = parameters.path ? ` in ${parameters.path}` : "";
3764
3854
  return `"${parameters.pattern}"${pathInfo}`;
3765
3855
  }
3766
3856
  return "(unknown pattern)";
3767
3857
  }
3768
- if (tool5 === "grep") {
3858
+ if (tool4 === "grep") {
3769
3859
  if (parameters.pattern) {
3770
3860
  const pathInfo = parameters.path ? ` in ${parameters.path}` : "";
3771
3861
  return `"${parameters.pattern}"${pathInfo}`;
3772
3862
  }
3773
3863
  return "(unknown pattern)";
3774
3864
  }
3775
- if (tool5 === "bash") {
3865
+ if (tool4 === "bash") {
3776
3866
  if (parameters.description) return parameters.description;
3777
3867
  if (parameters.command) {
3778
3868
  return parameters.command.length > 50 ? parameters.command.substring(0, 50) + "..." : parameters.command;
3779
3869
  }
3780
3870
  }
3781
- if (tool5 === "webfetch" && parameters.url) {
3871
+ if (tool4 === "webfetch" && parameters.url) {
3782
3872
  return parameters.url;
3783
3873
  }
3784
- if (tool5 === "websearch" && parameters.query) {
3874
+ if (tool4 === "websearch" && parameters.query) {
3785
3875
  return `"${parameters.query}"`;
3786
3876
  }
3787
- if (tool5 === "codesearch" && parameters.query) {
3877
+ if (tool4 === "codesearch" && parameters.query) {
3788
3878
  return `"${parameters.query}"`;
3789
3879
  }
3790
- if (tool5 === "todowrite") {
3880
+ if (tool4 === "todowrite") {
3791
3881
  return `${parameters.todos?.length || 0} todos`;
3792
3882
  }
3793
- if (tool5 === "todoread") {
3883
+ if (tool4 === "todoread") {
3794
3884
  return "read todo list";
3795
3885
  }
3796
- if (tool5 === "task" && parameters.description) {
3886
+ if (tool4 === "task" && parameters.description) {
3797
3887
  return parameters.description;
3798
3888
  }
3799
- if (tool5 === "skill" && parameters.name) {
3889
+ if (tool4 === "skill" && parameters.name) {
3800
3890
  return parameters.name;
3801
3891
  }
3802
- if (tool5 === "lsp") {
3892
+ if (tool4 === "lsp") {
3803
3893
  const op = parameters.operation || "lsp";
3804
3894
  const path = parameters.filePath || "";
3805
3895
  const line = parameters.line;
@@ -3812,7 +3902,7 @@ function extractParameterKey(tool5, parameters) {
3812
3902
  }
3813
3903
  return op;
3814
3904
  }
3815
- if (tool5 === "question") {
3905
+ if (tool4 === "question") {
3816
3906
  const questions = parameters.questions;
3817
3907
  if (Array.isArray(questions) && questions.length > 0) {
3818
3908
  const headers = questions.map((q) => q.header || "").filter(Boolean).slice(0, 3);
@@ -4391,7 +4481,7 @@ ${output}`);
4391
4481
  }
4392
4482
 
4393
4483
  // lib/compress/message.ts
4394
- function buildSchema() {
4484
+ function buildSchema(maxSummaryLength) {
4395
4485
  return {
4396
4486
  topic: tool.schema.string().describe(
4397
4487
  "Short label (3-5 words) for the overall batch - e.g., 'Closed Research Notes'"
@@ -4400,7 +4490,9 @@ function buildSchema() {
4400
4490
  tool.schema.object({
4401
4491
  messageId: tool.schema.string().describe("Raw message ID to compress (e.g. m00001)"),
4402
4492
  topic: tool.schema.string().describe("Short label (3-5 words) for this one message summary"),
4403
- summary: tool.schema.string().describe("Complete technical summary replacing that one message")
4493
+ summary: tool.schema.string().describe(
4494
+ `Complete technical summary replacing that one message. Aim for <=${maxSummaryLength} chars; exceed only when strictly necessary to preserve critical detail (file paths, decisions, signatures, exact values). Never pad.`
4495
+ )
4404
4496
  })
4405
4497
  ).describe("Batch of individual message summaries to create in one tool call")
4406
4498
  };
@@ -4410,10 +4502,18 @@ function createCompressMessageTool(ctx) {
4410
4502
  const runtimePrompts = ctx.prompts.getRuntimePrompts();
4411
4503
  return tool({
4412
4504
  description: runtimePrompts.compressMessage + MESSAGE_FORMAT_EXTENSION,
4413
- args: buildSchema(),
4505
+ args: buildSchema(ctx.config.compress.maxSummaryLength),
4414
4506
  async execute(args, toolCtx) {
4415
4507
  const input = args;
4416
4508
  validateArgs(input);
4509
+ const maxSummaryLengthHard = ctx.config.compress.maxSummaryLengthHard;
4510
+ for (const entry of input.content) {
4511
+ if (entry.summary.length > maxSummaryLengthHard) {
4512
+ throw new Error(
4513
+ `Summary too long (${entry.summary.length} chars, hard ceiling ${maxSummaryLengthHard}). Aim for <=${ctx.config.compress.maxSummaryLength}; exceed only when strictly necessary. Rewrite more concisely.`
4514
+ );
4515
+ }
4516
+ }
4417
4517
  const callId = typeof toolCtx.callID === "string" ? toolCtx.callID : void 0;
4418
4518
  const { rawMessages, searchContext } = await prepareSession(
4419
4519
  ctx,
@@ -4429,6 +4529,26 @@ function createCompressMessageTool(ctx) {
4429
4529
  if (plans.length === 0 && skippedCount > 0) {
4430
4530
  throw new Error(formatIssues(skippedIssues, skippedCount));
4431
4531
  }
4532
+ const minCompressRange = ctx.config.compress.minCompressRange;
4533
+ if (minCompressRange > 0) {
4534
+ let totalChars = 0;
4535
+ const counted = /* @__PURE__ */ new Set();
4536
+ for (const plan of plans) {
4537
+ for (const messageId of plan.selection.messageIds) {
4538
+ if (counted.has(messageId)) continue;
4539
+ counted.add(messageId);
4540
+ const rawMessage = searchContext.rawMessagesById.get(messageId);
4541
+ if (rawMessage) {
4542
+ totalChars += countMessageCharacters(rawMessage);
4543
+ }
4544
+ }
4545
+ }
4546
+ if (totalChars < minCompressRange) {
4547
+ throw new Error(
4548
+ `Range too small (${totalChars} chars, min ${minCompressRange}). Not worth compressing \u2014 overhead exceeds savings.`
4549
+ );
4550
+ }
4551
+ }
4432
4552
  const notifications = [];
4433
4553
  const preparedPlans = [];
4434
4554
  for (const plan of plans) {
@@ -4613,7 +4733,13 @@ function validateSummaryPlaceholders(placeholders, requiredBlockIds, startRefere
4613
4733
  }
4614
4734
  placeholders.length = 0;
4615
4735
  placeholders.push(...validPlaceholders);
4616
- return strictRequiredIds.filter((id) => !keptPlaceholderIds.has(id));
4736
+ const missingIds = strictRequiredIds.filter((id) => !keptPlaceholderIds.has(id));
4737
+ if (missingIds.length > 0) {
4738
+ console.warn(
4739
+ `[ACP] compress summary omitted placeholders for required blocks: ${missingIds.map((id) => `b${id}`).join(", ")}. They will be auto-attached as consumed blocks.`
4740
+ );
4741
+ }
4742
+ return missingIds;
4617
4743
  }
4618
4744
  function injectBlockPlaceholders(summary, _placeholders, _summaryByBlockId, _startReference, _endReference) {
4619
4745
  return {
@@ -4629,7 +4755,7 @@ function appendMissingBlockSummaries(summary, _missingBlockIds, _summaryByBlockI
4629
4755
  }
4630
4756
 
4631
4757
  // lib/compress/range.ts
4632
- function buildSchema2() {
4758
+ function buildSchema2(maxSummaryLength) {
4633
4759
  return {
4634
4760
  topic: tool2.schema.string().describe("Short label (3-5 words) for display - e.g., 'Auth System Exploration'"),
4635
4761
  content: tool2.schema.array(
@@ -4638,7 +4764,9 @@ function buildSchema2() {
4638
4764
  "Message or block ID marking the beginning of range (e.g. m00001, b2)"
4639
4765
  ),
4640
4766
  endId: tool2.schema.string().describe("Message or block ID marking the end of range (e.g. m00012, b5)"),
4641
- summary: tool2.schema.string().describe("Complete technical summary replacing all content in range")
4767
+ summary: tool2.schema.string().describe(
4768
+ `Complete technical summary replacing all content in range. Aim for <=${maxSummaryLength} chars; exceed only when strictly necessary to preserve critical detail (file paths, decisions, signatures, exact values). Never pad.`
4769
+ )
4642
4770
  })
4643
4771
  ).describe(
4644
4772
  "One or more ranges to compress, each with start/end boundaries and a summary"
@@ -4650,10 +4778,18 @@ function createCompressRangeTool(ctx) {
4650
4778
  const runtimePrompts = ctx.prompts.getRuntimePrompts();
4651
4779
  return tool2({
4652
4780
  description: runtimePrompts.compressRange + RANGE_FORMAT_EXTENSION,
4653
- args: buildSchema2(),
4781
+ args: buildSchema2(ctx.config.compress.maxSummaryLength),
4654
4782
  async execute(args, toolCtx) {
4655
4783
  const input = args;
4656
4784
  validateArgs2(input);
4785
+ const maxSummaryLengthHard = ctx.config.compress.maxSummaryLengthHard;
4786
+ for (const entry of input.content) {
4787
+ if (entry.summary.length > maxSummaryLengthHard) {
4788
+ throw new Error(
4789
+ `Summary too long (${entry.summary.length} chars, hard ceiling ${maxSummaryLengthHard}). Aim for <=${ctx.config.compress.maxSummaryLength}; exceed only when strictly necessary. Rewrite more concisely.`
4790
+ );
4791
+ }
4792
+ }
4657
4793
  const callId = typeof toolCtx.callID === "string" ? toolCtx.callID : void 0;
4658
4794
  const { rawMessages, searchContext } = await prepareSession(
4659
4795
  ctx,
@@ -4662,6 +4798,26 @@ function createCompressRangeTool(ctx) {
4662
4798
  );
4663
4799
  const resolvedPlans = resolveRanges(input, searchContext, ctx.state);
4664
4800
  validateNonOverlapping(resolvedPlans);
4801
+ const minCompressRange = ctx.config.compress.minCompressRange;
4802
+ if (minCompressRange > 0) {
4803
+ let totalChars = 0;
4804
+ const counted = /* @__PURE__ */ new Set();
4805
+ for (const plan of resolvedPlans) {
4806
+ for (const messageId of plan.selection.messageIds) {
4807
+ if (counted.has(messageId)) continue;
4808
+ counted.add(messageId);
4809
+ const rawMessage = searchContext.rawMessagesById.get(messageId);
4810
+ if (rawMessage) {
4811
+ totalChars += countMessageCharacters(rawMessage);
4812
+ }
4813
+ }
4814
+ }
4815
+ if (totalChars < minCompressRange) {
4816
+ throw new Error(
4817
+ `Range too small (${totalChars} chars, min ${minCompressRange}). Not worth compressing \u2014 overhead exceeds savings.`
4818
+ );
4819
+ }
4820
+ }
4665
4821
  const notifications = [];
4666
4822
  const preparedPlans = [];
4667
4823
  let totalCompressedMessages = 0;
@@ -4711,10 +4867,19 @@ function createCompressRangeTool(ctx) {
4711
4867
  searchContext.summaryByBlockId,
4712
4868
  injected.consumedBlockIds
4713
4869
  );
4714
- const mergeConsumedBlockIds = extractBoundaryConsumedBlocks(
4870
+ const boundaryConsumed = extractBoundaryConsumedBlocks(
4715
4871
  plan.selection.startReference,
4716
4872
  plan.selection.endReference
4717
4873
  );
4874
+ const seenConsumed = /* @__PURE__ */ new Set();
4875
+ const mergeConsumedBlockIds = [
4876
+ ...plan.selection.requiredBlockIds,
4877
+ ...boundaryConsumed
4878
+ ].filter((id) => {
4879
+ if (seenConsumed.has(id)) return false;
4880
+ seenConsumed.add(id);
4881
+ return true;
4882
+ });
4718
4883
  preparedPlans.push({
4719
4884
  entry: plan.entry,
4720
4885
  selection: plan.selection,
@@ -4815,7 +4980,8 @@ var createSyntheticUserMessage = (baseMessage, content, stableSeed) => {
4815
4980
  sessionID: userInfo.sessionID,
4816
4981
  messageID: messageId,
4817
4982
  type: "text",
4818
- text: content
4983
+ text: content,
4984
+ synthetic: true
4819
4985
  }
4820
4986
  ]
4821
4987
  };
@@ -4960,6 +5126,36 @@ var stripHallucinations = (messages) => {
4960
5126
  // lib/messages/prune.ts
4961
5127
  var prune = (state, logger, config, messages) => {
4962
5128
  filterCompressedRanges(state, logger, config, messages);
5129
+ stripStepMarkers(messages);
5130
+ };
5131
+ var MAX_STEP_FINISH_REASON = 50;
5132
+ var stripStepMarkers = (messages) => {
5133
+ for (const msg of messages) {
5134
+ const parts = Array.isArray(msg.parts) ? msg.parts : [];
5135
+ let changed = false;
5136
+ const filtered = [];
5137
+ for (const part of parts) {
5138
+ if (part.type === "step-start") {
5139
+ changed = true;
5140
+ continue;
5141
+ }
5142
+ if (part.type === "step-finish") {
5143
+ const reason = part.reason;
5144
+ if (typeof reason === "string" && reason.length > MAX_STEP_FINISH_REASON) {
5145
+ const truncated = reason.slice(0, MAX_STEP_FINISH_REASON) + "...";
5146
+ if (truncated !== reason) {
5147
+ filtered.push({ ...part, reason: truncated });
5148
+ changed = true;
5149
+ continue;
5150
+ }
5151
+ }
5152
+ }
5153
+ filtered.push(part);
5154
+ }
5155
+ if (changed) {
5156
+ msg.parts = filtered;
5157
+ }
5158
+ }
4963
5159
  };
4964
5160
  var filterCompressedRanges = (state, logger, config, messages) => {
4965
5161
  if (state.prune.messages.byMessageId.size === 0 && state.prune.messages.activeByAnchorMessageId.size === 0) {
@@ -5196,8 +5392,8 @@ var resolveEffectiveCompressPermission = (basePermission, hostPermissions, agent
5196
5392
  agentName ? hostPermissions.agents[agentName] : void 0
5197
5393
  ) ? "deny" : basePermission;
5198
5394
  };
5199
- var hasExplicitToolPermission = (permissionConfig, tool5) => {
5200
- return permissionConfig ? Object.prototype.hasOwnProperty.call(permissionConfig, tool5) : false;
5395
+ var hasExplicitToolPermission = (permissionConfig, tool4) => {
5396
+ return permissionConfig ? Object.prototype.hasOwnProperty.call(permissionConfig, tool4) : false;
5201
5397
  };
5202
5398
 
5203
5399
  // lib/compress-permission.ts
@@ -5225,12 +5421,47 @@ function buildCompressedBlockGuidance(state, gcConfig, context) {
5225
5421
  const recent = refs.slice(-20).join(", ");
5226
5422
  blockList = `${recent} (+${blockCount - 20} older, use decompress to access by ID)`;
5227
5423
  }
5424
+ const includeHint = context?.includeHint ?? true;
5228
5425
  const lines = [
5229
5426
  "Compressed block context:",
5230
5427
  `- Active compressed blocks: ${blockCount} (${blockList})`,
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."
5428
+ "- System auto-detects blocks in range \u2014 no need to manually list (bN) placeholders. Just write a short prose summary."
5233
5429
  ];
5430
+ if (includeHint) {
5431
+ lines.push("- \u{1F4A1} When you've finished using tool outputs, compress them \u2014 you can decompress later if needed. Lean context improves accuracy.");
5432
+ }
5433
+ if (blockCount > 50) {
5434
+ const oldBlockIds = activeBlockIds.slice(0, Math.max(0, blockCount - 20));
5435
+ const allOldBlocks = oldBlockIds.map((id) => state.prune.messages.blocksById.get(id)).filter((b) => b !== void 0);
5436
+ const visibleMessageIds = context?.visibleMessageIds;
5437
+ const visibleOldBlocks = visibleMessageIds === void 0 ? allOldBlocks : allOldBlocks.filter((b) => b.anchorMessageId && visibleMessageIds.has(b.anchorMessageId));
5438
+ if (visibleOldBlocks.length > 5) {
5439
+ const blocksWithRef = visibleOldBlocks.map((block) => {
5440
+ const ref = state.messageIds.byRawId.get(block.anchorMessageId);
5441
+ return ref ? { block, ref } : null;
5442
+ }).filter((x) => x !== null).sort((a, b) => a.ref.localeCompare(b.ref));
5443
+ const totalTokens = blocksWithRef.reduce((s, x) => s + (x.block.summaryTokens ?? 0), 0);
5444
+ const totalK = Math.max(1, Math.round(totalTokens / 1e3));
5445
+ const targets = [];
5446
+ const chunkSize = Math.ceil(blocksWithRef.length / 3);
5447
+ for (let i = 0; i < 3 && i * chunkSize < blocksWithRef.length; i++) {
5448
+ const chunk = blocksWithRef.slice(i * chunkSize, (i + 1) * chunkSize);
5449
+ if (chunk.length < 2) continue;
5450
+ const startRef = chunk[0].ref;
5451
+ const endRef = chunk[chunk.length - 1].ref;
5452
+ const chunkTokens = chunk.reduce((s, x) => s + (x.block.summaryTokens ?? 0), 0);
5453
+ const chunkK = Math.max(1, Math.round(chunkTokens / 1e3));
5454
+ targets.push(` \u2022 compress ${startRef}\u2192${endRef}: ${chunk.length} blocks (~${chunkK}K tokens)`);
5455
+ }
5456
+ if (targets.length > 0) {
5457
+ lines.push(`- \u{1F500} ${blocksWithRef.length} old blocks using ~${totalK}K tokens. Consolidate into ${targets.length}:`);
5458
+ lines.push(...targets);
5459
+ lines.push(` System auto-detects blocks in range \u2014 no need to manually list (bN) placeholders. Just write a short prose summary.`);
5460
+ }
5461
+ } else {
5462
+ lines.push(`- \u{1F500} You have ${blockCount} blocks \u2014 use compress to consolidate adjacent same-topic blocks.`);
5463
+ }
5464
+ }
5234
5465
  const usageRatio = context?.currentTokens && context?.modelContextLimit ? context.currentTokens / context.modelContextLimit : 0;
5235
5466
  if (gcConfig && usageRatio > 0.5) {
5236
5467
  const promotionThreshold = gcConfig.promotionThreshold;
@@ -5573,7 +5804,7 @@ function resolveThresholdPercent(threshold, modelContextLimit) {
5573
5804
  const parsed = parseFloat(threshold);
5574
5805
  return isNaN(parsed) ? void 0 : parsed;
5575
5806
  }
5576
- function buildContextUsageGuidance(config, currentTokens, modelContextLimit) {
5807
+ function buildContextUsageGuidance(config, currentTokens, modelContextLimit, minimal = false) {
5577
5808
  if (currentTokens === void 0 || modelContextLimit === void 0 || modelContextLimit === 0) {
5578
5809
  return "";
5579
5810
  }
@@ -5583,13 +5814,18 @@ function buildContextUsageGuidance(config, currentTokens, modelContextLimit) {
5583
5814
  const minPct = resolveThresholdPercent(config.compress.minContextLimit, modelContextLimit) ?? 45;
5584
5815
  const maxPct = resolveThresholdPercent(config.compress.maxContextLimit, modelContextLimit) ?? 55;
5585
5816
  const base = `Context usage: ${formatK(currentTokens)} / ${formatK(modelContextLimit)} tokens (${percentage}%).`;
5817
+ if (minimal) {
5818
+ return `
5819
+
5820
+ ${base}`;
5821
+ }
5586
5822
  let guidance;
5587
5823
  if (pct < minPct) {
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.";
5824
+ guidance = " \u{1F4A1} Be frugal with context. If any visible tool output exceeds 5000 characters and you've finished reading it, compress it into a summary now \u2014 don't keep large outputs 'just in case'. You can decompress later if needed.";
5589
5825
  } else if (pct < maxPct) {
5590
- guidance = " \u26A0\uFE0F Context is growing \u2014 compress completed sections and high-token waste now. Preserve key details.";
5826
+ guidance = " \u26A0\uFE0F Context is growing \u2014 compress completed sections and high-token waste now.";
5591
5827
  } else {
5592
- guidance = " \u{1F525} Context is high \u2014 compress aggressively but selectively. Preserve only what is essential.";
5828
+ guidance = " \u{1F525} Context is high \u2014 compress aggressively, preserve only what is essential.";
5593
5829
  }
5594
5830
  return `
5595
5831
 
@@ -5687,6 +5923,18 @@ function createSuffixMessage(messages) {
5687
5923
  messages.push(synthetic);
5688
5924
  return synthetic;
5689
5925
  }
5926
+ function shouldInjectPerMessageNudge(state, config, currentTokens, modelContextLimit) {
5927
+ const turn = state.currentTurn ?? 0;
5928
+ const lastTurn = state.nudges.lastPerMessageNudgeTurn ?? 0;
5929
+ const turnsSinceLast = turn - lastTurn;
5930
+ const tokens = currentTokens ?? 0;
5931
+ const lastTokens = state.nudges.lastPerMessageNudgeTokens ?? 0;
5932
+ const tokenGrowth = tokens - lastTokens;
5933
+ const tokenGrowthPercent = modelContextLimit ? tokenGrowth / modelContextLimit * 100 : 0;
5934
+ const frequency = config.compress.nudgeFrequency ?? 5;
5935
+ const growthThreshold = config.compress.perMessageNudgeGrowthPercent ?? 3;
5936
+ return turnsSinceLast >= frequency || tokenGrowthPercent >= growthThreshold;
5937
+ }
5690
5938
  var injectCompressNudges = (state, config, logger, messages, prompts, compressionPriorities) => {
5691
5939
  if (compressPermission(state, config) === "deny") {
5692
5940
  return;
@@ -5771,21 +6019,34 @@ var injectCompressNudges = (state, config, logger, messages, prompts, compressio
5771
6019
  }
5772
6020
  const suffixMessage = createSuffixMessage(messages);
5773
6021
  applyAnchoredNudges(state, config, messages, prompts, compressionPriorities, currentTokens, modelContextLimit, suffixMessage);
5774
- injectContextUsage(suffixMessage, config, currentTokens, modelContextLimit);
6022
+ const shouldNudge = shouldInjectPerMessageNudge(state, config, currentTokens, modelContextLimit);
6023
+ injectContextUsage(suffixMessage, config, currentTokens, modelContextLimit, !shouldNudge);
5775
6024
  if (config.compress.mode !== "message") {
5776
- const blockGuidance = buildCompressedBlockGuidance(state, config.gc, { currentTokens, modelContextLimit });
6025
+ const visibleMessageIds = new Set(
6026
+ messages.map((message) => message.info.id)
6027
+ );
6028
+ const blockGuidance = buildCompressedBlockGuidance(state, config.gc, {
6029
+ currentTokens,
6030
+ modelContextLimit,
6031
+ includeHint: shouldNudge,
6032
+ visibleMessageIds
6033
+ });
5777
6034
  if (blockGuidance.trim() && suffixMessage) {
5778
6035
  appendToLastTextPart(suffixMessage, "\n\n" + blockGuidance);
5779
6036
  }
5780
6037
  }
6038
+ if (shouldNudge) {
6039
+ state.nudges.lastPerMessageNudgeTurn = state.currentTurn ?? 0;
6040
+ state.nudges.lastPerMessageNudgeTokens = currentTokens ?? 0;
6041
+ }
5781
6042
  injectVisibleIdRange(state, messages, suffixMessage);
5782
6043
  if (anchorsChanged) {
5783
6044
  void saveSessionState(state, logger);
5784
6045
  }
5785
6046
  };
5786
- function injectContextUsage(target, config, currentTokens, modelContextLimit) {
6047
+ function injectContextUsage(target, config, currentTokens, modelContextLimit, minimal = false) {
5787
6048
  if (!target) return;
5788
- const usageTag = buildContextUsageGuidance(config, currentTokens, modelContextLimit);
6049
+ const usageTag = buildContextUsageGuidance(config, currentTokens, modelContextLimit, minimal);
5789
6050
  if (!usageTag) return;
5790
6051
  for (const part of target.parts) {
5791
6052
  if (part.type === "text") {
@@ -6316,109 +6577,6 @@ function createDecompressTool(ctx) {
6316
6577
  });
6317
6578
  }
6318
6579
 
6319
- // lib/compress/mark-block.ts
6320
- import { tool as tool4 } from "@opencode-ai/plugin";
6321
- async function prepareMarkSession(ctx, toolCtx) {
6322
- await toolCtx.ask({
6323
- permission: "compress",
6324
- patterns: ["*"],
6325
- always: ["*"],
6326
- metadata: {}
6327
- });
6328
- toolCtx.metadata({ title: "Mark block" });
6329
- const rawMessages = await fetchSessionMessages(ctx.client, toolCtx.sessionID);
6330
- await ensureSessionInitialized(
6331
- ctx.client,
6332
- ctx.state,
6333
- toolCtx.sessionID,
6334
- ctx.logger,
6335
- rawMessages,
6336
- ctx.config.manualMode.enabled
6337
- );
6338
- assignMessageRefs(ctx.state, rawMessages);
6339
- }
6340
- var MARK_DESCRIPTION = `Marks a compressed block for batch merge-cleanup.
6341
-
6342
- Use this for blocks whose detailed content you no longer need, but whose summaries
6343
- you want to keep in context for now (to preserve prompt cache). Marked blocks stay
6344
- fully active with zero immediate effect on context or cache. When context pressure
6345
- rises, all marked blocks are merge-compressed together into a single summary in one
6346
- cache break, instead of being handled one at a time.
6347
-
6348
- Argument: blockId \u2014 the block reference to mark (e.g., "b1", "b3")
6349
-
6350
- Use mark_block instead of compress when you want deferred cleanup: the block keeps
6351
- serving cache hits now and gets consolidated later only if context gets tight.`;
6352
- var UNMARK_DESCRIPTION = `Removes the batch cleanup mark from a compressed block.
6353
-
6354
- Reverses mark_block. The block returns to normal handling and will not be
6355
- auto-merged during batch cleanup.
6356
-
6357
- Argument: blockId \u2014 the block reference to unmark (e.g., "b1", "b3")`;
6358
- function buildSchema4() {
6359
- return {
6360
- blockId: tool4.schema.string().describe('Block reference to mark (e.g., "b1", "b3")')
6361
- };
6362
- }
6363
- function buildUnmarkSchema() {
6364
- return {
6365
- blockId: tool4.schema.string().describe('Block reference to unmark (e.g., "b1", "b3")')
6366
- };
6367
- }
6368
- function createMarkBlockTool(ctx) {
6369
- return tool4({
6370
- description: MARK_DESCRIPTION,
6371
- args: buildSchema4(),
6372
- async execute(args, toolCtx) {
6373
- await prepareMarkSession(ctx, toolCtx);
6374
- const targetBlockId = parseBlockRef(String(args.blockId));
6375
- if (targetBlockId === null) {
6376
- return `Error: Invalid block ID "${args.blockId}". Use format "b0", "b1", etc.`;
6377
- }
6378
- const messagesState = ctx.state.prune.messages;
6379
- const block = messagesState.blocksById.get(targetBlockId);
6380
- if (!block) {
6381
- return `Error: Block ${formatBlockRef(targetBlockId)} does not exist.`;
6382
- }
6383
- if (!block.active) {
6384
- return `Error: Block ${formatBlockRef(targetBlockId)} is not active.`;
6385
- }
6386
- messagesState.markedForCleanup.add(targetBlockId);
6387
- await saveSessionState(ctx.state, ctx.logger);
6388
- const ref = formatBlockRef(targetBlockId);
6389
- const markedCount = messagesState.markedForCleanup.size;
6390
- ctx.logger.info("mark_block: block marked for cleanup", {
6391
- blockId: targetBlockId,
6392
- markedCount
6393
- });
6394
- 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.)`;
6395
- }
6396
- });
6397
- }
6398
- function createUnmarkBlockTool(ctx) {
6399
- return tool4({
6400
- description: UNMARK_DESCRIPTION,
6401
- args: buildUnmarkSchema(),
6402
- async execute(args, toolCtx) {
6403
- await prepareMarkSession(ctx, toolCtx);
6404
- const targetBlockId = parseBlockRef(String(args.blockId));
6405
- if (targetBlockId === null) {
6406
- return `Error: Invalid block ID "${args.blockId}". Use format "b0", "b1", etc.`;
6407
- }
6408
- const messagesState = ctx.state.prune.messages;
6409
- if (!messagesState.markedForCleanup.has(targetBlockId)) {
6410
- return `Block ${formatBlockRef(targetBlockId)} was not marked for cleanup.`;
6411
- }
6412
- messagesState.markedForCleanup.delete(targetBlockId);
6413
- await saveSessionState(ctx.state, ctx.logger);
6414
- ctx.logger.info("unmark_block: block unmarked", {
6415
- blockId: targetBlockId
6416
- });
6417
- return `Block ${formatBlockRef(targetBlockId)} unmarked. It will no longer be auto-merged during batch cleanup.`;
6418
- }
6419
- });
6420
- }
6421
-
6422
6580
  // lib/logger.ts
6423
6581
  import { writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
6424
6582
  import { join as join3 } from "path";
@@ -6627,7 +6785,7 @@ var SYSTEM = `
6627
6785
 
6628
6786
  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.
6629
6787
 
6630
- 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.
6788
+ 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.
6631
6789
 
6632
6790
  \`<dcp-message-id>\` and \`<dcp-system-reminder>\` tags are environment-injected metadata. Do not output them.
6633
6791
 
@@ -6641,9 +6799,9 @@ Target the largest UNCOMPRESSED content first. Savings scale with original size
6641
6799
 
6642
6800
  CONTEXT PRESSURE LEVELS
6643
6801
 
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.
6802
+ - Normal: Be frugal \u2014 compress large completed outputs into summaries. You can decompress later if needed.
6803
+ - Elevated: Context is growing \u2014 compress completed sections and high-token waste now.
6804
+ - Critical: Compress aggressively now \u2014 preserve only what is essential for the current task.
6647
6805
 
6648
6806
  WHAT TO COMPRESS FIRST (high value, low risk)
6649
6807
 
@@ -6707,33 +6865,18 @@ Directly quote user messages when they are short enough to include safely. Direc
6707
6865
  Yet be LEAN. Strip away the noise: failed attempts that led nowhere, verbose tool outputs, back-and-forth exploration. What remains should be pure signal - golden nuggets of detail that preserve full understanding with zero ambiguity.
6708
6866
 
6709
6867
  COMPRESSED BLOCK PLACEHOLDERS
6710
- When the selected range includes previously compressed blocks, use this exact placeholder format when referencing one:
6711
-
6712
- - \`(bN)\`
6868
+ The system auto-detects any previously compressed blocks whose anchor messages fall inside your selected range. You do NOT need to manually list \`(bN)\` placeholders in your summary \u2014 every consumed block is tracked automatically.
6713
6869
 
6714
6870
  Compressed block sections in context are clearly marked with a header:
6715
6871
 
6716
6872
  - \`[Compressed conversation section]\`
6717
6873
 
6718
- Compressed block IDs always use the \`bN\` form (never \`mNNNNN\`) and are represented in the same XML metadata tag format.
6719
-
6720
6874
  Rules:
6721
6875
 
6722
- - Include every required block placeholder exactly once.
6876
+ - Write a short prose summary. The system handles block consumption automatically.
6723
6877
  - Do not invent placeholders for blocks outside the selected range.
6724
- - Treat \`(bN)\` placeholders as RESERVED TOKENS. Do not emit \`(bN)\` text anywhere except intentional placeholders.
6725
- - If you need to mention a block in prose, use plain text like \`compressed bN\` (not as a placeholder).
6726
- - Preflight check before finalizing: the set of \`(bN)\` placeholders in your summary must exactly match the required set, with no duplicates.
6727
-
6728
- These placeholders are semantic references. They will be replaced with the full stored compressed block content when the tool processes your output.
6729
-
6730
- FLOW PRESERVATION WITH PLACEHOLDERS
6731
- When you use compressed block placeholders, write the surrounding summary text so it still reads correctly AFTER placeholder expansion.
6732
-
6733
- - Treat each placeholder as a stand-in for a full conversation segment, not as a short label.
6734
- - Ensure transitions before and after each placeholder preserve chronology and causality.
6735
- - Do not write text that depends on the placeholder staying literal (for example, "as noted in \`(b2)\`").
6736
- - Your final meaning must be coherent once each placeholder is replaced with its full compressed block content.
6878
+ - Treat \`(bN)\` as a RESERVED TOKEN. Do not emit \`(bN)\` text anywhere in the summary.
6879
+ - If you need to mention a block in prose, use plain text like \`compressed bN\` (never as a placeholder).
6737
6880
 
6738
6881
  BOUNDARY IDS
6739
6882
  You specify boundaries by ID using the injected IDs visible in the conversation:
@@ -7658,7 +7801,7 @@ var COMPRESS_TRIGGER_PROMPT = [
7658
7801
  "Follow the active compress mode, preserve all critical implementation details, and choose safe targets.",
7659
7802
  "Return after compress with a brief explanation of what content was compressed."
7660
7803
  ].join("\n\n");
7661
- function getTriggerPrompt(tool5, state, config, userFocus) {
7804
+ function getTriggerPrompt(tool4, state, config, userFocus) {
7662
7805
  const base = COMPRESS_TRIGGER_PROMPT;
7663
7806
  const compressedBlockGuidance = config.compress.mode === "message" ? "" : buildCompressedBlockGuidance(state, config.gc);
7664
7807
  const sections = [base, compressedBlockGuidance];
@@ -7687,8 +7830,8 @@ async function handleManualToggleCommand(ctx, modeArg) {
7687
7830
  );
7688
7831
  logger.info("Manual mode toggled", { manualMode: state.manualMode });
7689
7832
  }
7690
- async function handleManualTriggerCommand(ctx, tool5, userFocus) {
7691
- return getTriggerPrompt(tool5, ctx.state, ctx.config, userFocus);
7833
+ async function handleManualTriggerCommand(ctx, tool4, userFocus) {
7834
+ return getTriggerPrompt(tool4, ctx.state, ctx.config, userFocus);
7692
7835
  }
7693
7836
  function applyPendingManualTrigger(state, messages, logger) {
7694
7837
  const pending = state.pendingManualTrigger;
@@ -8218,23 +8361,6 @@ function parseGcThreshold(limit, modelContextLimit) {
8218
8361
  }
8219
8362
 
8220
8363
  // lib/gc/merge.ts
8221
- var DEFAULT_BATCH_CLEANUP = {
8222
- lowThreshold: "55%",
8223
- highThreshold: "75%",
8224
- forceThreshold: "90%"
8225
- };
8226
- var ESCALATE_MIN_MARKED = 3;
8227
- var ESCALATE_MIN_RATIO = 0.4;
8228
- function resolveBatchCleanup(gc) {
8229
- return gc.batchCleanup ?? DEFAULT_BATCH_CLEANUP;
8230
- }
8231
- function percentToTokens(value, modelContextLimit) {
8232
- if (typeof value === "number") return value;
8233
- const percent = parseFloat(value.slice(0, -1));
8234
- if (isNaN(percent)) return modelContextLimit;
8235
- const clamped = Math.max(0, Math.min(100, Math.round(percent)));
8236
- return Math.round(clamped / 100 * modelContextLimit);
8237
- }
8238
8364
  function collectActiveOldGenBlocks(state, maxOldGenSummaryLength) {
8239
8365
  const blocks = [];
8240
8366
  const ids = Array.from(state.prune.messages.activeBlockIds).sort((a, b) => a - b);
@@ -8247,27 +8373,13 @@ function collectActiveOldGenBlocks(state, maxOldGenSummaryLength) {
8247
8373
  }
8248
8374
  return blocks;
8249
8375
  }
8250
- function collectActiveMarkedBlocks(state) {
8251
- const messagesState = state.prune.messages;
8252
- const ids = Array.from(messagesState.markedForCleanup).sort((a, b) => a - b);
8253
- const blocks = [];
8254
- for (const id of ids) {
8255
- const block = messagesState.blocksById.get(id);
8256
- if (!block || !block.active) {
8257
- messagesState.markedForCleanup.delete(id);
8258
- continue;
8259
- }
8260
- blocks.push(block);
8261
- }
8262
- return blocks;
8263
- }
8264
8376
  function extractSummaryBody(summary) {
8265
8377
  let body = summary;
8266
8378
  const headerPrefix = COMPRESSED_BLOCK_HEADER + "\n";
8267
8379
  if (body.startsWith(headerPrefix)) {
8268
8380
  body = body.slice(headerPrefix.length);
8269
8381
  }
8270
- body = body.replace(/\n<dcp-message-id[^>]*>b\d+<\/dcp-message-id>$/, "");
8382
+ body = body.replace(/\n]*>b\d+<\/dcp-message-id>$/, "");
8271
8383
  return body.trim();
8272
8384
  }
8273
8385
  function truncateMergedSummary(merged, maxLength) {
@@ -8381,55 +8493,6 @@ function mergeMarkedBlocks(state, markedIds, maxMergedLength) {
8381
8493
  const savedTokens = Math.max(0, sourceTokens - newSummaryTokens);
8382
8494
  return { mergedCount: sourceBlocks.length, savedTokens };
8383
8495
  }
8384
- function estimateTokens(blocks) {
8385
- return blocks.reduce(
8386
- (sum, block) => sum + (block.summaryTokens || Math.round(block.summary.length / 4)),
8387
- 0
8388
- );
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)` : "";
8426
- return [
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.`
8431
- ].join(" ");
8432
- }
8433
8496
  function runBatchCleanup(state, config, logger, messages) {
8434
8497
  const noop = {
8435
8498
  tier: 0,
@@ -8441,74 +8504,31 @@ function runBatchCleanup(state, config, logger, messages) {
8441
8504
  return noop;
8442
8505
  }
8443
8506
  const currentTokens = getCurrentTokenUsage(state, messages);
8444
- const limit = state.modelContextLimit;
8445
- const batchCleanup = resolveBatchCleanup(config.gc);
8446
- const maxMergedLength = config.gc.maxOldGenSummaryLength;
8447
- const forceTokens = percentToTokens(batchCleanup.forceThreshold, limit);
8448
- const highTokens = percentToTokens(batchCleanup.highThreshold, limit);
8449
- const lowTokens = percentToTokens(batchCleanup.lowThreshold, limit);
8450
- if (currentTokens >= forceTokens) {
8451
- const oldGenBlocks = collectActiveOldGenBlocks(state, maxMergedLength);
8452
- if (oldGenBlocks.length < 2) {
8453
- return noop;
8454
- }
8455
- const ids = oldGenBlocks.map((b) => b.blockId);
8456
- const result = mergeMarkedBlocks(state, ids, maxMergedLength);
8457
- if (result.mergedCount === 0) {
8458
- return noop;
8459
- }
8460
- logger.info("Batch cleanup tier 3 (force): merged old-gen blocks", {
8461
- mergedCount: result.mergedCount,
8462
- savedTokens: result.savedTokens,
8463
- currentTokens,
8464
- forceThreshold: batchCleanup.forceThreshold
8465
- });
8466
- return {
8467
- tier: 3,
8468
- action: "merge",
8469
- mergedCount: result.mergedCount,
8470
- savedTokens: result.savedTokens
8471
- };
8507
+ if (currentTokens < state.modelContextLimit) {
8508
+ return noop;
8472
8509
  }
8473
- if (currentTokens >= highTokens) {
8474
- const marked = collectActiveMarkedBlocks(state);
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
- }
8492
- }
8510
+ const maxMergedLength = config.gc.maxOldGenSummaryLength;
8511
+ const oldGenBlocks = collectActiveOldGenBlocks(state, maxMergedLength);
8512
+ if (oldGenBlocks.length < 2) {
8513
+ return noop;
8493
8514
  }
8494
- if (currentTokens >= lowTokens) {
8495
- const nudgeText = buildNudgeText(state, maxMergedLength);
8496
- if (!nudgeText) {
8497
- return noop;
8498
- }
8499
- logger.info("Batch cleanup tier 1 (low): nudge injected", {
8500
- currentTokens,
8501
- lowThreshold: batchCleanup.lowThreshold
8502
- });
8503
- return {
8504
- tier: 1,
8505
- action: "nudge",
8506
- mergedCount: 0,
8507
- savedTokens: 0,
8508
- nudgeText
8509
- };
8515
+ const ids = oldGenBlocks.map((b) => b.blockId);
8516
+ const result = mergeMarkedBlocks(state, ids, maxMergedLength);
8517
+ if (result.mergedCount === 0) {
8518
+ return noop;
8510
8519
  }
8511
- return noop;
8520
+ logger.info("Batch cleanup force fallback (100%): merged old-gen blocks", {
8521
+ mergedCount: result.mergedCount,
8522
+ savedTokens: result.savedTokens,
8523
+ currentTokens,
8524
+ contextLimit: state.modelContextLimit
8525
+ });
8526
+ return {
8527
+ tier: 3,
8528
+ action: "merge",
8529
+ mergedCount: result.mergedCount,
8530
+ savedTokens: result.savedTokens
8531
+ };
8512
8532
  }
8513
8533
 
8514
8534
  // lib/hooks.ts
@@ -8619,11 +8639,6 @@ function runMajorGC(state, config, logger, messages) {
8619
8639
  void saveSessionState(state, logger);
8620
8640
  }
8621
8641
  }
8622
- function appendBatchCleanupNudge(messages, nudgeText) {
8623
- const lastUser = getLastUserMessage(messages);
8624
- if (!lastUser) return;
8625
- appendToLastTextPart(lastUser, nudgeText);
8626
- }
8627
8642
  function createChatMessageTransformHandler(client, state, logger, config, prompts, hostPermissions) {
8628
8643
  return async (input, output) => {
8629
8644
  const receivedMessages = Array.isArray(output.messages) ? output.messages.length : 0;
@@ -8655,9 +8670,6 @@ function createChatMessageTransformHandler(client, state, logger, config, prompt
8655
8670
  buildToolIdList(state, output.messages);
8656
8671
  runMajorGC(state, config, logger, output.messages);
8657
8672
  const batchResult = runBatchCleanup(state, config, logger, output.messages);
8658
- if (batchResult.tier === 1 && batchResult.nudgeText) {
8659
- appendBatchCleanupNudge(output.messages, batchResult.nudgeText);
8660
- }
8661
8673
  if (batchResult.mergedCount > 0) {
8662
8674
  void saveSessionState(state, logger);
8663
8675
  }
@@ -9079,9 +9091,7 @@ var server = (async (ctx) => {
9079
9091
  tool: {
9080
9092
  ...config.compress.permission !== "deny" && {
9081
9093
  compress: config.compress.mode === "message" ? createCompressMessageTool(compressToolContext) : createCompressRangeTool(compressToolContext),
9082
- decompress: createDecompressTool(compressToolContext),
9083
- mark_block: createMarkBlockTool(compressToolContext),
9084
- unmark_block: createUnmarkBlockTool(compressToolContext)
9094
+ decompress: createDecompressTool(compressToolContext)
9085
9095
  }
9086
9096
  },
9087
9097
  config: async (opencodeConfig) => {
@@ -9097,7 +9107,7 @@ var server = (async (ctx) => {
9097
9107
  }
9098
9108
  const toolsToAdd = [];
9099
9109
  if (config.compress.permission !== "deny" && !config.experimental.allowSubAgents) {
9100
- toolsToAdd.push("compress", "decompress", "mark_block", "unmark_block");
9110
+ toolsToAdd.push("compress", "decompress");
9101
9111
  }
9102
9112
  if (toolsToAdd.length > 0) {
9103
9113
  const existingPrimaryTools = opencodeConfig.experimental?.primary_tools ?? [];