opencode-acp 1.16.0-pr.360.124 → 1.16.0-pr.360.128

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
@@ -890,6 +890,7 @@ var VALID_CONFIG_KEYS = /* @__PURE__ */ new Set([
890
890
  "compress.modelMaxLimits",
891
891
  "compress.modelMinLimits",
892
892
  "compress.providers",
893
+ "compress.contextLimitFallback",
893
894
  "compress.nudgeFrequency",
894
895
  "compress.minNudgeContextPercent",
895
896
  "compress.nudgeGrowthTokens",
@@ -913,6 +914,7 @@ var VALID_CONFIG_KEYS = /* @__PURE__ */ new Set([
913
914
  "compress.reasoning",
914
915
  "compress.reasoning.drop",
915
916
  "compress.reasoning.threshold",
917
+ "compress.completionReserveTokens",
916
918
  "gc",
917
919
  "gc.algorithm",
918
920
  "gc.promotionThreshold",
@@ -964,7 +966,11 @@ function validateConfigTypes(config) {
964
966
  errors.push({ key: "storagePath", expected: "string", actual: typeof config.storagePath });
965
967
  }
966
968
  if (config.allowSubAgents !== void 0 && typeof config.allowSubAgents !== "boolean") {
967
- errors.push({ key: "allowSubAgents", expected: "boolean", actual: typeof config.allowSubAgents });
969
+ errors.push({
970
+ key: "allowSubAgents",
971
+ expected: "boolean",
972
+ actual: typeof config.allowSubAgents
973
+ });
968
974
  }
969
975
  if (config.pruneNotification !== void 0) {
970
976
  const validValues = ["off", "minimal", "detailed"];
@@ -1293,6 +1299,20 @@ function validateConfigTypes(config) {
1293
1299
  }
1294
1300
  }
1295
1301
  }
1302
+ if (compress.completionReserveTokens !== void 0 && typeof compress.completionReserveTokens !== "number") {
1303
+ errors.push({
1304
+ key: "compress.completionReserveTokens",
1305
+ expected: "number",
1306
+ actual: typeof compress.completionReserveTokens
1307
+ });
1308
+ }
1309
+ if (typeof compress.completionReserveTokens === "number" && compress.completionReserveTokens < 0) {
1310
+ errors.push({
1311
+ key: "compress.completionReserveTokens",
1312
+ expected: "non-negative number (>= 0)",
1313
+ actual: `${compress.completionReserveTokens}`
1314
+ });
1315
+ }
1296
1316
  if (typeof compress.iterationNudgeThreshold === "number" && compress.iterationNudgeThreshold < 1) {
1297
1317
  errors.push({
1298
1318
  key: "compress.iterationNudgeThreshold",
@@ -1411,12 +1431,20 @@ function validateConfigTypes(config) {
1411
1431
  break;
1412
1432
  case "nudgeForce":
1413
1433
  if (value !== "strong" && value !== "soft") {
1414
- errors.push({ key, expected: "'strong' | 'soft'", actual: JSON.stringify(value) });
1434
+ errors.push({
1435
+ key,
1436
+ expected: "'strong' | 'soft'",
1437
+ actual: JSON.stringify(value)
1438
+ });
1415
1439
  }
1416
1440
  break;
1417
1441
  case "stringArray":
1418
1442
  if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) {
1419
- errors.push({ key, expected: "string[]", actual: JSON.stringify(value) });
1443
+ errors.push({
1444
+ key,
1445
+ expected: "string[]",
1446
+ actual: JSON.stringify(value)
1447
+ });
1420
1448
  }
1421
1449
  break;
1422
1450
  case "reasoningConfig":
@@ -1451,7 +1479,11 @@ function validateConfigTypes(config) {
1451
1479
  return;
1452
1480
  }
1453
1481
  if (typeof overrides !== "object" || overrides === null || Array.isArray(overrides)) {
1454
- errors.push({ key: prefix, expected: "CompressModelOverrides", actual: typeof overrides });
1482
+ errors.push({
1483
+ key: prefix,
1484
+ expected: "CompressModelOverrides",
1485
+ actual: typeof overrides
1486
+ });
1455
1487
  return;
1456
1488
  }
1457
1489
  const model = overrides;
@@ -1484,7 +1516,11 @@ function validateConfigTypes(config) {
1484
1516
  for (const [providerId, providerValue] of Object.entries(providers)) {
1485
1517
  const prefix = `compress.providers.${providerId}`;
1486
1518
  if (typeof providerValue !== "object" || providerValue === null || Array.isArray(providerValue)) {
1487
- errors.push({ key: prefix, expected: "ProviderOverrides", actual: typeof providerValue });
1519
+ errors.push({
1520
+ key: prefix,
1521
+ expected: "ProviderOverrides",
1522
+ actual: typeof providerValue
1523
+ });
1488
1524
  continue;
1489
1525
  }
1490
1526
  const provider = providerValue;
@@ -1521,6 +1557,20 @@ function validateConfigTypes(config) {
1521
1557
  }
1522
1558
  };
1523
1559
  validateProviderOverrides(compress.providers);
1560
+ if (compress.contextLimitFallback !== void 0 && typeof compress.contextLimitFallback !== "number") {
1561
+ errors.push({
1562
+ key: "compress.contextLimitFallback",
1563
+ expected: "number",
1564
+ actual: typeof compress.contextLimitFallback
1565
+ });
1566
+ }
1567
+ if (typeof compress.contextLimitFallback === "number" && compress.contextLimitFallback < 0) {
1568
+ errors.push({
1569
+ key: "compress.contextLimitFallback",
1570
+ expected: "non-negative number (0 disables the fallback)",
1571
+ actual: `${compress.contextLimitFallback}`
1572
+ });
1573
+ }
1524
1574
  const validValues = ["ask", "allow", "deny"];
1525
1575
  if (compress.permission !== void 0 && !validValues.includes(compress.permission)) {
1526
1576
  errors.push({
@@ -1606,13 +1656,22 @@ function validateConfigTypes(config) {
1606
1656
  });
1607
1657
  } else {
1608
1658
  if (gc.batchCleanup.lowThreshold !== void 0) {
1609
- validateBatchThreshold("gc.batchCleanup.lowThreshold", gc.batchCleanup.lowThreshold);
1659
+ validateBatchThreshold(
1660
+ "gc.batchCleanup.lowThreshold",
1661
+ gc.batchCleanup.lowThreshold
1662
+ );
1610
1663
  }
1611
1664
  if (gc.batchCleanup.highThreshold !== void 0) {
1612
- validateBatchThreshold("gc.batchCleanup.highThreshold", gc.batchCleanup.highThreshold);
1665
+ validateBatchThreshold(
1666
+ "gc.batchCleanup.highThreshold",
1667
+ gc.batchCleanup.highThreshold
1668
+ );
1613
1669
  }
1614
1670
  if (gc.batchCleanup.forceThreshold !== void 0) {
1615
- validateBatchThreshold("gc.batchCleanup.forceThreshold", gc.batchCleanup.forceThreshold);
1671
+ validateBatchThreshold(
1672
+ "gc.batchCleanup.forceThreshold",
1673
+ gc.batchCleanup.forceThreshold
1674
+ );
1616
1675
  }
1617
1676
  }
1618
1677
  }
@@ -1702,6 +1761,7 @@ var defaultConfig = {
1702
1761
  summaryBuffer: true,
1703
1762
  maxContextLimit: "80%",
1704
1763
  minContextLimit: "80%",
1764
+ contextLimitFallback: 128e3,
1705
1765
  nudgeFrequency: 5,
1706
1766
  minNudgeContextPercent: 5,
1707
1767
  iterationNudgeThreshold: 15,
@@ -1872,6 +1932,7 @@ function mergeCompress(base, override) {
1872
1932
  modelMaxLimits: override.modelMaxLimits ?? base.modelMaxLimits,
1873
1933
  modelMinLimits: override.modelMinLimits ?? base.modelMinLimits,
1874
1934
  providers: mergeProviderOverrides(base.providers, override.providers),
1935
+ contextLimitFallback: override.contextLimitFallback ?? base.contextLimitFallback,
1875
1936
  nudgeFrequency: override.nudgeFrequency ?? base.nudgeFrequency,
1876
1937
  minNudgeContextPercent: override.minNudgeContextPercent ?? base.minNudgeContextPercent,
1877
1938
  nudgeGrowthTokens: override.nudgeGrowthTokens,
@@ -1895,7 +1956,8 @@ function mergeCompress(base, override) {
1895
1956
  reasoning: {
1896
1957
  drop: override.reasoning?.drop ?? base.reasoning?.drop ?? DEFAULT_COMPRESS_REASONING.drop,
1897
1958
  threshold: override.reasoning?.threshold ?? base.reasoning?.threshold ?? DEFAULT_COMPRESS_REASONING.threshold
1898
- }
1959
+ },
1960
+ completionReserveTokens: override.completionReserveTokens ?? base.completionReserveTokens
1899
1961
  };
1900
1962
  }
1901
1963
  function mergeCommands(base, override) {
@@ -1933,10 +1995,9 @@ function deepCloneConfig(config) {
1933
1995
  ...provider,
1934
1996
  ...provider.models ? {
1935
1997
  models: Object.fromEntries(
1936
- Object.entries(provider.models).map(([modelId, model]) => [
1937
- modelId,
1938
- { ...model }
1939
- ])
1998
+ Object.entries(provider.models).map(
1999
+ ([modelId, model]) => [modelId, { ...model }]
2000
+ )
1940
2001
  )
1941
2002
  } : {}
1942
2003
  }
@@ -2010,8 +2071,14 @@ function mergeLayer(config, data) {
2010
2071
  ],
2011
2072
  compress: mergeCompress(config.compress, data.compress),
2012
2073
  gc: mergeGC(config.gc, data.gc),
2013
- qualityGate: mergeQualityGate(config.qualityGate, data.qualityGate),
2014
- messageFilters: mergeMessageFilters(config.messageFilters, data.messageFilters)
2074
+ qualityGate: mergeQualityGate(
2075
+ config.qualityGate,
2076
+ data.qualityGate
2077
+ ),
2078
+ messageFilters: mergeMessageFilters(
2079
+ config.messageFilters,
2080
+ data.messageFilters
2081
+ )
2015
2082
  };
2016
2083
  }
2017
2084
  function scheduleParseWarning(ctx, title, message) {
@@ -2159,6 +2226,56 @@ var messageHasCompressAttempt = (message) => {
2159
2226
  const parts = Array.isArray(message.parts) ? message.parts : [];
2160
2227
  return parts.some((part) => part.type === "tool" && part.tool === "compress");
2161
2228
  };
2229
+ var isCaptureOnlyCompress = (message) => {
2230
+ if (!isMessageWithInfo(message)) {
2231
+ return false;
2232
+ }
2233
+ if (message.info.role !== "assistant") {
2234
+ return false;
2235
+ }
2236
+ const parts = Array.isArray(message.parts) ? message.parts : [];
2237
+ let sawBoundary = false;
2238
+ for (const part of parts) {
2239
+ if (!(part.type === "tool" && part.tool === "compress")) {
2240
+ continue;
2241
+ }
2242
+ for (const startId of extractCompressBoundaryIds(part.state?.input)) {
2243
+ sawBoundary = true;
2244
+ if (/^b\d+$/i.test(startId)) {
2245
+ return false;
2246
+ }
2247
+ }
2248
+ }
2249
+ return sawBoundary;
2250
+ };
2251
+ function extractCompressBoundaryIds(rawInput) {
2252
+ let content = [];
2253
+ if (typeof rawInput === "string") {
2254
+ try {
2255
+ const parsed = JSON.parse(rawInput);
2256
+ const c = parsed?.content;
2257
+ content = Array.isArray(c) ? c : [];
2258
+ } catch {
2259
+ return [];
2260
+ }
2261
+ } else if (rawInput && typeof rawInput === "object") {
2262
+ const c = rawInput.content;
2263
+ content = Array.isArray(c) ? c : [];
2264
+ }
2265
+ const ids = [];
2266
+ for (const entry of content) {
2267
+ if (!entry || typeof entry !== "object") {
2268
+ continue;
2269
+ }
2270
+ const { startId, endId } = entry;
2271
+ for (const sid of [startId, endId]) {
2272
+ if (typeof sid === "string" && sid.trim() !== "") {
2273
+ ids.push(sid.trim());
2274
+ }
2275
+ }
2276
+ }
2277
+ return ids;
2278
+ }
2162
2279
  var isIgnoredUserMessage = (message) => {
2163
2280
  if (!isMessageWithInfo(message)) {
2164
2281
  return false;
@@ -2635,6 +2752,16 @@ function resetOnCompaction(state) {
2635
2752
  nextRef: 1
2636
2753
  };
2637
2754
  }
2755
+ function resolveEffectiveContextLimit(state, config) {
2756
+ if (typeof state.modelContextLimit === "number" && state.modelContextLimit > 0) {
2757
+ return { limit: state.modelContextLimit, source: "model" };
2758
+ }
2759
+ const fallback = config.compress.contextLimitFallback;
2760
+ if (typeof fallback === "number" && fallback > 0) {
2761
+ return { limit: fallback, source: "fallback" };
2762
+ }
2763
+ return void 0;
2764
+ }
2638
2765
 
2639
2766
  // lib/state/persistence.ts
2640
2767
  function getDefaultStorageDir() {
@@ -4506,6 +4633,24 @@ var SessionStateRegistry = class {
4506
4633
  hydrateModelLimitsFromClient(client) {
4507
4634
  return this.catalog.hydrateFromClient(client);
4508
4635
  }
4636
+ // [FIX #346] The init-time seed (above) is fire-and-forget and races
4637
+ // server readiness: in headless spawn+resume mode the provider-config
4638
+ // call can fail before the server is up, leaving the catalog empty for
4639
+ // the process's lifetime. During a request the server is guaranteed up
4640
+ // (we are inside its pipeline), so on a catalog miss we retry hydration
4641
+ // once per process before giving up (the fallback limit then applies).
4642
+ // The in-flight promise (not a boolean) lets concurrent callers await the
4643
+ // same hydration instead of skipping it.
4644
+ lazyHydration;
4645
+ async hydrateAndResolve(client, providerId, modelId) {
4646
+ const existing = this.catalog.resolve(providerId, modelId);
4647
+ if (existing !== void 0) {
4648
+ return existing;
4649
+ }
4650
+ this.lazyHydration ??= this.catalog.hydrateFromClient(client);
4651
+ await this.lazyHydration;
4652
+ return this.catalog.resolve(providerId, modelId);
4653
+ }
4509
4654
  get(sessionId) {
4510
4655
  return this.states.get(sessionId);
4511
4656
  }
@@ -4599,7 +4744,8 @@ function createSessionState() {
4599
4744
  modelID: void 0,
4600
4745
  systemPromptTokens: void 0,
4601
4746
  storageDir: void 0,
4602
- qualityGateRetryPending: false
4747
+ qualityGateRetryPending: false,
4748
+ noContextLimitWarned: false
4603
4749
  };
4604
4750
  }
4605
4751
  function resetSessionState(state) {
@@ -4642,6 +4788,7 @@ function resetSessionState(state) {
4642
4788
  state.systemPromptTokens = void 0;
4643
4789
  state.storageDir = void 0;
4644
4790
  state.qualityGateRetryPending = false;
4791
+ state.noContextLimitWarned = false;
4645
4792
  }
4646
4793
  async function ensureSessionInitialized(client, state, sessionId, logger, messages, config, projectDir) {
4647
4794
  if (state.sessionId === sessionId) {
@@ -6657,6 +6804,7 @@ function getModelInfo(messages) {
6657
6804
  };
6658
6805
  }
6659
6806
  function resolveContextTokenLimit(config, state, providerId, modelId, threshold) {
6807
+ const effectiveLimit = resolveEffectiveContextLimit(state, config);
6660
6808
  const parseLimitValue = (limit) => {
6661
6809
  if (limit === void 0) {
6662
6810
  return void 0;
@@ -6664,7 +6812,7 @@ function resolveContextTokenLimit(config, state, providerId, modelId, threshold)
6664
6812
  if (typeof limit === "number") {
6665
6813
  return limit;
6666
6814
  }
6667
- if (!limit.endsWith("%") || state.modelContextLimit === void 0) {
6815
+ if (!limit.endsWith("%") || effectiveLimit === void 0) {
6668
6816
  return void 0;
6669
6817
  }
6670
6818
  const parsedPercent = parseFloat(limit.slice(0, -1));
@@ -6673,7 +6821,7 @@ function resolveContextTokenLimit(config, state, providerId, modelId, threshold)
6673
6821
  }
6674
6822
  const roundedPercent = Math.round(parsedPercent);
6675
6823
  const clampedPercent = Math.max(0, Math.min(100, roundedPercent));
6676
- return Math.round(clampedPercent / 100 * state.modelContextLimit);
6824
+ return Math.round(clampedPercent / 100 * effectiveLimit.limit);
6677
6825
  };
6678
6826
  if (threshold === "max") {
6679
6827
  const nestedLimit = resolveCompressOverrides(config, providerId, modelId).maxContextLimit;
@@ -6724,11 +6872,12 @@ function isContextOverLimits(config, state, providerId, modelId, messages) {
6724
6872
  if (!overMaxLimit) break;
6725
6873
  }
6726
6874
  }
6875
+ const effectiveLimit = resolveEffectiveContextLimit(state, config);
6727
6876
  return {
6728
6877
  overMaxLimit,
6729
6878
  overMinLimit,
6730
6879
  currentTokens,
6731
- modelContextLimit: state.modelContextLimit
6880
+ modelContextLimit: effectiveLimit?.limit
6732
6881
  };
6733
6882
  }
6734
6883
  ensureBuiltinTriggerPolicyRegistered();
@@ -7888,8 +8037,11 @@ var injectCompressNudges = (state, config, logger, messages, prompts, compressio
7888
8037
  state.nudges.iterationNudgeAnchors.clear();
7889
8038
  state.nudges.lastNudgeShownTokens = void 0;
7890
8039
  state.nudges.lastToolOutputNudgeTokens = void 0;
7891
- state.nudges.lastTier2NudgeTokens = currentTokens;
7892
- state.nudges.lastTier3NudgeTokens = currentTokens;
8040
+ const captureOnly = isCaptureOnlyCompress(lastCompressMsg);
8041
+ if (!captureOnly) {
8042
+ state.nudges.lastTier2NudgeTokens = currentTokens;
8043
+ state.nudges.lastTier3NudgeTokens = currentTokens;
8044
+ }
7893
8045
  const currentTurnHasSuccessfulCompress = messages.slice(currentTurnStart).some((m) => m.info.role === "assistant" && messageHasCompress(m));
7894
8046
  if (currentTurnHasSuccessfulCompress && wasNudgeTriggered && !state.nudges.compressBaselineSet) {
7895
8047
  const baseline = state.nudges.lastPerMessageNudgeTokens;
@@ -8802,8 +8954,9 @@ function createDecompressTool(factoryCtx) {
8802
8954
  async execute(args, toolCtx) {
8803
8955
  const ctx = resolveToolContext(factoryCtx, toolCtx.sessionID);
8804
8956
  const { rawMessages } = await prepareDecompressSession(ctx, toolCtx);
8805
- const contextUsageBefore = ctx.state.modelContextLimit ? Math.round(
8806
- getCurrentTokenUsage(ctx.state, rawMessages) / ctx.state.modelContextLimit * 100
8957
+ const effectiveLimitBefore = resolveEffectiveContextLimit(ctx.state, ctx.config);
8958
+ const contextUsageBefore = effectiveLimitBefore ? Math.round(
8959
+ getCurrentTokenUsage(ctx.state, rawMessages) / effectiveLimitBefore.limit * 100
8807
8960
  ) : void 0;
8808
8961
  const resolved = resolveTargets(args, ctx.state, rawMessages, ctx.logger);
8809
8962
  if (!resolved.ok) {
@@ -8867,8 +9020,9 @@ function createDecompressTool(factoryCtx) {
8867
9020
  0,
8868
9021
  ctx.state.stats.totalPruneTokens - restoredTokens
8869
9022
  );
8870
- const contextUsageAfter = ctx.state.modelContextLimit ? Math.round(
8871
- getCurrentTokenUsage(ctx.state, rawMessages) / ctx.state.modelContextLimit * 100
9023
+ const effectiveLimitAfter = resolveEffectiveContextLimit(ctx.state, ctx.config);
9024
+ const contextUsageAfter = effectiveLimitAfter ? Math.round(
9025
+ getCurrentTokenUsage(ctx.state, rawMessages) / effectiveLimitAfter.limit * 100
8872
9026
  ) : void 0;
8873
9027
  await finalizeDecompressSession(ctx);
8874
9028
  const restoredContentPreview = buildRestoredContentPreview(
@@ -9525,7 +9679,7 @@ import { writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
9525
9679
  import { join as join4 } from "path";
9526
9680
  import { existsSync as existsSync4 } from "fs";
9527
9681
  import { homedir as homedir3 } from "os";
9528
- var LOG_VERSION = true ? "1.16.0-pr.360.124" : "dev";
9682
+ var LOG_VERSION = true ? "1.16.0-pr.360.128" : "dev";
9529
9683
  var LEVEL_RANK = {
9530
9684
  debug: 10,
9531
9685
  info: 20,
@@ -10349,6 +10503,8 @@ var MIN_OUTPUT_TOKENS = 1e3;
10349
10503
  var KEEP_PREFIX_CHARS = 2e3;
10350
10504
  var KEEP_SUFFIX_CHARS = 2e3;
10351
10505
  var PROTECT_RECENT_MESSAGES = 3;
10506
+ var OUTPUT_RESERVE_TOKENS = 16384;
10507
+ var overheadErrorLogged = /* @__PURE__ */ new Set();
10352
10508
  function parseGcThreshold(threshold, modelContextLimit) {
10353
10509
  if (typeof threshold === "number") return threshold;
10354
10510
  const str = threshold ?? "100%";
@@ -10357,10 +10513,26 @@ function parseGcThreshold(threshold, modelContextLimit) {
10357
10513
  return modelContextLimit;
10358
10514
  }
10359
10515
  function truncateLargeToolOutputs(state, config, logger, messages) {
10360
- if (!state.modelContextLimit) return;
10516
+ const effective = resolveEffectiveContextLimit(state, config);
10517
+ if (!effective) return;
10361
10518
  const currentTokens = getCurrentTokenUsage(state, messages);
10362
10519
  if (currentTokens === 0) return;
10363
- const threshold = parseGcThreshold(config.gc?.majorGcThresholdPercent, state.modelContextLimit);
10520
+ const configuredThreshold = parseGcThreshold(config.gc?.majorGcThresholdPercent, effective.limit);
10521
+ const overhead = (state.systemPromptTokens ?? 0) + OUTPUT_RESERVE_TOKENS;
10522
+ const threshold = Math.min(configuredThreshold, effective.limit - overhead);
10523
+ if (threshold <= 0) {
10524
+ const sessionKey = state.sessionId ?? "unknown";
10525
+ if (!overheadErrorLogged.has(sessionKey)) {
10526
+ overheadErrorLogged.add(sessionKey);
10527
+ logger.error("ACP: model context window too small to fit overhead", {
10528
+ session: state.sessionId,
10529
+ limit: effective.limit,
10530
+ contextLimitSource: effective.source,
10531
+ overhead
10532
+ });
10533
+ }
10534
+ return;
10535
+ }
10364
10536
  if (currentTokens < threshold) return;
10365
10537
  const protectedIndex = messages.length - PROTECT_RECENT_MESSAGES;
10366
10538
  const candidates = [];
@@ -10405,9 +10577,158 @@ function truncateLargeToolOutputs(state, config, logger, messages) {
10405
10577
  truncatedCount,
10406
10578
  estimatedSavedTokens: Math.round(savedTokens),
10407
10579
  currentTokens,
10408
- threshold
10580
+ threshold,
10581
+ contextLimit: effective.limit,
10582
+ contextLimitSource: effective.source
10583
+ });
10584
+ }
10585
+ }
10586
+
10587
+ // lib/messages/enforce-budget.ts
10588
+ var DEFAULT_COMPLETION_RESERVE_TOKENS = 32768;
10589
+ var TRUNCATION_MARKER2 = "[truncated for context space";
10590
+ var KEEP_PREFIX_CHARS2 = 2e3;
10591
+ var KEEP_SUFFIX_CHARS2 = 2e3;
10592
+ var PROTECT_RECENT_MESSAGES2 = 3;
10593
+ var MIN_CLEAR_TOKENS = 200;
10594
+ function resolveContextWindow(state) {
10595
+ if (typeof state.modelContextLimit === "number" && state.modelContextLimit > 0) {
10596
+ return state.modelContextLimit;
10597
+ }
10598
+ return void 0;
10599
+ }
10600
+ function estimateWireTokens(state, messages) {
10601
+ const base = getCurrentTokenUsage(state, messages);
10602
+ if (base > 0) {
10603
+ let baseAssistant = -1;
10604
+ for (let i = messages.length - 1; i >= 0; i--) {
10605
+ if (messages[i].info.role !== "assistant") continue;
10606
+ const tokens = messages[i].info.tokens;
10607
+ if ((tokens?.input || 0) <= 0 && (tokens?.output || 0) <= 0) continue;
10608
+ baseAssistant = i;
10609
+ break;
10610
+ }
10611
+ if (baseAssistant >= 0) {
10612
+ let additions = 0;
10613
+ for (let i = baseAssistant + 1; i < messages.length; i++) {
10614
+ additions += countAllMessageTokens(messages[i]);
10615
+ }
10616
+ return base + additions;
10617
+ }
10618
+ }
10619
+ let total = 0;
10620
+ for (const m of messages) total += countAllMessageTokens(m);
10621
+ return total + (state.systemPromptTokens ?? 0);
10622
+ }
10623
+ function enforceContextBudget(state, config, logger, messages) {
10624
+ const window = resolveContextWindow(state);
10625
+ if (window === void 0) return void 0;
10626
+ const configuredReserve = config.compress?.completionReserveTokens;
10627
+ const reserve = typeof configuredReserve === "number" && Number.isFinite(configuredReserve) && configuredReserve >= 0 ? configuredReserve : DEFAULT_COMPLETION_RESERVE_TOKENS;
10628
+ const budget = window - reserve;
10629
+ if (budget <= 0) return void 0;
10630
+ const estimatedTokens = estimateWireTokens(state, messages);
10631
+ if (estimatedTokens <= budget) {
10632
+ return {
10633
+ applied: false,
10634
+ window,
10635
+ reserve,
10636
+ budget,
10637
+ estimatedTokens,
10638
+ finalEstimate: estimatedTokens,
10639
+ truncatedCount: 0,
10640
+ clearedCount: 0
10641
+ };
10642
+ }
10643
+ const protectedIndex = messages.length - PROTECT_RECENT_MESSAGES2;
10644
+ const protectedTools = new Set(config.compress?.protectedTools ?? []);
10645
+ const candidates = [];
10646
+ for (let mi = 0; mi < protectedIndex; mi++) {
10647
+ if (mi === 0 && messages[mi].info.role === "user") continue;
10648
+ const msg = messages[mi];
10649
+ const parts = Array.isArray(msg.parts) ? msg.parts : [];
10650
+ for (const part of parts) {
10651
+ if (part?.type !== "tool") continue;
10652
+ if (part.state?.status !== "completed") continue;
10653
+ if (part.tool === "compress") continue;
10654
+ if (protectedTools.has(part.tool)) continue;
10655
+ const content = extractCompletedToolOutput(part);
10656
+ if (content === void 0) continue;
10657
+ if (content === COMPACTED_TOOL_OUTPUT_PLACEHOLDER) continue;
10658
+ const tokens = countTokens2(content);
10659
+ if (tokens <= 0) continue;
10660
+ candidates.push({ part, content, tokens, index: mi });
10661
+ }
10662
+ }
10663
+ let saved = 0;
10664
+ let truncatedCount = 0;
10665
+ let clearedCount = 0;
10666
+ const truncatable = candidates.filter(
10667
+ (c) => !c.content.includes(TRUNCATION_MARKER2) && c.content.length > KEEP_PREFIX_CHARS2 + KEEP_SUFFIX_CHARS2
10668
+ ).sort((a, b) => b.tokens - a.tokens);
10669
+ for (const c of truncatable) {
10670
+ if (estimatedTokens - saved <= budget) break;
10671
+ const prefix = c.content.slice(0, KEEP_PREFIX_CHARS2);
10672
+ const suffix = c.content.slice(-KEEP_SUFFIX_CHARS2);
10673
+ const truncated = prefix + `
10674
+
10675
+ ...${TRUNCATION_MARKER2} \u2014 original ~${c.tokens} tokens]...
10676
+
10677
+ ` + suffix;
10678
+ if (truncated.length >= c.content.length) continue;
10679
+ c.part.state.output = truncated;
10680
+ saved += c.tokens - countTokens2(truncated);
10681
+ truncatedCount++;
10682
+ }
10683
+ if (estimatedTokens - saved > budget) {
10684
+ const clearable = candidates.filter((c) => {
10685
+ const out = extractCompletedToolOutput(c.part);
10686
+ return out !== void 0 && out !== COMPACTED_TOOL_OUTPUT_PLACEHOLDER && countTokens2(out) > MIN_CLEAR_TOKENS;
10687
+ }).sort((a, b) => a.index - b.index);
10688
+ for (const c of clearable) {
10689
+ if (estimatedTokens - saved <= budget) break;
10690
+ const current = extractCompletedToolOutput(c.part);
10691
+ if (current === void 0 || current === COMPACTED_TOOL_OUTPUT_PLACEHOLDER) continue;
10692
+ c.part.state.output = COMPACTED_TOOL_OUTPUT_PLACEHOLDER;
10693
+ saved += countTokens2(current) - countTokens2(COMPACTED_TOOL_OUTPUT_PLACEHOLDER);
10694
+ clearedCount++;
10695
+ }
10696
+ }
10697
+ const finalEstimate = Math.max(0, estimatedTokens - saved);
10698
+ if (truncatedCount > 0 || clearedCount > 0) {
10699
+ logger.warn("Context budget guard: pruned tool outputs to fit the request", {
10700
+ session: state.sessionId,
10701
+ estimatedTokens: Math.round(estimatedTokens),
10702
+ budget,
10703
+ window,
10704
+ reserve,
10705
+ truncatedCount,
10706
+ clearedCount,
10707
+ estimatedSavedTokens: Math.round(saved),
10708
+ finalEstimate: Math.round(finalEstimate)
10409
10709
  });
10410
10710
  }
10711
+ if (finalEstimate > budget) {
10712
+ logger.warn(
10713
+ "Context budget guard: still over budget after pruning all compressible tool outputs; the request may be rejected by the model",
10714
+ {
10715
+ session: state.sessionId,
10716
+ finalEstimate: Math.round(finalEstimate),
10717
+ budget,
10718
+ window
10719
+ }
10720
+ );
10721
+ }
10722
+ return {
10723
+ applied: truncatedCount > 0 || clearedCount > 0,
10724
+ window,
10725
+ reserve,
10726
+ budget,
10727
+ estimatedTokens,
10728
+ finalEstimate,
10729
+ truncatedCount,
10730
+ clearedCount
10731
+ };
10411
10732
  }
10412
10733
 
10413
10734
  // lib/commands/context.ts
@@ -11366,11 +11687,12 @@ function runBatchCleanup(state, config, logger, messages) {
11366
11687
  mergedCount: 0,
11367
11688
  savedTokens: 0
11368
11689
  };
11369
- if (!state.modelContextLimit || state.modelContextLimit <= 0) {
11690
+ const effective = resolveEffectiveContextLimit(state, config);
11691
+ if (!effective) {
11370
11692
  return noop;
11371
11693
  }
11372
11694
  const currentTokens = getCurrentTokenUsage(state, messages);
11373
- if (currentTokens < state.modelContextLimit) {
11695
+ if (currentTokens < effective.limit) {
11374
11696
  return noop;
11375
11697
  }
11376
11698
  const maxMergedLength = config.gc.maxOldGenSummaryLength;
@@ -11387,7 +11709,8 @@ function runBatchCleanup(state, config, logger, messages) {
11387
11709
  mergedCount: result.mergedCount,
11388
11710
  savedTokens: result.savedTokens,
11389
11711
  currentTokens,
11390
- contextLimit: state.modelContextLimit
11712
+ contextLimit: effective.limit,
11713
+ contextLimitSource: effective.source
11391
11714
  });
11392
11715
  return {
11393
11716
  tier: 3,
@@ -11421,11 +11744,6 @@ function createSystemPromptHandler(registry4, logger, config, prompts) {
11421
11744
  input.model?.limit?.context
11422
11745
  );
11423
11746
  const state = input.sessionID ? registry4.get(input.sessionID) : void 0;
11424
- if (state && input.model?.limit?.context) {
11425
- state.modelContextLimit = input.model.limit.context;
11426
- state.modelProviderID = input.model?.providerID;
11427
- state.modelID = input.model?.id;
11428
- }
11429
11747
  if (!state || state.isSubAgent && !config.allowSubAgents) {
11430
11748
  return;
11431
11749
  }
@@ -11434,6 +11752,23 @@ function createSystemPromptHandler(registry4, logger, config, prompts) {
11434
11752
  logger.info("Skipping DCP system prompt injection for internal agent");
11435
11753
  return;
11436
11754
  }
11755
+ if (input.model?.limit?.context) {
11756
+ const limit = input.model.limit.context;
11757
+ const providerID = input.model?.providerID;
11758
+ const modelID = input.model?.id;
11759
+ const changed = state.modelContextLimit !== limit || providerID !== void 0 && state.modelProviderID !== providerID || modelID !== void 0 && state.modelID !== modelID;
11760
+ state.modelContextLimit = limit;
11761
+ if (providerID !== void 0) {
11762
+ state.modelProviderID = providerID;
11763
+ }
11764
+ if (modelID !== void 0) {
11765
+ state.modelID = modelID;
11766
+ }
11767
+ if (changed) {
11768
+ saveSessionState(state, logger).catch(() => {
11769
+ });
11770
+ }
11771
+ }
11437
11772
  const effectivePermission = compressPermission(state, config);
11438
11773
  if (effectivePermission === "deny") {
11439
11774
  return;
@@ -11478,10 +11813,17 @@ function createChatMessageTransformHandler(client, registry4, logger, config, pr
11478
11813
  config
11479
11814
  );
11480
11815
  const requestModel = lastUserMessage.info.model;
11481
- const requestModelLimit = registry4.resolveModelLimit(
11816
+ let requestModelLimit = registry4.resolveModelLimit(
11482
11817
  requestModel?.providerID,
11483
11818
  requestModel?.modelID
11484
11819
  );
11820
+ if (requestModelLimit === void 0 && requestModel?.providerID && requestModel?.modelID) {
11821
+ requestModelLimit = await registry4.hydrateAndResolve(
11822
+ client,
11823
+ requestModel.providerID,
11824
+ requestModel.modelID
11825
+ );
11826
+ }
11485
11827
  const prevModelID = state.modelID;
11486
11828
  if (requestModelLimit !== void 0) {
11487
11829
  state.modelContextLimit = requestModelLimit;
@@ -11511,6 +11853,16 @@ function createChatMessageTransformHandler(client, registry4, logger, config, pr
11511
11853
  });
11512
11854
  }
11513
11855
  await updatePerTurnState(state, logger, messages);
11856
+ if (state.modelContextLimit === void 0 && !state.noContextLimitWarned && requestModel?.providerID && requestModel?.modelID && registry4.resolveModelLimit(requestModel.providerID, requestModel.modelID) === void 0) {
11857
+ state.noContextLimitWarned = true;
11858
+ logger.warn(
11859
+ 'Model reports no context window and the catalog has no entry for it; all percentage thresholds (min/max/emergency, GC) and the context-budget guard are disabled. Set the model limit in opencode.json (e.g. "limit": {"context": 262144, "output": 16384}) to enable them (also fixes the 32000 max_tokens fallback); an absolute compress.maxContextLimit in acp.jsonc only enables proactive nudges, not the guard.',
11860
+ {
11861
+ session: state.sessionId,
11862
+ model: `${requestModel.providerID}/${requestModel.modelID}`
11863
+ }
11864
+ );
11865
+ }
11514
11866
  }
11515
11867
  syncCompressPermissionState(state, config, hostPermissions, output.messages);
11516
11868
  if (state.isSubAgent && !config.allowSubAgents) {
@@ -11536,10 +11888,11 @@ function createChatMessageTransformHandler(client, registry4, logger, config, pr
11536
11888
  }
11537
11889
  }
11538
11890
  ensureBuiltinFiltersRegistered();
11891
+ const effectiveLimit = resolveEffectiveContextLimit(state, config);
11539
11892
  applyMessageFilters(output.messages, config.messageFilters, logger, {
11540
11893
  sessionId: state.sessionId ?? "",
11541
11894
  isSubAgent: state.isSubAgent,
11542
- modelContextLimit: state.modelContextLimit
11895
+ modelContextLimit: effectiveLimit?.limit
11543
11896
  });
11544
11897
  cacheSystemPromptTokens(state, output.messages);
11545
11898
  assignMessageRefs(state, output.messages);
@@ -11559,6 +11912,7 @@ function createChatMessageTransformHandler(client, registry4, logger, config, pr
11559
11912
  const prePruneTokens = getCurrentTokenUsage(state, output.messages);
11560
11913
  prune(state, logger, config, output.messages);
11561
11914
  truncateLargeToolOutputs(state, config, logger, output.messages);
11915
+ enforceContextBudget(state, config, logger, output.messages);
11562
11916
  hideConsumedCompressCalls(state, output.messages);
11563
11917
  assignMessageRefs(state, output.messages);
11564
11918
  const compressionPriorities = buildPriorityMap(config, state, output.messages);
@@ -11590,14 +11944,31 @@ ${text}`);
11590
11944
  stripStaleMetadata(output.messages);
11591
11945
  dropEmptyMessages(output.messages);
11592
11946
  const postTokens = getCurrentTokenUsage(state, output.messages);
11947
+ if (postTokens !== void 0 && effectiveLimit) {
11948
+ const budget = effectiveLimit.limit - (state.systemPromptTokens ?? 0) - OUTPUT_RESERVE_TOKENS;
11949
+ if (postTokens > budget) {
11950
+ logger.error(
11951
+ "ACP hard guard: context exceeds model budget after in-flight reduction",
11952
+ {
11953
+ session: state.sessionId,
11954
+ postTokens,
11955
+ budget,
11956
+ contextLimit: effectiveLimit.limit,
11957
+ contextLimitSource: effectiveLimit.source,
11958
+ hint: "request will likely be rejected; run /compact or start a new session"
11959
+ }
11960
+ );
11961
+ }
11962
+ }
11593
11963
  logger.info("Chat transform complete", {
11594
11964
  session: state.sessionId,
11595
11965
  model: state.modelID,
11596
11966
  messages: output.messages.length,
11597
11967
  prePruneTokens,
11598
11968
  postTokens,
11599
- contextLimit: state.modelContextLimit,
11600
- usagePct: postTokens !== void 0 && state.modelContextLimit ? `${(postTokens / state.modelContextLimit * 100).toFixed(1)}%` : void 0,
11969
+ contextLimit: effectiveLimit?.limit,
11970
+ contextLimitSource: effectiveLimit?.source,
11971
+ usagePct: postTokens !== void 0 && effectiveLimit ? `${(postTokens / effectiveLimit.limit * 100).toFixed(1)}%` : void 0,
11601
11972
  nudged: state.nudges.shouldInjectThisTurn
11602
11973
  });
11603
11974
  if (state.sessionId) {
@@ -11629,12 +12000,7 @@ function createCommandExecuteHandler(client, registry4, logger, config, workingD
11629
12000
  path: { id: input.sessionID }
11630
12001
  });
11631
12002
  const messages = filterMessages(messagesResponse.data || messagesResponse);
11632
- const state = await registry4.getOrCreate(
11633
- client,
11634
- input.sessionID,
11635
- messages,
11636
- config
11637
- );
12003
+ const state = await registry4.getOrCreate(client, input.sessionID, messages, config);
11638
12004
  syncCompressPermissionState(state, config, hostPermissions, messages);
11639
12005
  const commandCtx = {
11640
12006
  client,
@@ -11648,7 +12014,7 @@ function createCommandExecuteHandler(client, registry4, logger, config, workingD
11648
12014
  const sub = input.arguments?.trim().toLowerCase();
11649
12015
  if (sub === "stats" || sub === "status" || sub === "") {
11650
12016
  await handleStatsCommand(commandCtx);
11651
- throw new Error("__DCP_CONTEXT_HANDLED__");
12017
+ return;
11652
12018
  }
11653
12019
  if (sub === "export" || sub.startsWith("export ")) {
11654
12020
  const exportArgs = input.arguments?.trim().slice("export".length).trim() || "";
@@ -11656,17 +12022,10 @@ function createCommandExecuteHandler(client, registry4, logger, config, workingD
11656
12022
  throw new Error("__DCP_CONTEXT_HANDLED__");
11657
12023
  }
11658
12024
  if (sub === "help") {
11659
- await sendIgnoredMessage(
11660
- client,
11661
- input.sessionID,
11662
- buildHelpText(),
11663
- {},
11664
- logger
11665
- );
12025
+ await sendIgnoredMessage(client, input.sessionID, buildHelpText(), {}, logger);
11666
12026
  throw new Error("__DCP_CONTEXT_HANDLED__");
11667
12027
  }
11668
12028
  await handleContextCommand(commandCtx);
11669
- throw new Error("__DCP_CONTEXT_HANDLED__");
11670
12029
  }
11671
12030
  };
11672
12031
  }
@@ -11737,9 +12096,7 @@ function createEventHandler(registry4, logger) {
11737
12096
  return;
11738
12097
  }
11739
12098
  if (typeof part.callID === "string" && typeof part.messageID === "string") {
11740
- timing.startsByCallId.delete(
11741
- buildCompressionTimingKey(part.messageID, part.callID)
11742
- );
12099
+ timing.startsByCallId.delete(buildCompressionTimingKey(part.messageID, part.callID));
11743
12100
  }
11744
12101
  };
11745
12102
  }
@@ -12016,7 +12373,7 @@ var server = (async (ctx) => {
12016
12373
  }
12017
12374
  const logger = new Logger(config.debug, config.debug ? "debug" : config.logLevel);
12018
12375
  logger.info("ACP plugin initialized", {
12019
- version: true ? "1.16.0-pr.360.124" : "dev",
12376
+ version: true ? "1.16.0-pr.360.128" : "dev",
12020
12377
  workspace: ctx.directory,
12021
12378
  logLevel: logger.level,
12022
12379
  debug: config.debug,