opencode-acp 1.14.19 → 1.14.20-pr.286.33

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
@@ -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
  }
@@ -2898,14 +2900,14 @@ function applyCompressionState(state, input, selection, anchorMessageId, blockId
2898
2900
  block.directMessageIds = [...newlyCompressedMessageIds];
2899
2901
  block.directToolIds = [...newlyCompressedToolIds];
2900
2902
  block.compressedTokens = compressedTokens;
2901
- let effectiveTokens = compressedTokens;
2903
+ let effectiveTokens2 = compressedTokens;
2902
2904
  for (const consumedBlockId of consumed) {
2903
2905
  const cb = messagesState.blocksById.get(consumedBlockId);
2904
2906
  if (cb && (cb.tier ?? 1) === targetTierForConsumption) {
2905
- effectiveTokens += cb.effectiveCompressedTokens ?? cb.compressedTokens;
2907
+ effectiveTokens2 += cb.effectiveCompressedTokens ?? cb.compressedTokens;
2906
2908
  }
2907
2909
  }
2908
- block.effectiveCompressedTokens = effectiveTokens;
2910
+ block.effectiveCompressedTokens = effectiveTokens2;
2909
2911
  state.stats.pruneTokenCounter += compressedTokens;
2910
2912
  state.stats.totalPruneTokens += state.stats.pruneTokenCounter;
2911
2913
  state.stats.pruneTokenCounter = 0;
@@ -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,6 +4187,26 @@ var SessionStateRegistry = class {
4140
4187
  startsByCallId: /* @__PURE__ */ new Map(),
4141
4188
  pendingByCallId: /* @__PURE__ */ new Map()
4142
4189
  };
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();
4196
+ recordModelLimit(providerId, modelId, limit) {
4197
+ this.catalog.record(providerId, modelId, limit);
4198
+ }
4199
+ resolveModelLimit(providerId, modelId) {
4200
+ return this.catalog.resolve(providerId, modelId);
4201
+ }
4202
+ /**
4203
+ * Best-effort one-time seed from the host's provider catalog
4204
+ * (`client.config.providers()` → GET /config/providers). Never throws;
4205
+ * returns the number of model-limit entries recorded.
4206
+ */
4207
+ hydrateModelLimitsFromClient(client) {
4208
+ return this.catalog.hydrateFromClient(client);
4209
+ }
4143
4210
  get(sessionId) {
4144
4211
  return this.states.get(sessionId);
4145
4212
  }
@@ -4228,6 +4295,8 @@ function createSessionState() {
4228
4295
  lastCompaction: 0,
4229
4296
  currentTurn: 0,
4230
4297
  modelContextLimit: void 0,
4298
+ modelProviderID: void 0,
4299
+ modelID: void 0,
4231
4300
  systemPromptTokens: void 0,
4232
4301
  qualityGateRetryPending: false
4233
4302
  };
@@ -4267,6 +4336,8 @@ function resetSessionState(state) {
4267
4336
  state.lastCompaction = 0;
4268
4337
  state.currentTurn = 0;
4269
4338
  state.modelContextLimit = void 0;
4339
+ state.modelProviderID = void 0;
4340
+ state.modelID = void 0;
4270
4341
  state.systemPromptTokens = void 0;
4271
4342
  state.qualityGateRetryPending = false;
4272
4343
  }
@@ -4341,6 +4412,8 @@ async function ensureSessionInitialized(client, state, sessionId, logger, messag
4341
4412
  }
4342
4413
  if (typeof persisted.modelContextLimit === "number" && persisted.modelContextLimit > 0) {
4343
4414
  state.modelContextLimit = persisted.modelContextLimit;
4415
+ state.modelProviderID = persisted.modelProviderID;
4416
+ state.modelID = persisted.modelID;
4344
4417
  }
4345
4418
  const applied = applyPendingCompressionDurations(state);
4346
4419
  if (applied > 0) {
@@ -4688,8 +4761,8 @@ ${progressBar}`;
4688
4761
  let toastMessage = message;
4689
4762
  toastMessage = config.pruneNotification === "minimal" ? toastMessage : truncateToastBody(toastMessage);
4690
4763
  if (config.debug) {
4691
- const chatMessage = config.pruneNotification === "minimal" ? message : truncateToastBody(message);
4692
- await sendIgnoredMessage(client, sessionId, chatMessage, params, logger);
4764
+ logger.debug(`[ACP Debug] Compress notification:
4765
+ ${message}`);
4693
4766
  }
4694
4767
  await client.tui.showToast({
4695
4768
  body: {
@@ -5672,11 +5745,6 @@ exact values, errors). Then add "acknowledgeRisk": true to the compress tool cal
5672
5745
  Without acknowledgeRisk: true, the compression will be rejected again.`;
5673
5746
  return new Error(message);
5674
5747
  }
5675
- function buildPreemptiveAcknowledgeError() {
5676
- return new Error(
5677
- '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.'
5678
- );
5679
- }
5680
5748
 
5681
5749
  // lib/compress/pipeline.ts
5682
5750
  function snapshotCompressionState(state) {
@@ -6150,13 +6218,13 @@ function createCompressRangeTool(factoryCtx) {
6150
6218
  }
6151
6219
  const acknowledgeRisk = args.acknowledgeRisk === true;
6152
6220
  const qualityGateRetryPendingBefore = ctx.state.qualityGateRetryPending;
6153
- if (acknowledgeRisk && !ctx.state.qualityGateRetryPending) {
6154
- 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");
6155
6225
  }
6156
- if (acknowledgeRisk) {
6157
- ctx.state.qualityGateRetryPending = false;
6158
- } else {
6159
- ctx.state.qualityGateRetryPending = false;
6226
+ ctx.state.qualityGateRetryPending = false;
6227
+ if (!bypassQuality) {
6160
6228
  for (const plan of preparedPlans) {
6161
6229
  const result = evaluatePreCommitQuality(
6162
6230
  rawMessages,
@@ -6232,7 +6300,10 @@ function createCompressRangeTool(factoryCtx) {
6232
6300
  const skippedNote = phantomSkipNotice !== null ? `
6233
6301
  \u26A0\uFE0F ${phantomSkipNotice}
6234
6302
  ` : "";
6235
- 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}
6236
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.
6237
6308
  \u{1F4A1} Tip: Use search_context('keyword') to find compressed content when you need it later.`;
6238
6309
  }
@@ -7566,16 +7637,6 @@ var injectCompressNudges = (state, config, logger, messages, prompts, compressio
7566
7637
  { logger }
7567
7638
  );
7568
7639
  const hasRecommendations = recommendedRanges.length > 0;
7569
- if (config.debug && contextRanges.compressible.length > 0) {
7570
- const compressible = contextRanges.compressible;
7571
- const fmt = (n) => n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n);
7572
- const lines = [
7573
- `[ACP Debug] Recommendation filter:`,
7574
- ` Input: ${compressible.length} range(s), ${fmt(compressible.reduce((s, r) => s + r.tokens, 0))} tokens`,
7575
- ` Output: ${recommendedRanges.length} range(s) (last segment marked dangerous)`
7576
- ];
7577
- logger.debug(lines.join("\n"));
7578
- }
7579
7640
  const allProtected = contextRanges.compressible.length === 0 && contextRanges.protected.length > 0;
7580
7641
  const allInProtectedZone = protectedRefs.size > 0 && unprotectedCompressible.length === 0;
7581
7642
  const nothingToCompress = allProtected || allInProtectedZone;
@@ -7671,6 +7732,16 @@ ${rules}`;
7671
7732
  }
7672
7733
  }
7673
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
+ }
7674
7745
  let tipsText = null;
7675
7746
  if (shouldInject) {
7676
7747
  if (suffixMessage && composition.total > 0) {
@@ -8125,10 +8196,6 @@ function resolveSingleBlockTarget(messagesState, blockIdArg) {
8125
8196
  error: `Error: Block ${target.displayId} is nested inside active block ${activeAncestorBlockId}. Decompress block ${activeAncestorBlockId} first.`
8126
8197
  };
8127
8198
  }
8128
- return {
8129
- ok: false,
8130
- error: `Error: Block ${target.displayId} is not active. It may have already been decompressed.`
8131
- };
8132
8199
  }
8133
8200
  return { ok: true, targets: [target] };
8134
8201
  }
@@ -8280,9 +8347,9 @@ function createDecompressTool(factoryCtx) {
8280
8347
  }
8281
8348
  const blockMessages = rawMessages.filter((m) => msgIdSet.has(extractMessageId(m)));
8282
8349
  const lines2 = blockMessages.map(extractMessageText2);
8283
- const { writeFile: writeFile3 } = await import("fs/promises");
8284
- const fileContent = lines2.length > 0 ? lines2.join("\n\n---\n\n") : activeBlocks[0]?.summary ?? "(no content available)";
8285
- await writeFile3(targetPath, fileContent, "utf-8");
8350
+ const { writeFile: writeFile4 } = await import("fs/promises");
8351
+ const fileContent = lines2.length > 0 ? lines2.join("\n\n---\n\n") : targets[0]?.blocks[0]?.summary ?? "(no content available)";
8352
+ await writeFile4(targetPath, fileContent, "utf-8");
8286
8353
  const displayIds2 = targets.map((t) => `b${t.displayId}`).join(", ");
8287
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.`;
8288
8355
  }
@@ -8738,16 +8805,23 @@ function renderCompressedDrilldown(blocks, sort, limit, blocksById) {
8738
8805
  lines.push(`Sorted by ${sort === "time" ? "time" : sort === "age" ? "age" : "size"}`);
8739
8806
  lines.push("");
8740
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
+ }
8741
8814
  for (const b of shown) {
8742
8815
  const survived = b.survivedCount ?? 0;
8743
8816
  const gen = b.generation ?? "young";
8744
8817
  const effCount = b.effectiveMessageIds?.length ?? 0;
8745
8818
  const consumed = b.includedBlockIds && b.includedBlockIds.length > 0 ? ` nested=[${b.includedBlockIds.map((n) => `b${n}`).join(",")}]` : "";
8819
+ const status = b.active ? "" : " [inactive]";
8746
8820
  const topic = b.topic || "(no topic)";
8747
8821
  const tier = tierLabel(b);
8748
8822
  const effTokens = getEffectiveCompressedTokens(b, blocksById);
8749
8823
  lines.push(
8750
- ` 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}`
8751
8825
  );
8752
8826
  lines.push(` "${topic}"`);
8753
8827
  }
@@ -8768,8 +8842,10 @@ function buildStatusReport(renderCtx, rawMessages, options) {
8768
8842
  const sort = options?.sort ?? "size";
8769
8843
  const limit = options?.limit ?? 30;
8770
8844
  const msgState = renderCtx.state.prune.messages;
8771
- const activeIds = Array.from(msgState.activeBlockIds).sort((a, b) => a - b);
8772
- 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);
8773
8849
  const lines = [];
8774
8850
  if (scope === "compressed") {
8775
8851
  lines.push(...renderCompressedDrilldown(allBlocks, sort, limit, msgState.blocksById));
@@ -8791,7 +8867,7 @@ function buildStatusReport(renderCtx, rawMessages, options) {
8791
8867
  visibleMsgs,
8792
8868
  summaryTokens,
8793
8869
  systemTokens,
8794
- allBlocks,
8870
+ activeBlocks,
8795
8871
  false,
8796
8872
  rawMessages,
8797
8873
  renderCtx
@@ -8949,7 +9025,7 @@ import { writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
8949
9025
  import { join as join3 } from "path";
8950
9026
  import { existsSync as existsSync3 } from "fs";
8951
9027
  import { homedir as homedir3 } from "os";
8952
- var LOG_VERSION = true ? "1.14.19" : "dev";
9028
+ var LOG_VERSION = true ? "1.14.20-pr.286.33" : "dev";
8953
9029
  var Logger = class {
8954
9030
  logDir;
8955
9031
  enabled;
@@ -9004,7 +9080,7 @@ var Logger = class {
9004
9080
  }
9005
9081
  }
9006
9082
  async write(level, component, message, data) {
9007
- if (!this.enabled) return;
9083
+ if (!this.enabled && level !== "ERROR" && level !== "WARN") return;
9008
9084
  try {
9009
9085
  await this.ensureLogDir();
9010
9086
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
@@ -9031,12 +9107,10 @@ var Logger = class {
9031
9107
  return this.write("DEBUG", component, message, data);
9032
9108
  }
9033
9109
  warn(message, data) {
9034
- if (!this.enabled) return;
9035
9110
  const component = this.getCallerFile(2);
9036
9111
  return this.write("WARN", component, message, data);
9037
9112
  }
9038
9113
  error(message, data) {
9039
- if (!this.enabled) return;
9040
9114
  const component = this.getCallerFile(2);
9041
9115
  return this.write("ERROR", component, message, data);
9042
9116
  }
@@ -10015,6 +10089,308 @@ ${report}`;
10015
10089
  );
10016
10090
  }
10017
10091
 
10092
+ // lib/commands/export.ts
10093
+ import * as fs2 from "fs/promises";
10094
+ import { existsSync as existsSync5 } from "fs";
10095
+ import { dirname as dirname3, isAbsolute, join as join5, resolve } from "path";
10096
+ var ALL_TIERS = [1, 2, 3];
10097
+ var TIER_NAMES = {
10098
+ 1: "Tier 1 \u2014 Capture",
10099
+ 2: "Tier 2 \u2014 Distilled",
10100
+ 3: "Tier 3 \u2014 Condensed"
10101
+ };
10102
+ function parseExportArgs(rawArgs) {
10103
+ const options = {
10104
+ outputPath: "",
10105
+ // "" sentinel => default path resolved later
10106
+ tiers: /* @__PURE__ */ new Set(),
10107
+ includeMetadata: true,
10108
+ append: false
10109
+ };
10110
+ const tokens = tokenize2(rawArgs);
10111
+ let i = 0;
10112
+ while (i < tokens.length) {
10113
+ const tok = tokens[i];
10114
+ const [flag, inlineValue] = splitFlag(tok);
10115
+ switch (flag) {
10116
+ case "--output":
10117
+ case "-o": {
10118
+ const value = inlineValue ?? tokens[i + 1];
10119
+ if (value === void 0) {
10120
+ throw new Error("--output requires a path (use `-` for stdout)");
10121
+ }
10122
+ options.outputPath = value;
10123
+ i += inlineValue !== void 0 ? 1 : 2;
10124
+ break;
10125
+ }
10126
+ case "--tier":
10127
+ case "-t": {
10128
+ const value = inlineValue ?? tokens[i + 1];
10129
+ if (value === void 0) {
10130
+ throw new Error("--tier requires a value (e.g. t2,t3 or all)");
10131
+ }
10132
+ options.tiers = parseTiers(value);
10133
+ i += inlineValue !== void 0 ? 1 : 2;
10134
+ break;
10135
+ }
10136
+ case "--no-metadata":
10137
+ if (inlineValue !== void 0) {
10138
+ throw new Error("--no-metadata does not take a value");
10139
+ }
10140
+ options.includeMetadata = false;
10141
+ i += 1;
10142
+ break;
10143
+ case "--metadata":
10144
+ if (inlineValue !== void 0) {
10145
+ throw new Error("--metadata does not take a value");
10146
+ }
10147
+ options.includeMetadata = true;
10148
+ i += 1;
10149
+ break;
10150
+ case "--append":
10151
+ if (inlineValue !== void 0) {
10152
+ throw new Error("--append does not take a value");
10153
+ }
10154
+ options.append = true;
10155
+ i += 1;
10156
+ break;
10157
+ case "--stdout":
10158
+ if (inlineValue !== void 0) {
10159
+ throw new Error("--stdout does not take a value");
10160
+ }
10161
+ options.outputPath = "-";
10162
+ i += 1;
10163
+ break;
10164
+ default:
10165
+ throw new Error(
10166
+ `Unknown flag: ${tok}. Supported: --output, --tier, --no-metadata, --append, --stdout.`
10167
+ );
10168
+ }
10169
+ }
10170
+ return options;
10171
+ }
10172
+ function tokenize2(input) {
10173
+ const tokens = [];
10174
+ const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
10175
+ let m;
10176
+ while ((m = re.exec(input)) !== null) {
10177
+ tokens.push(m[1] ?? m[2] ?? m[3]);
10178
+ }
10179
+ return tokens;
10180
+ }
10181
+ function splitFlag(tok) {
10182
+ const eq = tok.indexOf("=");
10183
+ if (eq === -1) return [tok, void 0];
10184
+ return [tok.slice(0, eq), tok.slice(eq + 1)];
10185
+ }
10186
+ function parseTiers(raw) {
10187
+ const lower = raw.trim().toLowerCase();
10188
+ if (lower === "all") return new Set(ALL_TIERS);
10189
+ const parts = lower.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
10190
+ const result = /* @__PURE__ */ new Set();
10191
+ for (const part of parts) {
10192
+ const digits = part.replace(/^t/i, "");
10193
+ const n = Number(digits);
10194
+ if (!Number.isInteger(n) || n < 1 || n > 3) {
10195
+ throw new Error(
10196
+ `Invalid tier "${part}". Expected t1, t2, t3, a combination (t2,t3), or "all".`
10197
+ );
10198
+ }
10199
+ result.add(n);
10200
+ }
10201
+ if (result.size === 0) {
10202
+ throw new Error(`Invalid tier "${raw}". Expected t1, t2, t3, or "all".`);
10203
+ }
10204
+ return result;
10205
+ }
10206
+ function resolveDefaultOutputPath(sessionId, cwd) {
10207
+ const short = (sessionId || "session").slice(0, 8);
10208
+ return join5(cwd, ".opencode", `acp-export-${short}.md`);
10209
+ }
10210
+ function formatTokens2(n) {
10211
+ if (!Number.isFinite(n) || n <= 0) return "0";
10212
+ return n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n);
10213
+ }
10214
+ function effectiveTokens(block) {
10215
+ return block.effectiveCompressedTokens ?? block.compressedTokens ?? 0;
10216
+ }
10217
+ function collectActiveBlocks(state) {
10218
+ const msgState = state.prune.messages;
10219
+ const blocks = [];
10220
+ for (const id of msgState.activeBlockIds) {
10221
+ const block = msgState.blocksById.get(id);
10222
+ if (block && block.active) {
10223
+ blocks.push(block);
10224
+ }
10225
+ }
10226
+ return blocks;
10227
+ }
10228
+ function filterByTier(blocks, tiers) {
10229
+ if (tiers.size === 0) return blocks;
10230
+ return blocks.filter((b) => tiers.has(b.tier ?? 1));
10231
+ }
10232
+ function tokenSpans(blocks) {
10233
+ return {
10234
+ effective: blocks.reduce((s, b) => s + effectiveTokens(b), 0),
10235
+ summary: blocks.reduce((s, b) => s + (b.summaryTokens || 0), 0)
10236
+ };
10237
+ }
10238
+ function tierCounts(blocks) {
10239
+ const counts = {};
10240
+ for (const b of blocks) {
10241
+ const t = b.tier ?? 1;
10242
+ counts[t] = (counts[t] || 0) + 1;
10243
+ }
10244
+ return ALL_TIERS.filter((t) => counts[t]).map((t) => `T${t}: ${counts[t]}`).join(", ");
10245
+ }
10246
+ function renderExportMarkdown(params) {
10247
+ const { sessionId, generatedAt, blocks, tiers, includeMetadata } = params;
10248
+ const out = [];
10249
+ const tierFilterLabel = tiers.size === 0 ? "all" : ALL_TIERS.filter((t) => tiers.has(t)).map((t) => `T${t}`).join(", ");
10250
+ out.push("# ACP Session Export");
10251
+ out.push("");
10252
+ out.push(`- **Session**: \`${(sessionId || "unknown").slice(0, 16)}\``);
10253
+ out.push(`- **Generated**: ${generatedAt.toISOString()}`);
10254
+ out.push(`- **Blocks exported**: ${blocks.length}`);
10255
+ const breakdown = tierCounts(blocks);
10256
+ if (breakdown) {
10257
+ out.push(` - ${breakdown}`);
10258
+ }
10259
+ out.push(`- **Tiers**: ${tierFilterLabel}`);
10260
+ const span = tokenSpans(blocks);
10261
+ if (blocks.length > 0) {
10262
+ out.push(
10263
+ `- **Coverage**: ${formatTokens2(span.effective)} original \u2192 ${formatTokens2(span.summary)} summary`
10264
+ );
10265
+ }
10266
+ out.push("");
10267
+ out.push("---");
10268
+ out.push("");
10269
+ if (blocks.length === 0) {
10270
+ out.push("_No active compression blocks match the selected tiers._");
10271
+ out.push("");
10272
+ out.push(
10273
+ "Tip: run `/acp export --tier all` to include every active tier, or trigger a compression first."
10274
+ );
10275
+ return out.join("\n");
10276
+ }
10277
+ for (const tier of [3, 2, 1]) {
10278
+ const tierBlocks = blocks.filter((b) => (b.tier ?? 1) === tier).sort((a, b) => a.blockId - b.blockId);
10279
+ if (tierBlocks.length === 0) continue;
10280
+ out.push(`## ${TIER_NAMES[tier]}`);
10281
+ out.push("");
10282
+ for (const block of tierBlocks) {
10283
+ const topic = block.topic || block.batchTopic || "(no topic)";
10284
+ out.push(`### b${block.blockId} \u2014 ${topic}`);
10285
+ out.push("");
10286
+ if (includeMetadata) {
10287
+ const mode = block.mode ?? "range";
10288
+ const msgCount = block.effectiveMessageIds?.length ?? 0;
10289
+ const created = new Date(block.createdAt).toISOString();
10290
+ const age = block.survivedCount ?? 0;
10291
+ out.push(`- **Tier**: T${tier} \xB7 **Mode**: ${mode}`);
10292
+ out.push(`- **Messages**: ${msgCount} (effective)`);
10293
+ out.push(
10294
+ `- **Tokens**: ${formatTokens2(effectiveTokens(block))} \u2192 ${formatTokens2(block.summaryTokens || 0)} summary`
10295
+ );
10296
+ out.push(`- **Created**: ${created}`);
10297
+ out.push(`- **Age**: survived ${age} transform${age === 1 ? "" : "s"}`);
10298
+ out.push("");
10299
+ }
10300
+ const summary = (block.summary || "").trim() || "_(empty summary)_";
10301
+ out.push(summary);
10302
+ out.push("");
10303
+ out.push("---");
10304
+ out.push("");
10305
+ }
10306
+ }
10307
+ return out.join("\n");
10308
+ }
10309
+ async function handleExportCommand(ctx, args) {
10310
+ const { client, state, logger, sessionId } = ctx;
10311
+ let options;
10312
+ try {
10313
+ options = parseExportArgs(args);
10314
+ } catch (err) {
10315
+ const msg = err?.message ?? String(err);
10316
+ logger.warn("export: argument parse failed", { error: msg, args });
10317
+ await sendExportNotice(client, sessionId, ctx, `[ACP Export] ${msg}`);
10318
+ return;
10319
+ }
10320
+ const tiers = options.tiers;
10321
+ const allActive = collectActiveBlocks(state);
10322
+ const blocks = filterByTier(allActive, tiers);
10323
+ const generatedAt = /* @__PURE__ */ new Date();
10324
+ const markdown = renderExportMarkdown({
10325
+ sessionId,
10326
+ generatedAt,
10327
+ blocks,
10328
+ tiers,
10329
+ includeMetadata: options.includeMetadata
10330
+ });
10331
+ if (options.outputPath === "-") {
10332
+ await sendExportNotice(client, sessionId, ctx, markdown);
10333
+ return;
10334
+ }
10335
+ const cwd = ctx.workingDirectory || process.cwd();
10336
+ const targetPath = options.outputPath !== "" ? isAbsolute(options.outputPath) ? options.outputPath : resolve(cwd, options.outputPath) : resolveDefaultOutputPath(sessionId, cwd);
10337
+ try {
10338
+ const dir = dirname3(targetPath);
10339
+ if (!existsSync5(dir)) {
10340
+ await fs2.mkdir(dir, { recursive: true });
10341
+ }
10342
+ const flag = options.append ? "a" : "w";
10343
+ if (options.append && existsSync5(targetPath)) {
10344
+ await fs2.appendFile(targetPath, `
10345
+
10346
+ ---
10347
+
10348
+ ${markdown}`, "utf-8");
10349
+ } else {
10350
+ await fs2.writeFile(targetPath, markdown, { flag, encoding: "utf-8" });
10351
+ }
10352
+ } catch (err) {
10353
+ const msg = err?.message ?? String(err);
10354
+ logger.warn("export: file write failed", { path: targetPath, error: msg });
10355
+ await sendExportNotice(
10356
+ client,
10357
+ sessionId,
10358
+ ctx,
10359
+ `[ACP Export] Failed to write \`${targetPath}\`: ${msg}`
10360
+ );
10361
+ return;
10362
+ }
10363
+ logger.info("export: wrote markdown", {
10364
+ path: targetPath,
10365
+ blocks: blocks.length,
10366
+ tiers: tiers.size ? [...tiers].sort().join(",") : "all"
10367
+ });
10368
+ const summary = formatExportSummary(targetPath, blocks, allActive, generatedAt);
10369
+ await sendExportNotice(client, sessionId, ctx, summary);
10370
+ }
10371
+ function formatExportSummary(path, exported, allActive, generatedAt) {
10372
+ const lines = [];
10373
+ lines.push("[ACP Export]");
10374
+ lines.push(`Wrote ${exported.length} block${exported.length === 1 ? "" : "s"} to:`);
10375
+ lines.push(` ${path}`);
10376
+ if (exported.length === 0) {
10377
+ lines.push("");
10378
+ lines.push(
10379
+ `No matching blocks (of ${allActive.length} active). Try \`/acp export --tier all\`.`
10380
+ );
10381
+ } else {
10382
+ lines.push("");
10383
+ lines.push(`Generated ${generatedAt.toISOString()}.`);
10384
+ lines.push(
10385
+ "Review, edit, and commit as a devlog / AGENTS.md supplement \u2014 export is user-driven, never auto-injected."
10386
+ );
10387
+ }
10388
+ return lines.join("\n");
10389
+ }
10390
+ async function sendExportNotice(client, sessionId, ctx, text) {
10391
+ await sendIgnoredMessage(client, sessionId, text, {}, ctx.logger);
10392
+ }
10393
+
10018
10394
  // lib/messages/filter/registry.ts
10019
10395
  var registry3 = /* @__PURE__ */ new Map();
10020
10396
  function registerMessageFilter(filter) {
@@ -10047,6 +10423,7 @@ function applyMessageFilters(messages, config, logger, ctx) {
10047
10423
  }
10048
10424
  const result = { partsFiltered: 0, partsDropped: 0, partsModified: 0 };
10049
10425
  const total = messages.length;
10426
+ const warnedFilters = /* @__PURE__ */ new Set();
10050
10427
  const buildCtx = (text, role, i) => ({
10051
10428
  text,
10052
10429
  role,
@@ -10102,11 +10479,14 @@ function applyMessageFilters(messages, config, logger, ctx) {
10102
10479
  try {
10103
10480
  decision = filter.filter(filterCtx);
10104
10481
  } catch (err) {
10105
- logger.warn("Message filter threw error", {
10106
- filter: filter.name,
10107
- error: err instanceof Error ? err.message : String(err),
10108
- messageIndex: i
10109
- });
10482
+ if (!warnedFilters.has(filter.name)) {
10483
+ warnedFilters.add(filter.name);
10484
+ logger.warn("Message filter threw error", {
10485
+ filter: filter.name,
10486
+ error: err instanceof Error ? err.message : String(err),
10487
+ messageIndex: i
10488
+ });
10489
+ }
10110
10490
  continue;
10111
10491
  }
10112
10492
  if (decision.action === "keep") continue;
@@ -10514,9 +10894,16 @@ function isInternalAgentRequest(messages) {
10514
10894
  }
10515
10895
  function createSystemPromptHandler(registry4, logger, config, prompts) {
10516
10896
  return async (input, output) => {
10897
+ registry4.recordModelLimit(
10898
+ input.model?.providerID,
10899
+ input.model?.id,
10900
+ input.model?.limit?.context
10901
+ );
10517
10902
  const state = input.sessionID ? registry4.get(input.sessionID) : void 0;
10518
10903
  if (state && input.model?.limit?.context) {
10519
10904
  state.modelContextLimit = input.model.limit.context;
10905
+ state.modelProviderID = input.model?.providerID;
10906
+ state.modelID = input.model?.id;
10520
10907
  }
10521
10908
  if (!state || state.isSubAgent && !config.allowSubAgents) {
10522
10909
  return;
@@ -10569,6 +10956,30 @@ function createChatMessageTransformHandler(client, registry4, logger, config, pr
10569
10956
  messages,
10570
10957
  config
10571
10958
  );
10959
+ const requestModel = lastUserMessage.info.model;
10960
+ const requestModelLimit = registry4.resolveModelLimit(
10961
+ requestModel?.providerID,
10962
+ requestModel?.modelID
10963
+ );
10964
+ if (requestModelLimit !== void 0) {
10965
+ state.modelContextLimit = requestModelLimit;
10966
+ state.modelProviderID = requestModel?.providerID;
10967
+ state.modelID = requestModel?.modelID;
10968
+ } else if (
10969
+ // [FIX #312 fallback] Catalog miss: we cannot CORRECT the
10970
+ // limit, but we can tell when it belongs to a DIFFERENT model.
10971
+ // Invalidate instead of letting every percentage threshold
10972
+ // below run against the wrong window (#312's false positive).
10973
+ // States persisted before this identity pair existed carry no
10974
+ // identity and are treated as stale for the same reason.
10975
+ // Consumers already tolerate undefined — fresh sessions run
10976
+ // with it until the first system.transform sets the pair.
10977
+ requestModel?.providerID && requestModel?.modelID && state.modelContextLimit !== void 0 && (state.modelProviderID !== requestModel.providerID || state.modelID !== requestModel.modelID)
10978
+ ) {
10979
+ state.modelContextLimit = void 0;
10980
+ state.modelProviderID = requestModel.providerID;
10981
+ state.modelID = requestModel.modelID;
10982
+ }
10572
10983
  await updatePerTurnState(state, logger, messages);
10573
10984
  }
10574
10985
  syncCompressPermissionState(state, config, hostPermissions, output.messages);
@@ -10614,23 +11025,6 @@ function createChatMessageTransformHandler(client, registry4, logger, config, pr
10614
11025
  config.debug ? (text) => {
10615
11026
  logger.debug(`[ACP Debug] Nudge injected:
10616
11027
  ${text}`);
10617
- if (state.sessionId && lastUserMessage) {
10618
- const userInfo = lastUserMessage.info;
10619
- sendIgnoredMessage(
10620
- client,
10621
- state.sessionId,
10622
- `[ACP Debug Nudge]
10623
- ${text}`,
10624
- {
10625
- providerId: userInfo.model?.providerID,
10626
- modelId: userInfo.model?.modelID,
10627
- agent: userInfo.agent,
10628
- variant: userInfo.variant
10629
- },
10630
- logger
10631
- ).catch(() => {
10632
- });
10633
- }
10634
11028
  client.tui.showToast({
10635
11029
  body: {
10636
11030
  title: "ACP: Nudge Injected",
@@ -10652,6 +11046,20 @@ ${text}`,
10652
11046
  }
10653
11047
  };
10654
11048
  }
11049
+ function buildHelpText() {
11050
+ return [
11051
+ "[ACP] Available commands:",
11052
+ "",
11053
+ " /acp Show compression status (same as /acp stats)",
11054
+ " /acp context Token usage breakdown (system, user, assistant, tools)",
11055
+ " /acp stats Compression status: blocks, context usage, ranges",
11056
+ " /acp export Export active compression blocks to markdown",
11057
+ " Options: --output <path>, --tier t1,t2,t3, --stdout, --append",
11058
+ " /acp help Show this help",
11059
+ "",
11060
+ "Also accepts /dcp for backward compatibility."
11061
+ ].join("\n");
11062
+ }
10655
11063
  function createCommandExecuteHandler(client, registry4, logger, config, workingDirectory, hostPermissions) {
10656
11064
  return async (input, output) => {
10657
11065
  if (!config.commands.enabled) {
@@ -10669,23 +11077,35 @@ function createCommandExecuteHandler(client, registry4, logger, config, workingD
10669
11077
  config
10670
11078
  );
10671
11079
  syncCompressPermissionState(state, config, hostPermissions, messages);
10672
- const effectivePermission = compressPermission(state, config);
10673
- if (effectivePermission === "deny") {
10674
- return;
10675
- }
10676
11080
  const commandCtx = {
10677
11081
  client,
10678
11082
  state,
10679
11083
  config,
10680
11084
  logger,
10681
11085
  sessionId: input.sessionID,
10682
- messages
11086
+ messages,
11087
+ workingDirectory
10683
11088
  };
10684
11089
  const sub = input.arguments?.trim().toLowerCase();
10685
- if (sub === "stats" || sub === "status") {
11090
+ if (sub === "stats" || sub === "status" || sub === "") {
10686
11091
  await handleStatsCommand(commandCtx);
10687
11092
  throw new Error("__DCP_CONTEXT_HANDLED__");
10688
11093
  }
11094
+ if (sub === "export" || sub.startsWith("export ")) {
11095
+ const exportArgs = input.arguments?.trim().slice("export".length).trim() || "";
11096
+ await handleExportCommand(commandCtx, exportArgs);
11097
+ throw new Error("__DCP_CONTEXT_HANDLED__");
11098
+ }
11099
+ if (sub === "help") {
11100
+ await sendIgnoredMessage(
11101
+ client,
11102
+ input.sessionID,
11103
+ buildHelpText(),
11104
+ {},
11105
+ logger
11106
+ );
11107
+ throw new Error("__DCP_CONTEXT_HANDLED__");
11108
+ }
10689
11109
  await handleContextCommand(commandCtx);
10690
11110
  throw new Error("__DCP_CONTEXT_HANDLED__");
10691
11111
  }
@@ -10795,7 +11215,7 @@ function configureClientAuth(client) {
10795
11215
 
10796
11216
  // lib/update.ts
10797
11217
  import { readFile as readFile2, rm } from "fs/promises";
10798
- import { basename, dirname as dirname3, join as join5 } from "path";
11218
+ import { basename, dirname as dirname4, join as join6 } from "path";
10799
11219
  import { fileURLToPath } from "url";
10800
11220
  var PACKAGE_NAME = "opencode-acp";
10801
11221
  function startAutoUpdate(ctx, enabled) {
@@ -10820,7 +11240,7 @@ function startAutoUpdate(ctx, enabled) {
10820
11240
  async function checkAutoUpdate(signal) {
10821
11241
  const packageDir = await findPackageDir(PACKAGE_NAME);
10822
11242
  if (!packageDir) return { updated: false };
10823
- const pkg = await readPackageJson(join5(packageDir, "package.json"));
11243
+ const pkg = await readPackageJson(join6(packageDir, "package.json"));
10824
11244
  if (!pkg?.name || !pkg.version) return { updated: false };
10825
11245
  const latest = await fetchLatestVersion(pkg.name, signal);
10826
11246
  if (!latest || !isVersionNewer(latest, pkg.version)) return { updated: false };
@@ -10840,21 +11260,21 @@ async function checkAutoUpdate(signal) {
10840
11260
  return { updated: true, name: pkg.name, current: pkg.version, latest };
10841
11261
  }
10842
11262
  async function findPackageDir(name) {
10843
- let dir = dirname3(fileURLToPath(import.meta.url));
11263
+ let dir = dirname4(fileURLToPath(import.meta.url));
10844
11264
  for (; ; ) {
10845
- const pkg = await readPackageJson(join5(dir, "package.json"));
11265
+ const pkg = await readPackageJson(join6(dir, "package.json"));
10846
11266
  if (pkg?.name === name) return dir;
10847
- const parent = dirname3(dir);
11267
+ const parent = dirname4(dir);
10848
11268
  if (parent === dir) return void 0;
10849
11269
  dir = parent;
10850
11270
  }
10851
11271
  }
10852
11272
  async function updateRemoveDir(packageDir, name) {
10853
- const packageParent = dirname3(packageDir);
10854
- const nodeModulesDir = basename(packageParent).startsWith("@") ? dirname3(packageParent) : packageParent;
11273
+ const packageParent = dirname4(packageDir);
11274
+ const nodeModulesDir = basename(packageParent).startsWith("@") ? dirname4(packageParent) : packageParent;
10855
11275
  if (basename(nodeModulesDir) !== "node_modules") return void 0;
10856
- const wrapperDir = dirname3(nodeModulesDir);
10857
- const wrapperPkg = await readPackageJson(join5(wrapperDir, "package.json"));
11276
+ const wrapperDir = dirname4(nodeModulesDir);
11277
+ const wrapperPkg = await readPackageJson(join6(wrapperDir, "package.json"));
10858
11278
  const spec = wrapperSpec(wrapperDir, name) ?? wrapperPkg?.dependencies?.[name];
10859
11279
  if (!spec || !isAutoUpdatableSpec(spec)) return void 0;
10860
11280
  return wrapperDir;
@@ -10862,7 +11282,7 @@ async function updateRemoveDir(packageDir, name) {
10862
11282
  function wrapperSpec(wrapperDir, name) {
10863
11283
  if (name.startsWith("@")) {
10864
11284
  const [scope, pkg] = name.split("/");
10865
- if (!scope || !pkg || basename(dirname3(wrapperDir)) !== scope) return void 0;
11285
+ if (!scope || !pkg || basename(dirname4(wrapperDir)) !== scope) return void 0;
10866
11286
  const prefix2 = `${pkg}@`;
10867
11287
  const base2 = basename(wrapperDir);
10868
11288
  return base2.startsWith(prefix2) ? base2.slice(prefix2.length) : void 0;
@@ -10954,6 +11374,25 @@ var server = (async (ctx) => {
10954
11374
  if (isSecureMode()) {
10955
11375
  configureClientAuth(ctx.client);
10956
11376
  }
11377
+ registry4.hydrateModelLimitsFromClient(ctx.client).then(
11378
+ (recorded) => {
11379
+ if (recorded > 0) {
11380
+ logger.info("Model limit catalog seeded from provider config", {
11381
+ models: recorded
11382
+ });
11383
+ } else {
11384
+ logger.warn(
11385
+ "Model limit catalog seeding recorded no entries \u2014 falling back to per-request refresh (system.transform)"
11386
+ );
11387
+ }
11388
+ },
11389
+ (error) => {
11390
+ logger.warn(
11391
+ "Model limit catalog seeding failed \u2014 falling back to per-request refresh (system.transform)",
11392
+ { error: error instanceof Error ? error.message : String(error) }
11393
+ );
11394
+ }
11395
+ );
10957
11396
  logger.info("DCP initialized");
10958
11397
  startAutoUpdate(ctx, config.autoUpdate);
10959
11398
  const compressToolContext = {