opencode-acp 1.14.19-pr.314.26 → 1.14.19-pr.315.29

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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AA2BjD,QAAA,MAAM,MAAM,EAAE,MAwHK,CAAA;AAEnB,eAAe,MAAM,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AA2BjD,QAAA,MAAM,MAAM,EAAE,MA6IK,CAAA;AAEnB,eAAe,MAAM,CAAA"}
package/dist/index.js CHANGED
@@ -2418,7 +2418,9 @@ async function saveSessionState(sessionState, logger, sessionName) {
2418
2418
  nextRef: sessionState.messageIds.nextRef
2419
2419
  },
2420
2420
  lastCompaction: sessionState.lastCompaction,
2421
- modelContextLimit: sessionState.modelContextLimit
2421
+ modelContextLimit: sessionState.modelContextLimit,
2422
+ modelProviderID: sessionState.modelProviderID,
2423
+ modelID: sessionState.modelID
2422
2424
  };
2423
2425
  await writePersistedSessionState(sessionState.sessionId, state, logger);
2424
2426
  }
@@ -2949,6 +2951,51 @@ function applyPendingCompressionDurations(state) {
2949
2951
  return updates;
2950
2952
  }
2951
2953
 
2954
+ // lib/state/model-limits.ts
2955
+ function createModelLimitCatalog() {
2956
+ const modelLimits = /* @__PURE__ */ new Map();
2957
+ return {
2958
+ record(providerId, modelId, limit) {
2959
+ if (!providerId || !modelId || typeof limit !== "number" || limit <= 0) return;
2960
+ modelLimits.set(`${providerId}/${modelId}`, limit);
2961
+ },
2962
+ resolve(providerId, modelId) {
2963
+ if (!providerId || !modelId) return void 0;
2964
+ return modelLimits.get(`${providerId}/${modelId}`);
2965
+ },
2966
+ /**
2967
+ * Best-effort one-time seed from the host's provider catalog
2968
+ * (`client.config.providers()` → GET /config/providers). Never throws;
2969
+ * returns the number of model-limit entries recorded.
2970
+ */
2971
+ async hydrateFromClient(client) {
2972
+ try {
2973
+ const config = client;
2974
+ const result = await config.config?.providers?.();
2975
+ const payload = result;
2976
+ const providers = payload?.data?.providers;
2977
+ if (!Array.isArray(providers)) return 0;
2978
+ let recorded = 0;
2979
+ for (const provider of providers) {
2980
+ const { id, models } = provider ?? {};
2981
+ if (typeof id !== "string" || !models) continue;
2982
+ for (const [modelId, model] of Object.entries(models)) {
2983
+ const limit = model?.limit;
2984
+ const context = limit?.context;
2985
+ if (typeof context === "number" && context > 0) {
2986
+ modelLimits.set(`${id}/${modelId}`, context);
2987
+ recorded++;
2988
+ }
2989
+ }
2990
+ }
2991
+ return recorded;
2992
+ } catch {
2993
+ return 0;
2994
+ }
2995
+ }
2996
+ };
2997
+ }
2998
+
2952
2999
  // lib/compress/search.ts
2953
3000
  import { tool } from "@opencode-ai/plugin";
2954
3001
  async function fetchSessionMessages(client, sessionId) {
@@ -4140,56 +4187,25 @@ var SessionStateRegistry = class {
4140
4187
  startsByCallId: /* @__PURE__ */ new Map(),
4141
4188
  pendingByCallId: /* @__PURE__ */ new Map()
4142
4189
  };
4143
- // [FIX #312] Catalog of per-model context limits, keyed `${providerID}/${modelID}`.
4144
- // Within one LLM request the host fires experimental.chat.messages.transform
4145
- // BEFORE experimental.chat.system.transform (sst/opencode: session/prompt.ts
4146
- // triggers messages.transform, then llm/request.ts triggers system.transform
4147
- // during handle.process). state.modelContextLimit is written only by the
4148
- // system hook, so on the first request after a model switch every percentage
4149
- // threshold (emergencyThresholdPercent, min/maxContextLimit "%", adaptive
4150
- // nudge growth, GC tiers) is still computed against the PREVIOUS model's
4151
- // limit. This catalog lets the messages hook reconcile against the model
4152
- // named on the request's user message instead of waiting one turn.
4153
- // Entries are recorded live by the system hook every request and seeded once
4154
- // at plugin init from the host's /config/providers catalog.
4155
- modelLimits = /* @__PURE__ */ new Map();
4190
+ // [FIX #312] Model-limit catalog (full rationale in ./model-limits.ts):
4191
+ // lets the messages hook reconcile state.modelContextLimit against the
4192
+ // model named on the request's user message instead of waiting one turn
4193
+ // for the system hook. Shared implementation — the test registry stub
4194
+ // composes the same factory.
4195
+ catalog = createModelLimitCatalog();
4156
4196
  recordModelLimit(providerId, modelId, limit) {
4157
- if (!providerId || !modelId || typeof limit !== "number" || limit <= 0) return;
4158
- this.modelLimits.set(`${providerId}/${modelId}`, limit);
4197
+ this.catalog.record(providerId, modelId, limit);
4159
4198
  }
4160
4199
  resolveModelLimit(providerId, modelId) {
4161
- if (!providerId || !modelId) return void 0;
4162
- return this.modelLimits.get(`${providerId}/${modelId}`);
4200
+ return this.catalog.resolve(providerId, modelId);
4163
4201
  }
4164
4202
  /**
4165
4203
  * Best-effort one-time seed from the host's provider catalog
4166
4204
  * (`client.config.providers()` → GET /config/providers). Never throws;
4167
4205
  * returns the number of model-limit entries recorded.
4168
4206
  */
4169
- async hydrateModelLimitsFromClient(client) {
4170
- try {
4171
- const config = client;
4172
- const result = await config.config?.providers?.();
4173
- const payload = result;
4174
- const providers = payload?.data?.providers;
4175
- if (!Array.isArray(providers)) return 0;
4176
- let recorded = 0;
4177
- for (const provider of providers) {
4178
- const { id, models } = provider ?? {};
4179
- if (typeof id !== "string" || !models) continue;
4180
- for (const [modelId, model] of Object.entries(models)) {
4181
- const limit = model?.limit;
4182
- const context = limit?.context;
4183
- if (typeof context === "number" && context > 0) {
4184
- this.modelLimits.set(`${id}/${modelId}`, context);
4185
- recorded++;
4186
- }
4187
- }
4188
- }
4189
- return recorded;
4190
- } catch {
4191
- return 0;
4192
- }
4207
+ hydrateModelLimitsFromClient(client) {
4208
+ return this.catalog.hydrateFromClient(client);
4193
4209
  }
4194
4210
  get(sessionId) {
4195
4211
  return this.states.get(sessionId);
@@ -4279,6 +4295,8 @@ function createSessionState() {
4279
4295
  lastCompaction: 0,
4280
4296
  currentTurn: 0,
4281
4297
  modelContextLimit: void 0,
4298
+ modelProviderID: void 0,
4299
+ modelID: void 0,
4282
4300
  systemPromptTokens: void 0,
4283
4301
  qualityGateRetryPending: false
4284
4302
  };
@@ -4318,6 +4336,8 @@ function resetSessionState(state) {
4318
4336
  state.lastCompaction = 0;
4319
4337
  state.currentTurn = 0;
4320
4338
  state.modelContextLimit = void 0;
4339
+ state.modelProviderID = void 0;
4340
+ state.modelID = void 0;
4321
4341
  state.systemPromptTokens = void 0;
4322
4342
  state.qualityGateRetryPending = false;
4323
4343
  }
@@ -4392,6 +4412,8 @@ async function ensureSessionInitialized(client, state, sessionId, logger, messag
4392
4412
  }
4393
4413
  if (typeof persisted.modelContextLimit === "number" && persisted.modelContextLimit > 0) {
4394
4414
  state.modelContextLimit = persisted.modelContextLimit;
4415
+ state.modelProviderID = persisted.modelProviderID;
4416
+ state.modelID = persisted.modelID;
4395
4417
  }
4396
4418
  const applied = applyPendingCompressionDurations(state);
4397
4419
  if (applied > 0) {
@@ -4739,8 +4761,8 @@ ${progressBar}`;
4739
4761
  let toastMessage = message;
4740
4762
  toastMessage = config.pruneNotification === "minimal" ? toastMessage : truncateToastBody(toastMessage);
4741
4763
  if (config.debug) {
4742
- const chatMessage = config.pruneNotification === "minimal" ? message : truncateToastBody(message);
4743
- await sendIgnoredMessage(client, sessionId, chatMessage, params, logger);
4764
+ logger.debug(`[ACP Debug] Compress notification:
4765
+ ${message}`);
4744
4766
  }
4745
4767
  await client.tui.showToast({
4746
4768
  body: {
@@ -5723,11 +5745,6 @@ exact values, errors). Then add "acknowledgeRisk": true to the compress tool cal
5723
5745
  Without acknowledgeRisk: true, the compression will be rejected again.`;
5724
5746
  return new Error(message);
5725
5747
  }
5726
- function buildPreemptiveAcknowledgeError() {
5727
- return new Error(
5728
- 'Parameter "acknowledgeRisk": true was provided, but no quality gate rejection is pending. This parameter is only valid immediately after a compression was rejected by the quality gate. Remove it and try again.'
5729
- );
5730
- }
5731
5748
 
5732
5749
  // lib/compress/pipeline.ts
5733
5750
  function snapshotCompressionState(state) {
@@ -6201,13 +6218,13 @@ function createCompressRangeTool(factoryCtx) {
6201
6218
  }
6202
6219
  const acknowledgeRisk = args.acknowledgeRisk === true;
6203
6220
  const qualityGateRetryPendingBefore = ctx.state.qualityGateRetryPending;
6204
- if (acknowledgeRisk && !ctx.state.qualityGateRetryPending) {
6205
- throw buildPreemptiveAcknowledgeError();
6221
+ const bypassQuality = acknowledgeRisk && ctx.state.qualityGateRetryPending;
6222
+ const ignoredAcknowledgeRisk = acknowledgeRisk && !ctx.state.qualityGateRetryPending;
6223
+ if (ignoredAcknowledgeRisk) {
6224
+ ctx.logger.warn("compress: acknowledgeRisk ignored \u2014 no quality gate rejection pending");
6206
6225
  }
6207
- if (acknowledgeRisk) {
6208
- ctx.state.qualityGateRetryPending = false;
6209
- } else {
6210
- ctx.state.qualityGateRetryPending = false;
6226
+ ctx.state.qualityGateRetryPending = false;
6227
+ if (!bypassQuality) {
6211
6228
  for (const plan of preparedPlans) {
6212
6229
  const result = evaluatePreCommitQuality(
6213
6230
  rawMessages,
@@ -6283,7 +6300,10 @@ function createCompressRangeTool(factoryCtx) {
6283
6300
  const skippedNote = phantomSkipNotice !== null ? `
6284
6301
  \u26A0\uFE0F ${phantomSkipNotice}
6285
6302
  ` : "";
6286
- return `Compressed ${totalCompressedMessages} messages into ${COMPRESSED_BLOCK_HEADER}.${skippedNote}
6303
+ const ackNote = ignoredAcknowledgeRisk ? `
6304
+ \u26A0\uFE0F acknowledgeRisk was ignored: no quality gate rejection was pending, so quality checks ran normally. Only pass it when retrying immediately after a quality gate rejection.
6305
+ ` : "";
6306
+ return `Compressed ${totalCompressedMessages} messages into ${COMPRESSED_BLOCK_HEADER}.${skippedNote}${ackNote}
6287
6307
  IMPORTANT: This was an automatic context compression. You MUST continue your previous task exactly where you left off. Do NOT ask the user what to do next.
6288
6308
  \u{1F4A1} Tip: Use search_context('keyword') to find compressed content when you need it later.`;
6289
6309
  }
@@ -7617,16 +7637,6 @@ var injectCompressNudges = (state, config, logger, messages, prompts, compressio
7617
7637
  { logger }
7618
7638
  );
7619
7639
  const hasRecommendations = recommendedRanges.length > 0;
7620
- if (config.debug && contextRanges.compressible.length > 0) {
7621
- const compressible = contextRanges.compressible;
7622
- const fmt = (n) => n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n);
7623
- const lines = [
7624
- `[ACP Debug] Recommendation filter:`,
7625
- ` Input: ${compressible.length} range(s), ${fmt(compressible.reduce((s, r) => s + r.tokens, 0))} tokens`,
7626
- ` Output: ${recommendedRanges.length} range(s) (last segment marked dangerous)`
7627
- ];
7628
- logger.debug(lines.join("\n"));
7629
- }
7630
7640
  const allProtected = contextRanges.compressible.length === 0 && contextRanges.protected.length > 0;
7631
7641
  const allInProtectedZone = protectedRefs.size > 0 && unprotectedCompressible.length === 0;
7632
7642
  const nothingToCompress = allProtected || allInProtectedZone;
@@ -7722,6 +7732,16 @@ ${rules}`;
7722
7732
  }
7723
7733
  }
7724
7734
  state.nudges.shouldInjectThisTurn = shouldInject;
7735
+ if (shouldInject && config.debug && contextRanges.compressible.length > 0) {
7736
+ const compressible = contextRanges.compressible;
7737
+ const fmt = (n) => n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n);
7738
+ const lines = [
7739
+ `[ACP Debug] Recommendation filter:`,
7740
+ ` Input: ${compressible.length} range(s), ${fmt(compressible.reduce((s, r) => s + r.tokens, 0))} tokens`,
7741
+ ` Output: ${recommendedRanges.length} range(s) (last segment marked dangerous)`
7742
+ ];
7743
+ logger.debug(lines.join("\n"));
7744
+ }
7725
7745
  let tipsText = null;
7726
7746
  if (shouldInject) {
7727
7747
  if (suffixMessage && composition.total > 0) {
@@ -8176,10 +8196,6 @@ function resolveSingleBlockTarget(messagesState, blockIdArg) {
8176
8196
  error: `Error: Block ${target.displayId} is nested inside active block ${activeAncestorBlockId}. Decompress block ${activeAncestorBlockId} first.`
8177
8197
  };
8178
8198
  }
8179
- return {
8180
- ok: false,
8181
- error: `Error: Block ${target.displayId} is not active. It may have already been decompressed.`
8182
- };
8183
8199
  }
8184
8200
  return { ok: true, targets: [target] };
8185
8201
  }
@@ -8332,7 +8348,7 @@ function createDecompressTool(factoryCtx) {
8332
8348
  const blockMessages = rawMessages.filter((m) => msgIdSet.has(extractMessageId(m)));
8333
8349
  const lines2 = blockMessages.map(extractMessageText2);
8334
8350
  const { writeFile: writeFile3 } = await import("fs/promises");
8335
- const fileContent = lines2.length > 0 ? lines2.join("\n\n---\n\n") : activeBlocks[0]?.summary ?? "(no content available)";
8351
+ const fileContent = lines2.length > 0 ? lines2.join("\n\n---\n\n") : targets[0]?.blocks[0]?.summary ?? "(no content available)";
8336
8352
  await writeFile3(targetPath, fileContent, "utf-8");
8337
8353
  const displayIds2 = targets.map((t) => `b${t.displayId}`).join(", ");
8338
8354
  return `Block(s) ${displayIds2} content (${blockMessages.length} messages, ${fileContent.length} chars) written to ${targetPath}. Block(s) stay compressed \u2014 context unchanged. Use read tool to access specific parts.`;
@@ -8789,16 +8805,23 @@ function renderCompressedDrilldown(blocks, sort, limit, blocksById) {
8789
8805
  lines.push(`Sorted by ${sort === "time" ? "time" : sort === "age" ? "age" : "size"}`);
8790
8806
  lines.push("");
8791
8807
  const shown = sorted.slice(0, limit);
8808
+ const activeCount = sorted.filter((b) => b.active).length;
8809
+ const inactiveCount = sorted.length - activeCount;
8810
+ if (inactiveCount > 0) {
8811
+ lines.push(`${activeCount} active, ${inactiveCount} inactive/consumed`);
8812
+ lines.push("");
8813
+ }
8792
8814
  for (const b of shown) {
8793
8815
  const survived = b.survivedCount ?? 0;
8794
8816
  const gen = b.generation ?? "young";
8795
8817
  const effCount = b.effectiveMessageIds?.length ?? 0;
8796
8818
  const consumed = b.includedBlockIds && b.includedBlockIds.length > 0 ? ` nested=[${b.includedBlockIds.map((n) => `b${n}`).join(",")}]` : "";
8819
+ const status = b.active ? "" : " [inactive]";
8797
8820
  const topic = b.topic || "(no topic)";
8798
8821
  const tier = tierLabel(b);
8799
8822
  const effTokens = getEffectiveCompressedTokens(b, blocksById);
8800
8823
  lines.push(
8801
- ` b${b.blockId} (${tier}) ${formatTokens(effTokens)}\u2192${formatTokens(b.summaryTokens)} ${formatAge(b.createdAt)} ${formatIdRange(b)} age=${survived} ${gen} eff=${effCount}${consumed}`
8824
+ ` b${b.blockId} (${tier}) ${formatTokens(effTokens)}\u2192${formatTokens(b.summaryTokens)} ${formatAge(b.createdAt)} ${formatIdRange(b)} age=${survived} ${gen} eff=${effCount}${consumed}${status}`
8802
8825
  );
8803
8826
  lines.push(` "${topic}"`);
8804
8827
  }
@@ -8819,8 +8842,10 @@ function buildStatusReport(renderCtx, rawMessages, options) {
8819
8842
  const sort = options?.sort ?? "size";
8820
8843
  const limit = options?.limit ?? 30;
8821
8844
  const msgState = renderCtx.state.prune.messages;
8822
- const activeIds = Array.from(msgState.activeBlockIds).sort((a, b) => a - b);
8823
- const allBlocks = activeIds.map((id) => msgState.blocksById.get(id)).filter((b) => b !== void 0 && b.active);
8845
+ const allBlocks = Array.from(msgState.blocksById.values()).sort(
8846
+ (a, b) => a.blockId - b.blockId
8847
+ );
8848
+ const activeBlocks = allBlocks.filter((b) => b.active);
8824
8849
  const lines = [];
8825
8850
  if (scope === "compressed") {
8826
8851
  lines.push(...renderCompressedDrilldown(allBlocks, sort, limit, msgState.blocksById));
@@ -8842,7 +8867,7 @@ function buildStatusReport(renderCtx, rawMessages, options) {
8842
8867
  visibleMsgs,
8843
8868
  summaryTokens,
8844
8869
  systemTokens,
8845
- allBlocks,
8870
+ activeBlocks,
8846
8871
  false,
8847
8872
  rawMessages,
8848
8873
  renderCtx
@@ -9000,7 +9025,7 @@ import { writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
9000
9025
  import { join as join3 } from "path";
9001
9026
  import { existsSync as existsSync3 } from "fs";
9002
9027
  import { homedir as homedir3 } from "os";
9003
- var LOG_VERSION = true ? "1.14.19-pr.314.26" : "dev";
9028
+ var LOG_VERSION = true ? "1.14.19-pr.315.29" : "dev";
9004
9029
  var Logger = class {
9005
9030
  logDir;
9006
9031
  enabled;
@@ -10575,6 +10600,8 @@ function createSystemPromptHandler(registry4, logger, config, prompts) {
10575
10600
  const state = input.sessionID ? registry4.get(input.sessionID) : void 0;
10576
10601
  if (state && input.model?.limit?.context) {
10577
10602
  state.modelContextLimit = input.model.limit.context;
10603
+ state.modelProviderID = input.model?.providerID;
10604
+ state.modelID = input.model?.id;
10578
10605
  }
10579
10606
  if (!state || state.isSubAgent && !config.allowSubAgents) {
10580
10607
  return;
@@ -10634,6 +10661,22 @@ function createChatMessageTransformHandler(client, registry4, logger, config, pr
10634
10661
  );
10635
10662
  if (requestModelLimit !== void 0) {
10636
10663
  state.modelContextLimit = requestModelLimit;
10664
+ state.modelProviderID = requestModel?.providerID;
10665
+ state.modelID = requestModel?.modelID;
10666
+ } else if (
10667
+ // [FIX #312 fallback] Catalog miss: we cannot CORRECT the
10668
+ // limit, but we can tell when it belongs to a DIFFERENT model.
10669
+ // Invalidate instead of letting every percentage threshold
10670
+ // below run against the wrong window (#312's false positive).
10671
+ // States persisted before this identity pair existed carry no
10672
+ // identity and are treated as stale for the same reason.
10673
+ // Consumers already tolerate undefined — fresh sessions run
10674
+ // with it until the first system.transform sets the pair.
10675
+ requestModel?.providerID && requestModel?.modelID && state.modelContextLimit !== void 0 && (state.modelProviderID !== requestModel.providerID || state.modelID !== requestModel.modelID)
10676
+ ) {
10677
+ state.modelContextLimit = void 0;
10678
+ state.modelProviderID = requestModel.providerID;
10679
+ state.modelID = requestModel.modelID;
10637
10680
  }
10638
10681
  await updatePerTurnState(state, logger, messages);
10639
10682
  }
@@ -10680,23 +10723,6 @@ function createChatMessageTransformHandler(client, registry4, logger, config, pr
10680
10723
  config.debug ? (text) => {
10681
10724
  logger.debug(`[ACP Debug] Nudge injected:
10682
10725
  ${text}`);
10683
- if (state.sessionId && lastUserMessage) {
10684
- const userInfo = lastUserMessage.info;
10685
- sendIgnoredMessage(
10686
- client,
10687
- state.sessionId,
10688
- `[ACP Debug Nudge]
10689
- ${text}`,
10690
- {
10691
- providerId: userInfo.model?.providerID,
10692
- modelId: userInfo.model?.modelID,
10693
- agent: userInfo.agent,
10694
- variant: userInfo.variant
10695
- },
10696
- logger
10697
- ).catch(() => {
10698
- });
10699
- }
10700
10726
  client.tui.showToast({
10701
10727
  body: {
10702
10728
  title: "ACP: Nudge Injected",
@@ -11020,8 +11046,25 @@ var server = (async (ctx) => {
11020
11046
  if (isSecureMode()) {
11021
11047
  configureClientAuth(ctx.client);
11022
11048
  }
11023
- registry4.hydrateModelLimitsFromClient(ctx.client).catch(() => {
11024
- });
11049
+ registry4.hydrateModelLimitsFromClient(ctx.client).then(
11050
+ (recorded) => {
11051
+ if (recorded > 0) {
11052
+ logger.info("Model limit catalog seeded from provider config", {
11053
+ models: recorded
11054
+ });
11055
+ } else {
11056
+ logger.warn(
11057
+ "Model limit catalog seeding recorded no entries \u2014 falling back to per-request refresh (system.transform)"
11058
+ );
11059
+ }
11060
+ },
11061
+ (error) => {
11062
+ logger.warn(
11063
+ "Model limit catalog seeding failed \u2014 falling back to per-request refresh (system.transform)",
11064
+ { error: error instanceof Error ? error.message : String(error) }
11065
+ );
11066
+ }
11067
+ );
11025
11068
  logger.info("DCP initialized");
11026
11069
  startAutoUpdate(ctx, config.autoUpdate);
11027
11070
  const compressToolContext = {