opencode-acp 1.8.2 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -903,6 +903,7 @@ var VALID_CONFIG_KEYS = /* @__PURE__ */ new Set([
903
903
  "compress.protectUserMessages",
904
904
  "compress.maxSummaryLengthHard",
905
905
  "compress.minCompressRange",
906
+ "compress.maxVisibleSegments",
906
907
  "gc",
907
908
  "gc.algorithm",
908
909
  "gc.promotionThreshold",
@@ -1184,6 +1185,20 @@ function validateConfigTypes(config) {
1184
1185
  actual: `${compress.minCompressRange}`
1185
1186
  });
1186
1187
  }
1188
+ if (compress.maxVisibleSegments !== void 0 && typeof compress.maxVisibleSegments !== "number") {
1189
+ errors.push({
1190
+ key: "compress.maxVisibleSegments",
1191
+ expected: "number",
1192
+ actual: typeof compress.maxVisibleSegments
1193
+ });
1194
+ }
1195
+ if (typeof compress.maxVisibleSegments === "number" && compress.maxVisibleSegments < 1) {
1196
+ errors.push({
1197
+ key: "compress.maxVisibleSegments",
1198
+ expected: "positive number (>= 1)",
1199
+ actual: `${compress.maxVisibleSegments}`
1200
+ });
1201
+ }
1187
1202
  if (typeof compress.iterationNudgeThreshold === "number" && compress.iterationNudgeThreshold < 1) {
1188
1203
  errors.push({
1189
1204
  key: "compress.iterationNudgeThreshold",
@@ -1497,8 +1512,9 @@ var defaultConfig = {
1497
1512
  protectedTools: [...COMPRESS_DEFAULT_PROTECTED_TOOLS],
1498
1513
  protectTags: false,
1499
1514
  protectUserMessages: false,
1500
- maxSummaryLengthHard: 4e3,
1501
- minCompressRange: 2e3
1515
+ maxSummaryLengthHard: 1e4,
1516
+ minCompressRange: 2e3,
1517
+ maxVisibleSegments: 50
1502
1518
  },
1503
1519
  strategies: {
1504
1520
  deduplication: {
@@ -1653,7 +1669,8 @@ function mergeCompress(base, override) {
1653
1669
  protectTags: override.protectTags ?? base.protectTags,
1654
1670
  protectUserMessages: override.protectUserMessages ?? base.protectUserMessages,
1655
1671
  maxSummaryLengthHard: override.maxSummaryLengthHard ?? base.maxSummaryLengthHard,
1656
- minCompressRange: override.minCompressRange ?? base.minCompressRange
1672
+ minCompressRange: override.minCompressRange ?? base.minCompressRange,
1673
+ maxVisibleSegments: override.maxVisibleSegments ?? base.maxVisibleSegments
1657
1674
  };
1658
1675
  }
1659
1676
  function mergeCommands(base, override) {
@@ -2245,20 +2262,39 @@ function resolveBoundaryIds(context, state, startId, endId) {
2245
2262
  }
2246
2263
  let startReference = lookup.get(parsedStartId.ref);
2247
2264
  let endReference = lookup.get(parsedEndId.ref);
2265
+ if (!startReference && parsedStartId.kind === "message") {
2266
+ const clamped = clampMessageRef(parsedStartId, context, state);
2267
+ if (clamped) {
2268
+ startReference = lookup.get(clamped.ref);
2269
+ if (startReference) {
2270
+ parsedStartId.ref = clamped.ref;
2271
+ }
2272
+ }
2273
+ }
2274
+ if (!endReference && parsedEndId.kind === "message") {
2275
+ const clamped = clampMessageRef(parsedEndId, context, state);
2276
+ if (clamped) {
2277
+ endReference = lookup.get(clamped.ref);
2278
+ if (endReference) {
2279
+ parsedEndId.ref = clamped.ref;
2280
+ }
2281
+ }
2282
+ }
2248
2283
  if (!startReference) {
2249
2284
  issues.push(
2250
- `startId ${parsedStartId.ref} is not available in the current conversation context. Choose an injected ID visible in context.`
2285
+ `startId ${parsedStartId.ref} is not available \u2014 likely consumed by an existing block.`
2251
2286
  );
2252
2287
  }
2253
2288
  if (!endReference) {
2254
2289
  issues.push(
2255
- `endId ${parsedEndId.ref} is not available in the current conversation context. Choose an injected ID visible in context.`
2290
+ `endId ${parsedEndId.ref} is not available \u2014 likely consumed by an existing block.`
2256
2291
  );
2257
2292
  }
2258
2293
  if (issues.length > 0) {
2259
- throw new Error(
2260
- issues.length === 1 ? issues[0] : issues.map((issue) => `- ${issue}`).join("\n")
2261
- );
2294
+ const hint = buildBoundaryRecoveryHint(context, state);
2295
+ const body = issues.length === 1 ? issues[0] : issues.map((issue) => `- ${issue}`).join("\n");
2296
+ throw new Error(hint ? `${body}
2297
+ ${hint}` : body);
2262
2298
  }
2263
2299
  if (!startReference || !endReference) {
2264
2300
  throw new Error("Failed to resolve boundary IDs");
@@ -2268,6 +2304,40 @@ function resolveBoundaryIds(context, state, startId, endId) {
2268
2304
  }
2269
2305
  return { startReference, endReference };
2270
2306
  }
2307
+ function buildBoundaryRecoveryHint(context, state) {
2308
+ const visibleRefs = [];
2309
+ for (const [messageRef, messageId] of state.messageIds.byRef) {
2310
+ if (context.rawMessagesById.has(messageId)) {
2311
+ visibleRefs.push(messageRef);
2312
+ }
2313
+ }
2314
+ const parts = [];
2315
+ if (visibleRefs.length > 0) {
2316
+ visibleRefs.sort();
2317
+ const first = visibleRefs[0];
2318
+ const last = visibleRefs[visibleRefs.length - 1];
2319
+ parts.push(`Current visible: ${first}\u2013${last} (${visibleRefs.length} msgs).`);
2320
+ }
2321
+ const blockCount = context.summaryByBlockId.size;
2322
+ if (blockCount > 0) {
2323
+ parts.push(`${blockCount} active compressed block${blockCount === 1 ? "" : "s"}.`);
2324
+ }
2325
+ if (parts.length === 0) {
2326
+ return "";
2327
+ }
2328
+ return `${parts.join(" ")} Call acp_status() to see which blocks consumed which IDs, then retry with valid IDs.`;
2329
+ }
2330
+ function clampMessageRef(requested, context, state) {
2331
+ if (state.messageIds.byRef.has(requested.ref)) return null;
2332
+ let maxIndex = -1;
2333
+ for (const [messageRef, messageId] of state.messageIds.byRef) {
2334
+ if (!context.rawMessagesById.has(messageId)) continue;
2335
+ const idx = parseMessageRef(messageRef);
2336
+ if (idx !== null && idx > maxIndex) maxIndex = idx;
2337
+ }
2338
+ if (maxIndex < 0 || requested.index <= maxIndex) return null;
2339
+ return { ref: formatMessageRef(maxIndex) };
2340
+ }
2271
2341
  function resolveSelection(context, startReference, endReference) {
2272
2342
  const startRawIndex = startReference.rawIndex;
2273
2343
  const endRawIndex = endReference.rawIndex;
@@ -3188,6 +3258,7 @@ function resetOnCompaction(state) {
3188
3258
  iterationNudgeAnchors: /* @__PURE__ */ new Set(),
3189
3259
  lastPerMessageNudgeTurn: 0,
3190
3260
  lastPerMessageNudgeTokens: void 0,
3261
+ lastToolOutputNudgeTokens: void 0,
3191
3262
  shouldInjectThisTurn: void 0
3192
3263
  };
3193
3264
  state.messageIds = {
@@ -3198,38 +3269,45 @@ function resetOnCompaction(state) {
3198
3269
  }
3199
3270
 
3200
3271
  // lib/state/persistence.ts
3201
- var LEGACY_STORAGE_DIR = join2(
3202
- process.env.XDG_DATA_HOME || join2(homedir2(), ".local", "share"),
3203
- "opencode",
3204
- "storage",
3205
- "plugin",
3206
- "dcp"
3207
- );
3208
- var STORAGE_DIR = join2(
3209
- process.env.XDG_DATA_HOME || join2(homedir2(), ".local", "share"),
3210
- "opencode",
3211
- "storage",
3212
- "plugin",
3213
- "acp"
3214
- );
3272
+ function getLegacyStorageDir() {
3273
+ return join2(
3274
+ process.env.XDG_DATA_HOME || join2(homedir2(), ".local", "share"),
3275
+ "opencode",
3276
+ "storage",
3277
+ "plugin",
3278
+ "dcp"
3279
+ );
3280
+ }
3281
+ function getStorageDir() {
3282
+ return join2(
3283
+ process.env.XDG_DATA_HOME || join2(homedir2(), ".local", "share"),
3284
+ "opencode",
3285
+ "storage",
3286
+ "plugin",
3287
+ "acp"
3288
+ );
3289
+ }
3215
3290
  function migrateFromLegacyIfNeeded(logger) {
3216
- if (existsSyncSync(STORAGE_DIR)) return;
3217
- if (!existsSyncSync(LEGACY_STORAGE_DIR)) return;
3291
+ const storageDir = getStorageDir();
3292
+ const legacyDir = getLegacyStorageDir();
3293
+ if (existsSyncSync(storageDir)) return;
3294
+ if (!existsSyncSync(legacyDir)) return;
3218
3295
  try {
3219
- cpSync(LEGACY_STORAGE_DIR, STORAGE_DIR, { recursive: true });
3220
- logger.info(`[ACP] Migrated storage from ${LEGACY_STORAGE_DIR} \u2192 ${STORAGE_DIR}`);
3296
+ cpSync(legacyDir, storageDir, { recursive: true });
3297
+ logger.info(`[ACP] Migrated storage from ${legacyDir} \u2192 ${storageDir}`);
3221
3298
  } catch (e) {
3222
3299
  logger.warn(`[ACP] Storage migration failed: ${e.message}`);
3223
3300
  }
3224
3301
  }
3225
3302
  async function ensureStorageDir(logger) {
3226
- if (!existsSync2(STORAGE_DIR)) {
3303
+ const storageDir = getStorageDir();
3304
+ if (!existsSync2(storageDir)) {
3227
3305
  migrateFromLegacyIfNeeded(logger);
3228
- await fs.mkdir(STORAGE_DIR, { recursive: true });
3306
+ await fs.mkdir(storageDir, { recursive: true });
3229
3307
  }
3230
3308
  }
3231
3309
  function getSessionFilePath(sessionId) {
3232
- return join2(STORAGE_DIR, `${sessionId}.json`);
3310
+ return join2(getStorageDir(), `${sessionId}.json`);
3233
3311
  }
3234
3312
  async function writePersistedSessionState(sessionId, state, logger) {
3235
3313
  await ensureStorageDir(logger);
@@ -3256,7 +3334,8 @@ async function saveSessionState(sessionState, logger, sessionName) {
3256
3334
  turnNudgeAnchors: Array.from(sessionState.nudges.turnNudgeAnchors),
3257
3335
  iterationNudgeAnchors: Array.from(sessionState.nudges.iterationNudgeAnchors),
3258
3336
  lastPerMessageNudgeTurn: sessionState.nudges.lastPerMessageNudgeTurn ?? 0,
3259
- lastPerMessageNudgeTokens: sessionState.nudges.lastPerMessageNudgeTokens
3337
+ lastPerMessageNudgeTokens: sessionState.nudges.lastPerMessageNudgeTokens,
3338
+ lastToolOutputNudgeTokens: sessionState.nudges.lastToolOutputNudgeTokens
3260
3339
  },
3261
3340
  stats: sessionState.stats,
3262
3341
  lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
@@ -3355,14 +3434,15 @@ async function loadAllSessionStats(logger) {
3355
3434
  sessionCount: 0
3356
3435
  };
3357
3436
  try {
3358
- if (!existsSync2(STORAGE_DIR)) {
3437
+ const storageDir = getStorageDir();
3438
+ if (!existsSync2(storageDir)) {
3359
3439
  return result;
3360
3440
  }
3361
- const files = await fs.readdir(STORAGE_DIR);
3441
+ const files = await fs.readdir(storageDir);
3362
3442
  const jsonFiles = files.filter((f) => f.endsWith(".json"));
3363
3443
  for (const file of jsonFiles) {
3364
3444
  try {
3365
- const filePath = join2(STORAGE_DIR, file);
3445
+ const filePath = join2(storageDir, file);
3366
3446
  const content = await fs.readFile(filePath, "utf-8");
3367
3447
  const state = JSON.parse(content);
3368
3448
  if (state?.stats?.totalPruneTokens && state?.prune) {
@@ -3473,6 +3553,7 @@ function createSessionState() {
3473
3553
  iterationNudgeAnchors: /* @__PURE__ */ new Set(),
3474
3554
  lastPerMessageNudgeTurn: 0,
3475
3555
  lastPerMessageNudgeTokens: void 0,
3556
+ lastToolOutputNudgeTokens: void 0,
3476
3557
  shouldInjectThisTurn: void 0
3477
3558
  },
3478
3559
  stats: {
@@ -3513,6 +3594,7 @@ function resetSessionState(state) {
3513
3594
  iterationNudgeAnchors: /* @__PURE__ */ new Set(),
3514
3595
  lastPerMessageNudgeTurn: 0,
3515
3596
  lastPerMessageNudgeTokens: void 0,
3597
+ lastToolOutputNudgeTokens: void 0,
3516
3598
  shouldInjectThisTurn: void 0
3517
3599
  };
3518
3600
  state.stats = {
@@ -3560,6 +3642,7 @@ async function ensureSessionInitialized(client, state, sessionId, logger, messag
3560
3642
  );
3561
3643
  state.nudges.lastPerMessageNudgeTurn = persisted.nudges.lastPerMessageNudgeTurn ?? 0;
3562
3644
  state.nudges.lastPerMessageNudgeTokens = persisted.nudges.lastPerMessageNudgeTokens;
3645
+ state.nudges.lastToolOutputNudgeTokens = persisted.nudges.lastToolOutputNudgeTokens;
3563
3646
  state.stats = {
3564
3647
  pruneTokenCounter: persisted.stats?.pruneTokenCounter || 0,
3565
3648
  totalPruneTokens: persisted.stats?.totalPruneTokens || 0
@@ -4589,7 +4672,7 @@ ${output}`);
4589
4672
  }
4590
4673
 
4591
4674
  // lib/compress/message.ts
4592
- function buildSchema() {
4675
+ function buildSchema(maxSummaryLengthHard) {
4593
4676
  return {
4594
4677
  topic: tool2.schema.string().describe(
4595
4678
  "Short label (3-5 words) for the overall batch - e.g., 'Closed Research Notes'"
@@ -4603,7 +4686,7 @@ function buildSchema() {
4603
4686
  )
4604
4687
  })
4605
4688
  ).describe("Batch of individual message summaries to create in one tool call"),
4606
- summaryMaxChars: tool2.schema.number().optional().describe("Override max summary length (default max: 4000 chars). Use when content is important and needs more detail \u2014 don't lose critical info just to fit the limit.")
4689
+ summaryMaxChars: tool2.schema.number().optional().describe(`Override max summary length (default max: ${maxSummaryLengthHard} chars). Use when content is important and needs more detail \u2014 don't lose critical info just to fit the limit.`)
4607
4690
  };
4608
4691
  }
4609
4692
  function createCompressMessageTool(ctx) {
@@ -4611,7 +4694,7 @@ function createCompressMessageTool(ctx) {
4611
4694
  const runtimePrompts = ctx.prompts.getRuntimePrompts();
4612
4695
  return tool2({
4613
4696
  description: runtimePrompts.compressMessage + MESSAGE_FORMAT_EXTENSION,
4614
- args: buildSchema(),
4697
+ args: buildSchema(ctx.config.compress.maxSummaryLengthHard),
4615
4698
  async execute(args, toolCtx) {
4616
4699
  const input = args;
4617
4700
  validateArgs(input);
@@ -4867,7 +4950,7 @@ function appendMissingBlockSummaries(summary, _missingBlockIds, _summaryByBlockI
4867
4950
  }
4868
4951
 
4869
4952
  // lib/compress/range.ts
4870
- function buildSchema2() {
4953
+ function buildSchema2(maxSummaryLengthHard) {
4871
4954
  return {
4872
4955
  topic: tool3.schema.string().describe("Short label (3-5 words) for display - e.g., 'Auth System Exploration'"),
4873
4956
  content: tool3.schema.array(
@@ -4883,7 +4966,7 @@ function buildSchema2() {
4883
4966
  ).describe(
4884
4967
  "One or more ranges to compress, each with start/end boundaries and a summary"
4885
4968
  ),
4886
- summaryMaxChars: tool3.schema.number().optional().describe("Override max summary length (default max: 4000 chars). Use when content is important and needs more detail \u2014 don't lose critical info just to fit the limit.")
4969
+ summaryMaxChars: tool3.schema.number().optional().describe(`Override max summary length (default max: ${maxSummaryLengthHard} chars). Use when content is important and needs more detail \u2014 don't lose critical info just to fit the limit.`)
4887
4970
  };
4888
4971
  }
4889
4972
  function createCompressRangeTool(ctx) {
@@ -4891,7 +4974,7 @@ function createCompressRangeTool(ctx) {
4891
4974
  const runtimePrompts = ctx.prompts.getRuntimePrompts();
4892
4975
  return tool3({
4893
4976
  description: runtimePrompts.compressRange + RANGE_FORMAT_EXTENSION,
4894
- args: buildSchema2(),
4977
+ args: buildSchema2(ctx.config.compress.maxSummaryLengthHard),
4895
4978
  async execute(args, toolCtx) {
4896
4979
  const input = args;
4897
4980
  validateArgs2(input);
@@ -5062,10 +5145,11 @@ import { tool as tool4 } from "@opencode-ai/plugin";
5062
5145
  // lib/messages/utils.ts
5063
5146
  import { createHash } from "crypto";
5064
5147
  var SUMMARY_ID_HASH_LENGTH = 16;
5065
- var MERGED_SUMMARY_HEADER = (blockId) => `[ACP compressed context summary (block ${blockId}) \u2014 prior conversation recap]
5148
+ var MERGED_SUMMARY_HEADER = (blockId) => `<acp-compression-summary>
5149
+ [ACP model-generated recap (block ${blockId}) \u2014 NOT a user message]
5066
5150
  `;
5067
5151
  var MERGED_SUMMARY_FOOTER = `
5068
- [End ACP compressed context summary]
5152
+ </acp-compression-summary>
5069
5153
 
5070
5154
  `;
5071
5155
  var DCP_BLOCK_ID_TAG_REGEX = /(<dcp-message-id(?=[\s>])[^>]*>)b\d+(<\/(?:dcp|acp)-message-id>)/g;
@@ -5076,33 +5160,54 @@ var generateStableId = (prefix, seed) => {
5076
5160
  const hash = createHash("sha256").update(seed).digest("hex").slice(0, SUMMARY_ID_HASH_LENGTH);
5077
5161
  return `${prefix}_${hash}`;
5078
5162
  };
5079
- var createSyntheticUserMessage = (baseMessage, content, stableSeed) => {
5080
- const userInfo = baseMessage.info;
5163
+ var createSyntheticMessage = (baseMessage, content, stableSeed, role = "user") => {
5164
+ const baseInfo = baseMessage.info;
5081
5165
  const now = Date.now();
5082
- const deterministicSeed = stableSeed?.trim() || userInfo.id;
5166
+ const deterministicSeed = stableSeed?.trim() || baseInfo.id;
5083
5167
  const messageId = generateStableId("msg_dcp_summary", deterministicSeed);
5084
5168
  const partId = generateStableId("prt_dcp_summary", deterministicSeed);
5085
- return {
5086
- info: {
5169
+ const parts = [
5170
+ {
5171
+ id: partId,
5172
+ sessionID: baseInfo.sessionID,
5173
+ messageID: messageId,
5174
+ type: "text",
5175
+ text: content,
5176
+ synthetic: true
5177
+ }
5178
+ ];
5179
+ if (role === "assistant") {
5180
+ const isAssistant = baseInfo.role === "assistant";
5181
+ const assistantBase = isAssistant ? baseInfo : void 0;
5182
+ const userModel = !isAssistant ? baseInfo.model : void 0;
5183
+ const info2 = {
5087
5184
  id: messageId,
5088
- sessionID: userInfo.sessionID,
5089
- role: "user",
5090
- agent: userInfo.agent,
5091
- model: userInfo.model,
5092
- time: { created: now }
5093
- },
5094
- parts: [
5095
- {
5096
- id: partId,
5097
- sessionID: userInfo.sessionID,
5098
- messageID: messageId,
5099
- type: "text",
5100
- text: content,
5101
- synthetic: true
5102
- }
5103
- ]
5185
+ sessionID: baseInfo.sessionID,
5186
+ role: "assistant",
5187
+ time: { created: now },
5188
+ parentID: assistantBase?.parentID ?? "",
5189
+ modelID: assistantBase?.modelID ?? userModel?.modelID ?? "",
5190
+ providerID: assistantBase?.providerID ?? userModel?.providerID ?? "",
5191
+ mode: assistantBase?.mode ?? "code",
5192
+ agent: baseInfo.agent ?? "code",
5193
+ path: assistantBase?.path ?? { cwd: "", root: "" },
5194
+ cost: 0,
5195
+ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
5196
+ };
5197
+ return { info: info2, parts };
5198
+ }
5199
+ const userInfo = baseInfo;
5200
+ const info = {
5201
+ id: messageId,
5202
+ sessionID: userInfo.sessionID,
5203
+ role: "user",
5204
+ agent: userInfo.agent,
5205
+ model: userInfo.model,
5206
+ time: { created: now }
5104
5207
  };
5208
+ return { info, parts };
5105
5209
  };
5210
+ var createSyntheticUserMessage = (baseMessage, content, stableSeed) => createSyntheticMessage(baseMessage, content, stableSeed, "user");
5106
5211
  var prependCompressionSummary = (message, summary, blockId) => {
5107
5212
  const parts = Array.isArray(message.parts) ? message.parts : [];
5108
5213
  const header = MERGED_SUMMARY_HEADER(blockId);
@@ -5241,6 +5346,11 @@ var stripHallucinations = (messages) => {
5241
5346
  };
5242
5347
 
5243
5348
  // lib/messages/prune.ts
5349
+ var STANDALONE_SUMMARY_HEADER = (blockId) => `<acp-compression-summary>
5350
+ [ACP model-generated recap (block ${blockId}) \u2014 NOT a user message]
5351
+ `;
5352
+ var STANDALONE_SUMMARY_FOOTER = `
5353
+ </acp-compression-summary>`;
5244
5354
  var prune = (state, logger, config, messages) => {
5245
5355
  filterCompressedRanges(state, logger, config, messages);
5246
5356
  stripStepMarkers(messages);
@@ -5303,41 +5413,18 @@ var filterCompressedRanges = (state, logger, config, messages) => {
5303
5413
  summaryLength: summaryContent.length
5304
5414
  });
5305
5415
  } else {
5306
- const userMessage = getLastUserMessage(messages, i);
5416
+ const taggedContent = STANDALONE_SUMMARY_HEADER(summary.blockId) + summaryContent + STANDALONE_SUMMARY_FOOTER;
5307
5417
  const summarySeed = `${summary.blockId}:${summary.anchorMessageId}`;
5308
- if (userMessage) {
5309
- result.push(
5310
- createSyntheticUserMessage(userMessage, summaryContent, summarySeed)
5311
- );
5312
- logger.info("Injected compress summary", {
5313
- anchorMessageId: msgId,
5314
- summaryLength: summaryContent.length
5315
- });
5316
- } else {
5317
- const anchorInfo = msg.info;
5318
- const fallbackBase = {
5319
- info: {
5320
- id: anchorInfo.id || msgId,
5321
- sessionID: anchorInfo.sessionID || "",
5322
- role: "user",
5323
- agent: anchorInfo.agent || "code",
5324
- model: anchorInfo.model || {
5325
- providerID: "",
5326
- modelID: "",
5327
- variant: void 0
5328
- },
5329
- time: { created: anchorInfo.time?.created || Date.now() }
5330
- },
5331
- parts: []
5332
- };
5333
- result.push(
5334
- createSyntheticUserMessage(fallbackBase, summaryContent, summarySeed)
5335
- );
5336
- logger.info("Injected compress summary (fallback, no preceding user message)", {
5337
- anchorMessageId: msgId,
5338
- summaryLength: summaryContent.length
5339
- });
5340
- }
5418
+ const userMessage = getLastUserMessage(messages, i);
5419
+ const baseForSummary = userMessage ?? msg;
5420
+ result.push(
5421
+ createSyntheticMessage(baseForSummary, taggedContent, summarySeed, "assistant")
5422
+ );
5423
+ logger.info("Injected compress summary as assistant role", {
5424
+ anchorMessageId: msgId,
5425
+ summaryLength: taggedContent.length,
5426
+ hadUserBase: userMessage !== null
5427
+ });
5341
5428
  }
5342
5429
  }
5343
5430
  }
@@ -5797,12 +5884,15 @@ function isContextOverLimits(config, state, providerId, modelId, messages) {
5797
5884
  };
5798
5885
  }
5799
5886
  function computeShouldNudge(params) {
5800
- const { currentTokens, modelContextLimit, overMinLimit, overMaxLimit } = params;
5801
- const contextPct = modelContextLimit && currentTokens ? currentTokens / modelContextLimit * 100 : 0;
5802
- const lastNudgeTokens = params.lastNudgeTokens;
5803
- const growthSinceLastNudge = (currentTokens ?? 0) - (lastNudgeTokens ?? 0);
5804
- const frequencyTriggered = lastNudgeTokens === void 0 || growthSinceLastNudge >= params.nudgeGrowthTokens || overMaxLimit;
5805
- const shouldNudge = contextPct >= params.minNudgeContextPercent && frequencyTriggered;
5887
+ const { currentTokens, overMinLimit, overMaxLimit } = params;
5888
+ if (currentTokens === void 0) {
5889
+ return { shouldNudge: false, tipsVariant: null };
5890
+ }
5891
+ if (params.lastNudgeTokens === void 0) {
5892
+ return { shouldNudge: false, tipsVariant: null };
5893
+ }
5894
+ const growthSinceLastNudge = currentTokens - params.lastNudgeTokens;
5895
+ const shouldNudge = growthSinceLastNudge >= params.nudgeGrowthTokens || overMaxLimit;
5806
5896
  if (!shouldNudge) {
5807
5897
  return { shouldNudge: false, tipsVariant: null };
5808
5898
  }
@@ -5932,7 +6022,7 @@ function buildContextUsageGuidance(config, currentTokens, modelContextLimit) {
5932
6022
  return `
5933
6023
 
5934
6024
  Context: ${formatK(currentTokens)} tokens.
5935
- All compression serves the primary task, but be frugal. Context capacity is precious \u2014 compress waste promptly. Save context by compressing consumed outputs, not by avoiding tools. Compress by need, not by percentage.`;
6025
+ All compression serves the primary task, but be frugal. Context capacity is precious. Save context by compressing consumed outputs, not by avoiding tools. Compress by need, not by percentage.`;
5936
6026
  }
5937
6027
  function applyAnchoredNudges(state, config, messages, prompts, compressionPriorities, currentTokens, modelContextLimit, suffixMessage) {
5938
6028
  const turnNudgeAnchors = collectTurnNudgeAnchors2(state, config, messages);
@@ -6014,6 +6104,85 @@ function applyAnchoredNudges(state, config, messages, prompts, compressionPriori
6014
6104
  ""
6015
6105
  );
6016
6106
  }
6107
+ function estimateCodeTokens(text) {
6108
+ let codeChars = 0;
6109
+ let inCode = false;
6110
+ for (const line of text.split("\n")) {
6111
+ if (line.trim().startsWith("```")) {
6112
+ inCode = !inCode;
6113
+ codeChars += line.length + 1;
6114
+ continue;
6115
+ }
6116
+ if (inCode) codeChars += line.length + 1;
6117
+ }
6118
+ return Math.round(codeChars / 4);
6119
+ }
6120
+ function estimateContextComposition(messages, state) {
6121
+ let toolTokens = 0;
6122
+ let codeTokens = 0;
6123
+ let summaryTokens = 0;
6124
+ let messageTokens = 0;
6125
+ const perMessage = [];
6126
+ const perTool = [];
6127
+ const perCode = [];
6128
+ const perText = [];
6129
+ for (const msg of messages) {
6130
+ const text = (msg.parts || []).filter((p) => p.type === "text").map((p) => p.text || "").join("");
6131
+ const msgId = msg.info?.id || "";
6132
+ const isSummary = msgId.startsWith("msg_dcp_summary") || text.includes("[Compressed conversation section]");
6133
+ let msgTotal = 0;
6134
+ let msgTool = 0;
6135
+ let msgCode = 0;
6136
+ let msgText = 0;
6137
+ for (const part of msg.parts || []) {
6138
+ if (part.type === "text" && typeof part.text === "string") {
6139
+ const partText = part.text;
6140
+ const tokens = Math.round(partText.length / 4);
6141
+ msgTotal += tokens;
6142
+ if (isSummary) {
6143
+ summaryTokens += tokens;
6144
+ } else {
6145
+ messageTokens += tokens;
6146
+ msgText += tokens;
6147
+ const cTokens = estimateCodeTokens(partText);
6148
+ if (cTokens > 0) {
6149
+ codeTokens += cTokens;
6150
+ msgCode += cTokens;
6151
+ }
6152
+ }
6153
+ } else if (part.type !== "text" && part.type !== "reasoning") {
6154
+ const raw = JSON.stringify(part);
6155
+ const tokens = Math.round(raw.length / 4);
6156
+ msgTotal += tokens;
6157
+ toolTokens += tokens;
6158
+ msgTool += tokens;
6159
+ }
6160
+ }
6161
+ if (!isSummary) {
6162
+ const ref = state?.messageIds?.byRawId?.get(msgId) || "?";
6163
+ if (msgTotal > 500) perMessage.push({ ref, tokens: msgTotal });
6164
+ if (msgTool > 500) perTool.push({ ref, tokens: msgTool });
6165
+ if (msgCode > 300) perCode.push({ ref, tokens: msgCode });
6166
+ if (msgText > 500 && msgCode === 0) perText.push({ ref, tokens: msgText });
6167
+ }
6168
+ }
6169
+ perMessage.sort((a, b) => b.tokens - a.tokens);
6170
+ perTool.sort((a, b) => b.tokens - a.tokens);
6171
+ perCode.sort((a, b) => b.tokens - a.tokens);
6172
+ perText.sort((a, b) => b.tokens - a.tokens);
6173
+ return {
6174
+ toolTokens,
6175
+ codeTokens,
6176
+ summaryTokens,
6177
+ messageTokens,
6178
+ textTokens: Math.max(0, messageTokens - codeTokens),
6179
+ total: toolTokens + summaryTokens + messageTokens,
6180
+ largestRanges: perMessage.slice(0, 10),
6181
+ largestToolRanges: perTool.slice(0, 5),
6182
+ largestCodeRanges: perCode.slice(0, 5),
6183
+ largestMessageRanges: perText.slice(0, 5)
6184
+ };
6185
+ }
6017
6186
 
6018
6187
  // lib/messages/inject/inject.ts
6019
6188
  var ACP_SUFFIX_SEED = "acp-dynamic-guidance";
@@ -6046,7 +6215,8 @@ var injectCompressNudges = (state, config, logger, messages, prompts, compressio
6046
6215
  state.nudges.turnNudgeAnchors.clear();
6047
6216
  state.nudges.iterationNudgeAnchors.clear();
6048
6217
  state.nudges.lastPerMessageNudgeTokens = currentTokens;
6049
- void saveSessionState(state, logger);
6218
+ saveSessionState(state, logger).catch(() => {
6219
+ });
6050
6220
  return;
6051
6221
  }
6052
6222
  let anchorsChanged = false;
@@ -6109,6 +6279,10 @@ var injectCompressNudges = (state, config, logger, messages, prompts, compressio
6109
6279
  }
6110
6280
  const suffixMessage = createSuffixMessage(messages);
6111
6281
  applyAnchoredNudges(state, config, messages, prompts, compressionPriorities, currentTokens, modelContextLimit, suffixMessage);
6282
+ const nudgeGrowthTokens = config.compress?.nudgeGrowthTokens ?? resolveAdaptiveNudgeGrowth(modelContextLimit);
6283
+ if (currentTokens !== void 0 && state.nudges.lastPerMessageNudgeTokens !== void 0 && currentTokens < state.nudges.lastPerMessageNudgeTokens - nudgeGrowthTokens) {
6284
+ state.nudges.lastPerMessageNudgeTokens = currentTokens;
6285
+ }
6112
6286
  const decision = computeShouldNudge({
6113
6287
  currentTokens,
6114
6288
  modelContextLimit,
@@ -6116,18 +6290,67 @@ var injectCompressNudges = (state, config, logger, messages, prompts, compressio
6116
6290
  overMaxLimit,
6117
6291
  lastNudgeTokens: state.nudges.lastPerMessageNudgeTokens,
6118
6292
  minNudgeContextPercent: config.compress?.minNudgeContextPercent ?? 15,
6119
- nudgeGrowthTokens: config.compress?.nudgeGrowthTokens ?? resolveAdaptiveNudgeGrowth(modelContextLimit)
6293
+ nudgeGrowthTokens
6120
6294
  });
6121
6295
  state.nudges.shouldInjectThisTurn = decision.shouldNudge;
6296
+ if (state.nudges.lastPerMessageNudgeTokens === void 0 && currentTokens !== void 0) {
6297
+ state.nudges.lastPerMessageNudgeTokens = currentTokens;
6298
+ }
6299
+ const composition = estimateContextComposition(messages, state);
6300
+ const toolOutputThreshold = config.compress?.toolOutputNudgeThreshold ?? 5e3;
6301
+ let toolOutputReminder = null;
6302
+ if (composition.toolTokens > 0) {
6303
+ if (state.nudges.lastToolOutputNudgeTokens === void 0) {
6304
+ state.nudges.lastToolOutputNudgeTokens = composition.toolTokens;
6305
+ } else {
6306
+ const toolGrowth = composition.toolTokens - state.nudges.lastToolOutputNudgeTokens;
6307
+ if (toolGrowth >= toolOutputThreshold) {
6308
+ const fmt = (n) => n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n);
6309
+ const topRanges = composition.largestRanges.slice(0, 5).map((r) => `${r.ref} (${fmt(r.tokens)})`).join(", ");
6310
+ toolOutputReminder = `
6311
+
6312
+ \u26A0\uFE0F ${fmt(toolGrowth)} new tool outputs accumulated (${fmt(composition.toolTokens)} total). Largest: ${topRanges}. Use compress tool to compress these ranges now.`;
6313
+ state.nudges.lastToolOutputNudgeTokens = composition.toolTokens;
6314
+ anchorsChanged = true;
6315
+ }
6316
+ }
6317
+ }
6122
6318
  let tipsText = null;
6123
6319
  if (decision.shouldNudge) {
6124
6320
  injectContextUsage(suffixMessage, config, currentTokens, modelContextLimit);
6321
+ if (suffixMessage && composition.total > 0) {
6322
+ const fmt = (n) => n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n);
6323
+ const pct = (n) => Math.round(n / composition.total * 100);
6324
+ const growth = currentTokens !== void 0 && state.nudges.lastPerMessageNudgeTokens !== void 0 ? currentTokens - state.nudges.lastPerMessageNudgeTokens : 0;
6325
+ const growthStr = growth > 0 ? ` (+${fmt(growth)} since last nudge)` : "";
6326
+ const plainTextTokens = composition.textTokens;
6327
+ const efficiencyNote = decision.tipsVariant !== "maxLimit" ? `
6328
+ This is an efficiency nudge to compress early and keep context lean \u2014 not an overflow warning. A separate, stronger alert will appear if the context is actually full.` : "";
6329
+ let breakdown = `${efficiencyNote}
6330
+ Breakdown: ${fmt(composition.toolTokens)} tool (${pct(composition.toolTokens)}%) | ${fmt(composition.summaryTokens)} summaries (${pct(composition.summaryTokens)}%) | ${fmt(composition.codeTokens)} code (${pct(composition.codeTokens)}%) | ${fmt(plainTextTokens)} text (${pct(plainTextTokens)}%)${growthStr}`;
6331
+ const topBlocks = Array.from(state.prune.messages.blocksById.values()).filter((b) => b.active).sort((a, b) => b.compressedTokens - a.compressedTokens).slice(0, 3);
6332
+ if (topBlocks.length > 0) {
6333
+ breakdown += `
6334
+ Top blocks: ${topBlocks.map((b) => `b${b.blockId} ${fmt(b.compressedTokens)}\u2192${fmt(b.summaryTokens)}`).join(", ")}`;
6335
+ }
6336
+ if (composition.largestToolRanges.length > 0) {
6337
+ breakdown += `
6338
+ Largest tool outputs: ${composition.largestToolRanges.map((r) => `${r.ref} (${fmt(r.tokens)})`).join(", ")}`;
6339
+ }
6340
+ if (composition.largestCodeRanges.length > 0) {
6341
+ breakdown += `
6342
+ Largest code messages: ${composition.largestCodeRanges.map((r) => `${r.ref} (${fmt(r.tokens)})`).join(", ")}`;
6343
+ }
6344
+ if (composition.largestMessageRanges.length > 0) {
6345
+ breakdown += `
6346
+ Largest text messages: ${composition.largestMessageRanges.map((r) => `${r.ref} (${fmt(r.tokens)})`).join(", ")}`;
6347
+ }
6348
+ breakdown += `
6349
+ \u{1F4A1} Compress incrementally: target the ranges above whose content you have already extracted for this step. Size alone is not a reason to compress \u2014 if a large range is still needed in full, keep it.`;
6350
+ appendToLastTextPart(suffixMessage, breakdown);
6351
+ }
6125
6352
  if (decision.tipsVariant === "maxLimit") {
6126
6353
  tipsText = '\n\n\u26A0\uFE0F Context limit reached \u2014 compress now. Prioritize consumed tool outputs.\n\n{ "topic": "...", "content": [{ "startId": "<ID>", "endId": "<ID>", "summary": "..." }] }\n\nOnly use IDs from visible messages above. Compress older work first.';
6127
- } else if (decision.tipsVariant === "minLimit") {
6128
- tipsText = "\n\n\u26A0\uFE0F Context is growing \u2014 consider compressing older work. Tools: compress, decompress, search_context.";
6129
- } else {
6130
- tipsText = "\n\n\u{1F4A1} Tools: compress, decompress, search_context.";
6131
6354
  }
6132
6355
  state.nudges.lastPerMessageNudgeTokens = currentTokens;
6133
6356
  state.nudges.lastPerMessageNudgeTurn = state.currentTurn ?? 0;
@@ -6148,13 +6371,32 @@ var injectCompressNudges = (state, config, logger, messages, prompts, compressio
6148
6371
  if (tipsText && suffixMessage) {
6149
6372
  appendToLastTextPart(suffixMessage, tipsText);
6150
6373
  }
6151
- injectVisibleIdRange(state, messages, suffixMessage);
6374
+ injectVisibleIdRange(state, config, messages, suffixMessage);
6375
+ }
6376
+ if (toolOutputReminder && suffixMessage) {
6377
+ if (!decision.shouldNudge) {
6378
+ injectContextUsage(suffixMessage, config, currentTokens, modelContextLimit);
6379
+ if (composition.total > 0) {
6380
+ const fmt2 = (n) => n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n);
6381
+ const pct2 = (n) => Math.round(n / composition.total * 100);
6382
+ const topBlocks = Array.from(state.prune.messages.blocksById.values()).filter((b) => b.active).sort((a, b) => b.compressedTokens - a.compressedTokens).slice(0, 3);
6383
+ let mini = `
6384
+ Breakdown: ${fmt2(composition.toolTokens)} tool outputs (${pct2(composition.toolTokens)}%) | ${fmt2(composition.summaryTokens)} summaries (${pct2(composition.summaryTokens)}%) | ${fmt2(composition.messageTokens)} messages (${pct2(composition.messageTokens)}%)`;
6385
+ if (topBlocks.length > 0) {
6386
+ mini += `
6387
+ Top blocks: ${topBlocks.map((b) => `b${b.blockId} ${fmt2(b.compressedTokens)}\u2192${fmt2(b.summaryTokens)}`).join(", ")}`;
6388
+ }
6389
+ appendToLastTextPart(suffixMessage, mini);
6390
+ }
6391
+ }
6392
+ appendToLastTextPart(suffixMessage, toolOutputReminder);
6152
6393
  }
6153
6394
  if (suffixMessage) {
6154
6395
  appendToLastTextPart(suffixMessage, "\n");
6155
6396
  }
6156
6397
  if (anchorsChanged) {
6157
- void saveSessionState(state, logger);
6398
+ saveSessionState(state, logger).catch(() => {
6399
+ });
6158
6400
  }
6159
6401
  };
6160
6402
  function injectContextUsage(target, config, currentTokens, modelContextLimit) {
@@ -6170,22 +6412,78 @@ function injectContextUsage(target, config, currentTokens, modelContextLimit) {
6170
6412
  }
6171
6413
  target.parts.push(createSyntheticTextPart(target, usageTag));
6172
6414
  }
6173
- function injectVisibleIdRange(state, messages, target) {
6415
+ function refNumber(ref) {
6416
+ const n = parseInt(ref.slice(1), 10);
6417
+ return Number.isNaN(n) ? -1 : n;
6418
+ }
6419
+ function buildVisibleSegments(state, messages) {
6420
+ const refInfo = /* @__PURE__ */ new Map();
6421
+ for (const msg of messages) {
6422
+ const ref = state.messageIds.byRawId.get(msg.info.id);
6423
+ if (!ref) continue;
6424
+ let tokens = 0;
6425
+ let hasTool = false;
6426
+ for (const part of msg.parts || []) {
6427
+ if (part.type === "text" && typeof part.text === "string") {
6428
+ tokens += Math.round(part.text.length / 4);
6429
+ } else if (part.type !== "text" && part.type !== "reasoning") {
6430
+ tokens += Math.round(JSON.stringify(part).length / 4);
6431
+ hasTool = true;
6432
+ }
6433
+ }
6434
+ refInfo.set(ref, { tokens, hasTool });
6435
+ }
6436
+ if (refInfo.size === 0) return [];
6437
+ const refs = Array.from(refInfo.keys()).sort((a, b) => refNumber(a) - refNumber(b));
6438
+ const segments = [];
6439
+ let cur = null;
6440
+ let prevNum = -2;
6441
+ for (const ref of refs) {
6442
+ const num = refNumber(ref);
6443
+ const info = refInfo.get(ref);
6444
+ if (cur && num === prevNum + 1) {
6445
+ cur.endRef = ref;
6446
+ cur.count++;
6447
+ cur.tokens += info.tokens;
6448
+ if (info.hasTool) cur.hasTool = true;
6449
+ } else {
6450
+ if (cur) segments.push(cur);
6451
+ cur = { startRef: ref, endRef: ref, count: 1, tokens: info.tokens, hasTool: info.hasTool };
6452
+ }
6453
+ prevNum = num;
6454
+ }
6455
+ if (cur) segments.push(cur);
6456
+ return segments;
6457
+ }
6458
+ function formatSegment(seg) {
6459
+ return seg.startRef === seg.endRef ? seg.startRef : `${seg.startRef}\u2013${seg.endRef}`;
6460
+ }
6461
+ function formatVisibleGuidance(segments, maxSegs) {
6462
+ if (segments.length === 0) return "";
6463
+ const totalMsgs = segments.reduce((s, seg) => s + seg.count, 0);
6464
+ const totalSegs = segments.length;
6465
+ const fmt = (n) => n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n);
6466
+ if (totalSegs <= maxSegs) {
6467
+ return `[Visible: ${segments.map(formatSegment).join(", ")} (${totalMsgs} msg${totalMsgs === 1 ? "" : "s"}, ${totalSegs} segment${totalSegs === 1 ? "" : "s"})]`;
6468
+ }
6469
+ const keepSet = new Set(
6470
+ [...segments].sort((a, b) => {
6471
+ if (a.hasTool !== b.hasTool) return a.hasTool ? -1 : 1;
6472
+ return b.tokens - a.tokens;
6473
+ }).slice(0, maxSegs)
6474
+ );
6475
+ const shown = segments.filter((s) => keepSet.has(s));
6476
+ const omitted = segments.filter((s) => !keepSet.has(s));
6477
+ const omittedTokens = omitted.reduce((sum, s) => sum + s.tokens, 0);
6478
+ const omittedMsgs = omitted.reduce((sum, s) => sum + s.count, 0);
6479
+ return `[Visible (top ${shown.length} of ${totalSegs} segments, ${totalMsgs} msgs): ${shown.map(formatSegment).join(", ")} | +${omitted.length} smaller segment${omitted.length === 1 ? "" : "s"} (~${fmt(omittedTokens)} tokens, ${omittedMsgs} msg${omittedMsgs === 1 ? "" : "s"}) omitted]`;
6480
+ }
6481
+ function injectVisibleIdRange(state, config, messages, target) {
6174
6482
  if (!target) return;
6175
- const visibleRefs = [];
6176
- for (const message of messages) {
6177
- const ref = state.messageIds.byRawId.get(message.info.id);
6178
- if (ref) {
6179
- visibleRefs.push(ref);
6180
- }
6181
- }
6182
- if (visibleRefs.length === 0) return;
6183
- visibleRefs.sort();
6184
- const first = visibleRefs[0];
6185
- const last = visibleRefs[visibleRefs.length - 1];
6186
- const rangeTag = `
6187
-
6188
- [Visible messages: ${first} to ${last} (${visibleRefs.length} messages)]`;
6483
+ const segments = buildVisibleSegments(state, messages);
6484
+ if (segments.length === 0) return;
6485
+ const maxSegs = config.compress?.maxVisibleSegments ?? 50;
6486
+ const rangeTag = "\n\n" + formatVisibleGuidance(segments, maxSegs);
6189
6487
  for (const part of target.parts) {
6190
6488
  if (part.type === "text") {
6191
6489
  appendToTextPart(part, rangeTag);
@@ -6728,45 +7026,107 @@ ${content}`;
6728
7026
 
6729
7027
  // lib/compress/status.ts
6730
7028
  import { tool as tool5 } from "@opencode-ai/plugin";
6731
- var ACP_STATUS_TOOL_DESCRIPTION = `Show detailed status of all active compressed context blocks. Returns a table of block IDs, summary sizes, ages, and topics \u2014 use this to decide which blocks to decompress or search. No arguments needed.
7029
+ var ACP_STATUS_TOOL_DESCRIPTION = `Show detailed status of all active compressed context blocks. Returns block IDs, sizes, ages, topics, and the message-ID ranges each block consumed \u2014 use this to see what has been compressed away and to choose safe compress boundaries.
6732
7030
 
6733
7031
  Use this tool when:
6734
- - You need to know what content has been compressed away
7032
+ - You are unsure which mNNNNN refs are still compressible
7033
+ - Before choosing compress boundaries, if any prior compressions exist
6735
7034
  - You want to see block sizes before deciding to decompress
6736
- - You need a quick overview of context compression state`;
7035
+ - A compress call failed with "not available" (the ID was likely consumed)
7036
+
7037
+ Args:
7038
+ - mode: "summary" (default) \u2014 one line per block with size/range/topic. "detailed" \u2014 adds age, generation, effective message count, consumed block lineage.
7039
+ - sort: "recent" (default) | "size" (largest compressed first) | "age" (oldest surviving first, nearing GC).
7040
+ - limit: max blocks to show (default 30).`;
6737
7041
  function formatTokens(n) {
7042
+ if (!Number.isFinite(n) || n <= 0) return "0";
6738
7043
  return n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n);
6739
7044
  }
7045
+ function formatSizePair(compressed, summary) {
7046
+ return `${formatTokens(compressed)}\u2192${formatTokens(summary)}`;
7047
+ }
7048
+ function formatIdRange(block) {
7049
+ const start = (block.startId || "").trim();
7050
+ const end = (block.endId || "").trim();
7051
+ if (!start || !end) return "\u2014";
7052
+ if (start === end) return start;
7053
+ return `${start}\u2013${end}`;
7054
+ }
7055
+ function sortBlocks(blocks, sort) {
7056
+ const copy = [...blocks];
7057
+ if (sort === "size") {
7058
+ copy.sort((a, b) => (b.compressedTokens || 0) - (a.compressedTokens || 0));
7059
+ } else if (sort === "age") {
7060
+ copy.sort((a, b) => (b.survivedCount || 0) - (a.survivedCount || 0));
7061
+ } else {
7062
+ copy.sort((a, b) => b.createdAt - a.createdAt);
7063
+ }
7064
+ return copy;
7065
+ }
7066
+ function renderSummaryRow(block, idWidth) {
7067
+ const idStr = `b${block.blockId}`.padEnd(idWidth + 1);
7068
+ const sizeStr = formatSizePair(block.compressedTokens, block.summaryTokens).padStart(13);
7069
+ const ageStr = formatAge(block.createdAt).padStart(10);
7070
+ const rangeStr = formatIdRange(block).padStart(19);
7071
+ const topic = block.topic || "(no topic)";
7072
+ return ` ${idStr} ${sizeStr} ${ageStr} ${rangeStr} "${topic}"`;
7073
+ }
7074
+ function renderDetailedRow(block, idWidth) {
7075
+ const idStr = `b${block.blockId}`.padEnd(idWidth + 1);
7076
+ const sizeStr = formatSizePair(block.compressedTokens, block.summaryTokens).padStart(13);
7077
+ const ageStr = formatAge(block.createdAt).padStart(10);
7078
+ const rangeStr = formatIdRange(block).padStart(19);
7079
+ const survived = block.survivedCount ?? 0;
7080
+ const gen = block.generation ?? "young";
7081
+ const effCount = block.effectiveMessageIds?.length ?? 0;
7082
+ const consumedLineage = block.consumedBlockIds && block.consumedBlockIds.length > 0 ? ` nested=[${block.consumedBlockIds.map((n) => `b${n}`).join(",")}]` : "";
7083
+ const topic = block.topic || "(no topic)";
7084
+ return ` ${idStr} ${sizeStr} ${ageStr} ${rangeStr} age=${survived} ${gen} eff=${effCount}${consumedLineage} "${topic}"`;
7085
+ }
6740
7086
  function createAcpStatusTool(ctx) {
6741
7087
  ctx.prompts.reload();
6742
7088
  return tool5({
6743
7089
  description: ACP_STATUS_TOOL_DESCRIPTION,
6744
- args: {},
6745
- async execute() {
7090
+ args: {
7091
+ mode: tool5.schema.string().optional().describe('Output detail level: "summary" (default) or "detailed"'),
7092
+ sort: tool5.schema.string().optional().describe('Sort order: "recent" (default), "size", or "age"'),
7093
+ limit: tool5.schema.number().optional().describe("Maximum blocks to show (default 30)")
7094
+ },
7095
+ async execute(args) {
7096
+ const mode = args.mode === "detailed" ? "detailed" : "summary";
7097
+ const sort = args.sort === "size" || args.sort === "age" ? args.sort : "recent";
7098
+ const limit = Number.isFinite(args.limit) && args.limit > 0 ? Math.min(args.limit, 200) : 30;
6746
7099
  const messages = ctx.state.prune.messages;
6747
7100
  const activeIds = Array.from(messages.activeBlockIds).sort((a, b) => a - b);
6748
7101
  if (activeIds.length === 0) {
6749
7102
  return "No compressed blocks. Context is fully visible.";
6750
7103
  }
6751
- const blocks = activeIds.map((id) => messages.blocksById.get(id)).filter((b) => b !== void 0 && b.active);
6752
- const totalSummary = blocks.reduce((sum, b) => sum + b.summaryTokens, 0);
6753
- const totalCompressed = blocks.reduce((sum, b) => sum + b.compressedTokens, 0);
7104
+ const allBlocks = activeIds.map((id) => messages.blocksById.get(id)).filter((b) => b !== void 0 && b.active);
7105
+ if (allBlocks.length === 0) {
7106
+ return "No compressed blocks. Context is fully visible.";
7107
+ }
7108
+ const totalSummary = allBlocks.reduce((s, b) => s + (b.summaryTokens || 0), 0);
7109
+ const totalCompressed = allBlocks.reduce((s, b) => s + (b.compressedTokens || 0), 0);
7110
+ const sorted = sortBlocks(allBlocks, sort);
7111
+ const shown = sorted.slice(0, limit);
7112
+ const truncated = sorted.length - shown.length;
7113
+ const idWidth = Math.max(...shown.map((b) => String(b.blockId).length));
6754
7114
  const lines = [
6755
- `ACP Status \u2014 ${blocks.length} active compressed block${blocks.length === 1 ? "" : "s"} (${formatTokens(totalSummary)} summary tokens, ${formatTokens(totalCompressed)} original content compressed)`,
7115
+ `ACP Status \u2014 ${allBlocks.length} active compressed block${allBlocks.length === 1 ? "" : "s"} (${formatTokens(totalSummary)} summary, ${formatTokens(totalCompressed)} original compressed)`,
6756
7116
  ""
6757
7117
  ];
6758
- const idWidth = String(Math.max(...blocks.map((b) => b.blockId))).length;
6759
- for (const b of blocks) {
6760
- const idStr = `b${b.blockId}`.padEnd(idWidth + 1);
6761
- const tokStr = `${formatTokens(b.summaryTokens)}t`.padStart(7);
6762
- const ageStr = formatAge(b.createdAt).padStart(10);
6763
- const topic = b.topic || "(no topic)";
6764
- lines.push(` ${idStr} ${tokStr} ${ageStr} "${topic}"`);
7118
+ for (const b of shown) {
7119
+ lines.push(
7120
+ mode === "detailed" ? renderDetailedRow(b, idWidth) : renderSummaryRow(b, idWidth)
7121
+ );
7122
+ }
7123
+ if (truncated > 0) {
7124
+ lines.push("");
7125
+ lines.push(`${shown.length} of ${sorted.length} blocks shown (${truncated} hidden). Raise limit or change sort to see more.`);
6765
7126
  }
6766
7127
  lines.push("");
6767
- lines.push(
6768
- "Use decompress to restore a block's full content, or search_context to search within compressed blocks."
6769
- );
7128
+ const sortHint = sort === "recent" ? 'sorted by recent. Use acp_status({sort:"size"}) for largest, {sort:"age"} for near-GC.' : `sorted by ${sort}.`;
7129
+ lines.push(`${sortHint} Use decompress to restore a block's full content, or search_context to search within compressed blocks.`);
6770
7130
  return lines.join("\n");
6771
7131
  }
6772
7132
  });
@@ -6978,23 +7338,76 @@ import { homedir as homedir4 } from "os";
6978
7338
  // lib/prompts/system.ts
6979
7339
  var SYSTEM = `
6980
7340
 
6981
- 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.
7341
+ You operate in a context-constrained environment. All compression serves the primary task, but be frugal. 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.
7342
+
7343
+ ACP TAGS
7344
+
7345
+ \`<acp-context>\` tags wrap ACP (Agent Context Pruning) system metadata \u2014 context management information injected each turn. This is system data, not user input. You may also see \`<dcp-message-id>\` and \`<dcp-system-reminder>\` tags \u2014 these are equivalent (DCP was the previous name for ACP). Treat them as boundary metadata only, not as tool-result content.
6982
7346
 
6983
- The tools you have for context management are \`compress\`, \`decompress\`, and \`search_context\`. \`compress\` replaces older conversation content with technical summaries you produce. \`decompress\` restores previously compressed content when you need exact details. \`search_context\` searches compressed block summaries (and visible messages) to locate relevant content before you decompress.
7347
+ TOOLS
6984
7348
 
6985
- \`<acp-context>\` tags wrap ACP (Agent Context Pruning) system metadata \u2014 context management information injected each turn. This is system data, not user input. You may also see \`<dcp-message-id>\` and \`<dcp-system-reminder>\` tags \u2014 these are equivalent (DCP was the previous name for ACP).
7349
+ You have four context-management tools:
7350
+
7351
+ - \`compress\` \u2014 Replace a contiguous range of older conversation with a single detailed summary you write. Use when content is genuinely consumed (no longer needed for the current task step). Example: \`compress({ topic: "API exploration", content: [{ startId: "m00150", endId: "m00220", summary: "..." }] })\`.
7352
+ - \`decompress\` \u2014 Restore a previously compressed block's full original content, optionally to a file for large blocks. Use when a summary lacks the exact detail you need. Example: \`decompress({ blockId: "b5" })\` or \`decompress({ blockId: "b5", toFile: "path" })\`.
7353
+ - \`search_context\` \u2014 Search compressed block summaries (and optionally visible messages) by keyword. Use BEFORE decompressing to find the right block. Example: \`search_context({ query: "auth token refresh" })\`.
7354
+ - \`acp_status\` \u2014 List all active compressed blocks with their sizes, ages, and the message ranges they consumed. Use when you are unsure which IDs are still compressible, or before choosing compress boundaries. Example: \`acp_status({ mode: "summary", sort: "recent" })\`.
6986
7355
 
6987
7356
  COMPRESSION PHILOSOPHY
6988
7357
 
6989
- All compression serves the primary task, but be frugal. Two failure modes to avoid:
7358
+ Two failure modes to avoid:
6990
7359
  - Over-compression: Compressing too aggressively loses critical details, decisions, and state needed for your task. This directly harms task quality.
6991
7360
  - Under-compression: Failing to compress verbose outputs causes context overflow, reducing accuracy and eventually blocking your work.
6992
7361
 
6993
- Balance is key. Compress selectively to keep context lean. But never compress content you're actively using for an ongoing task. Use \`search_context\` to find compressed content when needed, and \`decompress\` to restore details.
7362
+ Balance is key. The single test for whether to compress is: "Is this content still needed by the current task step?" If yes, keep it. If no, it is a candidate. When uncertain, lean toward keeping content.
6994
7363
 
6995
7364
  BE FRUGAL
6996
7365
 
6997
- Be frugal with context \u2014 compress obvious waste promptly. Examples include verbose command output (build/test logs, git diff/status, npm install), sub-agent results once consumed, experiment/training logs (keep final metrics only), duplicate file reads, and failed explorations. Any content that is finished serving the task and would not be needed in upcoming turns should be compressed \u2014 not just these examples.
7366
+ Be frugal with context. Compress obvious waste proactively when you encounter it \u2014 verbose outputs you have already used, duplicate reads, abandoned explorations. Do not wait until context is critically full before compressing; that harms retrieval quality and risks overflow. When compressing, cover the largest range you can in a single call \u2014 aim for 20+ messages. Compressing 3-5 messages at a time creates many small summaries that collectively waste more tokens than they save. But never let the urge to compress distract from the actual task.
7367
+
7368
+ WHEN TO COMPRESS
7369
+
7370
+ - A sub-agent or delegated task has returned a large result that you have already extracted the key facts from.
7371
+ - Verbose command output (build/test logs, \`git diff\`, \`npm install\`, directory listings) where you have already used the information you need.
7372
+ - Exploration that led nowhere \u2014 compress the dead-ends but preserve the lessons learned: what was tried, what failed, and why.
7373
+ - Repeated reads of the same file or repeated status checks once the decision is recorded.
7374
+ - Resolved discussion threads where a decision has been captured in the summary or in code \u2014 compress the back-and-forth but preserve the decision rationale if it will be referenced later.
7375
+ - Intermediate steps of a completed multi-step task, once the final result is recorded.
7376
+ - When a task phase ends \u2014 such as finishing a bug hunt, locating a root cause, wrapping up a codebase exploration, or completing a research sprint \u2014 proactively compress the phase's redundant churn (exploratory reads, failed attempts, verbose outputs) while preserving what endures: key findings, relevant code and file paths, decision rationale, and lessons learned (what worked, what didn't, what's worth remembering next time).
7377
+ - Any other content where compression serves the primary task \u2014 be frugal.
7378
+
7379
+ WHEN NOT TO COMPRESS
7380
+
7381
+ - Content the current task step is actively reading or reasoning about.
7382
+ - Important user messages \u2014 preserve their exact intent, constraints, and acceptance criteria verbatim, not just the most recent one.
7383
+ - Outputs from protected tools (e.g. \`task\`, \`skill\`, \`todowrite\`, \`write\`, \`edit\`) \u2014 these are appended to summaries automatically, not compressed away.
7384
+
7385
+ PERIODIC CONTEXT STATUS
7386
+
7387
+ Periodically, as context grows, the system appends a short status line in a synthetic suffix message. It looks like:
7388
+
7389
+ [ACP] Context: 47.3K tokens. Visible: m00001\u2013m00929, m00944\u2013m00950 (810 msgs). 3 active blocks. \`acp_status\` for details.
7390
+
7391
+ This line is INFORMATION, not an instruction. Seeing it does not mean you should compress. Compress only when one of the WHEN TO COMPRESS conditions actually holds. Between these lines, context is not under additional pressure \u2014 you do not need to seek things to compress.
7392
+
7393
+ If you are unsure which \`mNNNNN\` refs are still compressible, or which blocks have already consumed which ranges, call \`acp_status\` first. It returns the block IDs, their sizes, and the message-ID ranges each covers.
7394
+
7395
+ CONTEXT BREAKDOWN
7396
+
7397
+ When context usage passes a threshold, the system appends a breakdown showing where your context tokens are spent:
7398
+
7399
+ Breakdown: 12.3K tool (40%) | 3.1K summaries (10%) | 8.5K code (28%) | 6.5K text (22%)
7400
+
7401
+ - "tool" = tool call outputs (largest category \u2014 compress first when consumed)
7402
+ - "summaries" = existing compression block summaries (already compressed; do not re-compress standalone)
7403
+ - "code" = messages containing code blocks
7404
+ - "text" = plain text messages
7405
+
7406
+ Below the breakdown, the system lists the largest ranges in each category (e.g. \`Largest tool outputs: m00175 (20.7K), m00200 (8.1K)\`). These are high-value compression candidates \u2014 compress those whose content you have already consumed (extracted the facts you need). Keep any you still need to reference.
7407
+
7408
+ Compress incrementally: target one large consumed range per compress call (e.g. m00150\u2192m00200), not the entire context at once. Each compression creates a reusable summary block you can decompress later if needed.
7409
+
7410
+ <acp-compression-summary>\`<acp-compression-summary>\` tags wrap ACP model-generated recaps of previously compressed conversation ranges. These are system-generated metadata, not user messages. Treat them as reference material for the compressed history.
6998
7411
  `;
6999
7412
 
7000
7413
  // lib/prompts/compress-range.ts
@@ -7049,7 +7462,7 @@ When multiple independent ranges are ready and their boundaries do not overlap,
7049
7462
  var COMPRESS_MESSAGE = `Collapse selected individual messages in the conversation into detailed summaries.
7050
7463
 
7051
7464
  THE SUMMARY
7052
- Your summary must be EXHAUSTIVE. Capture file paths, function signatures, decisions made, constraints discovered, key findings, tool outcomes, and user intent details that matter... EVERYTHING that preserves the value of the selected message after the raw message is removed.
7465
+ Your summary must be EXHAUSTIVE. Capture file paths, function signatures, decisions made, constraints discovered, key findings, tool outcomes, and user intent details that matter... EVERYTHING that preserves the value of the selected message after it is summarized. The original content can be restored via decompress if needed later.
7053
7466
 
7054
7467
  USER INTENT FIDELITY
7055
7468
  When a selected message contains user intent, preserve that intent with extra care. Do not change scope, constraints, priorities, acceptance criteria, or requested outcomes.
@@ -8751,7 +9164,8 @@ function runMajorGC(state, config, logger, messages) {
8751
9164
  agedOutTokens,
8752
9165
  maxBlockAge
8753
9166
  });
8754
- void saveSessionState(state, logger);
9167
+ saveSessionState(state, logger).catch(() => {
9168
+ });
8755
9169
  }
8756
9170
  if (!state.modelContextLimit) return;
8757
9171
  const currentTokens = getCurrentTokenUsage(state, messages);
@@ -8781,7 +9195,8 @@ function runMajorGC(state, config, logger, messages) {
8781
9195
  currentTokens,
8782
9196
  threshold: config.gc.majorGcThresholdPercent
8783
9197
  });
8784
- void saveSessionState(state, logger);
9198
+ saveSessionState(state, logger).catch(() => {
9199
+ });
8785
9200
  }
8786
9201
  }
8787
9202
  function createChatMessageTransformHandler(client, state, logger, config, prompts, hostPermissions) {
@@ -8809,14 +9224,16 @@ function createChatMessageTransformHandler(client, state, logger, config, prompt
8809
9224
  const activeBlockCountBefore = state.prune.messages.activeBlockIds.size;
8810
9225
  syncCompressionBlocks(state, logger, output.messages);
8811
9226
  if (state.prune.messages.activeBlockIds.size !== activeBlockCountBefore) {
8812
- void saveSessionState(state, logger);
9227
+ saveSessionState(state, logger).catch(() => {
9228
+ });
8813
9229
  }
8814
9230
  syncToolCache(state, config, logger, output.messages);
8815
9231
  buildToolIdList(state, output.messages);
8816
9232
  runMajorGC(state, config, logger, output.messages);
8817
9233
  const batchResult = runBatchCleanup(state, config, logger, output.messages);
8818
9234
  if (batchResult.mergedCount > 0) {
8819
- void saveSessionState(state, logger);
9235
+ saveSessionState(state, logger).catch(() => {
9236
+ });
8820
9237
  }
8821
9238
  prune(state, logger, config, output.messages);
8822
9239
  assignMessageRefs(state, output.messages);