opencode-acp 1.14.19 → 1.14.20-pr.316.31

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/README.md CHANGED
@@ -284,7 +284,8 @@ Each level overrides the previous, so project settings take priority over global
284
284
  // Automatically update npm-installed ACP when a newer npm latest is available.
285
285
  // Version-locked plugin specs are not updated.
286
286
  "autoUpdate": true,
287
- // Enable debug logging to ~/.config/opencode/logs/acp/
287
+ // Enable INFO/DEBUG logging + per-request snapshots to ~/.config/opencode/logs/acp/
288
+ // (WARN/ERROR are always logged to daily/<date>.log)
288
289
  "debug": false,
289
290
  // Notification display: "off", "minimal", or "detailed"
290
291
  "pruneNotification": "off",
@@ -492,6 +493,20 @@ For the complete list with root cause analysis, see the [bug tracker](https://gi
492
493
 
493
494
  ## Changelog
494
495
 
496
+ ### v1.14.20 — Post-v1.14.19 fix batch (modelContextLimit lifecycle, inactive-block decompress, logging)
497
+
498
+ **Problem**: Five clusters of post-v1.14.19 issues: (1) `modelContextLimit` stayed stale after a model switch (#312) — within one LLM request the messages hook runs before the system hook refreshes the limit, and on a catalog miss (fresh instance + failed hydration) the previous model's window survived, so every percentage threshold misfired (#315); (2) `decompress` failed on standalone inactive blocks and inactive blocks were invisible in `acp_status` (#193); (3) a preemptive `acknowledgeRisk: true` (carried over from a non-quality error) hard-failed with "no quality gate rejection is pending" (#301); (4) debug nudge notifications used `sendIgnoredMessage` causing phantom turns, the debug recommendation-filter log spammed every turn, and ERROR/WARN lines only reached the daily log when debug was on (#311, #278, #279).
499
+
500
+ **Fix**:
501
+ - #314 + #315: reconcile `state.modelContextLimit` from the model-limit catalog on model switch; record the (provider, model) identity pair alongside the limit and, on a catalog miss, invalidate it when the request's model identity mismatches (match keeps it; legacy persisted states without an identity are treated as stale — one blind turn per upgraded session). The catalog is extracted to `lib/state/model-limits.ts`; failed hydration now logs (info with entry count on success, warn on zero entries/failure) instead of degrading silently.
502
+ - #193: `decompress` accepts standalone inactive blocks (nested-block redirect unchanged) and `decompress toFile` writes the real block content; `acp_status scope=compressed` now lists all blocks with an `[inactive]` marker plus active/inactive counts (overview still shows active blocks only).
503
+ - #303: `acknowledgeRisk` without a pending quality-gate rejection is now a no-op with a usage-teaching note instead of an error — only a real quality-gate rejection arms the bypass, so the gate's protection is unchanged.
504
+ - #278/#279/#311: debug notifications no longer use `sendIgnoredMessage` (phantom turn loop gone); the recommendation-filter log is gated behind `shouldInject` (no more per-turn noise); ERROR/WARN are written to the daily log even when debug is off.
505
+
506
+ Files: `lib/compress/range.ts`, `lib/state/model-limits.ts`, `lib/state/types.ts`, `index.ts`, `lib/state/registry.ts`. Tests: 1003 pass.
507
+
508
+ **Install**: `opencode plugin opencode-acp@latest --global`
509
+
495
510
  ### v1.14.19 — Fix release pipeline for real (npm 10 `--ignore-scripts` bug)
496
511
 
497
512
  **Problem**: v1.14.18 (#306) was merged but still never published — `release.yml` failed at the exact same step. Root cause of the failed fix: the CI runner uses Node 22 (npm 10.9.x), and **npm 10 runs the `prepare` lifecycle hook during `npm pack` even when `--ignore-scripts` is passed**. v1.14.18's fix (`npm pack --ignore-scripts`) works on npm 11 but is a no-op on npm 10. When prepare runs, tsup writes `CLI Building entry: index.ts` to **stdout**, breaking `JSON.parse` in `verify-package.mjs` (`SyntaxError: Unexpected token`).
package/README.zh-CN.md CHANGED
@@ -239,7 +239,8 @@ ACP 使用自己的配置文件,按以下顺序搜索:
239
239
  // Automatically update npm-installed ACP when a newer npm latest is available.
240
240
  // Version-locked plugin specs are not updated.
241
241
  "autoUpdate": true,
242
- // Enable debug logging to ~/.config/opencode/logs/acp/
242
+ // Enable INFO/DEBUG logging + per-request snapshots to ~/.config/opencode/logs/acp/
243
+ // (WARN/ERROR are always logged to daily/<date>.log)
243
244
  "debug": false,
244
245
  // Notification display: "off", "minimal", or "detailed"
245
246
  "pruneNotification": "detailed",
@@ -446,6 +447,20 @@ ACP 是 DCP 的直接替代品。迁移步骤:
446
447
 
447
448
  ## 更新日志
448
449
 
450
+ ### v1.14.20 — v1.14.19 之后的修复批次(modelContextLimit 生命周期、inactive block 解压缩、日志)
451
+
452
+ **问题**:五类 v1.14.19 之后发现的问题:(1)`modelContextLimit` 在模型切换后失效(#312)——同一次 LLM 请求内,messages hook 先于 system hook 刷新窗口值,而目录未命中时(新实例 + 水合失败),上一个模型的窗口值会一直残留,导致所有按百分比计算的阈值误触发(#315);(2)`decompress` 对独立的 inactive block 报 `Block not found`,且 inactive block 在 `acp_status` 里完全不可见(#193);(3)预防式 `acknowledgeRisk: true`(从非质量门禁错误里带过来的)直接报错 `no quality gate rejection is pending`(#301);(4)debug nudge 通知用 `sendIgnoredMessage` 触发幽灵回合(#311),debug 推荐过滤日志每回合刷屏,ERROR/WARN 在 debug 关闭时不进日常日志(#311、#278、#279)。
453
+
454
+ **修复**:
455
+ - #314 + #315:模型切换时从模型限制目录重新对齐 `state.modelContextLimit`;目录未命中时记录 (provider, model) 身份对,请求的身份与记录不一致时使窗口值失效(一致则保留;未记录身份的旧持久化状态按失效处理,升级后每个会话盲一轮)。目录提取到 `lib/state/model-limits.ts`;水合失败现在会记日志(成功 info 带条目数,失败 warn),不再静默降级。
456
+ - #193:`decompress` 接受独立的 inactive block(嵌套 block 重定向不变);`acp_status scope=compressed` 现在列出所有 block,inactive 的带 `[inactive]` 标记,并显示 active/inactive 计数(overview 仍只显示 active)。
457
+ - #303:没有待处理的质量门禁拒绝时,`acknowledgeRisk` 变为 no-op + 用法提示,不再报错 —— 真正的质量门禁拒绝才会武装 bypass,门禁保护不受影响。
458
+ - #278/#279/#311:debug 通知不再用 `sendIgnoredMessage`(幽灵回合循环消失);推荐过滤日志改为 `shouldInject` 后才输出(不再每回合刷屏);ERROR/WARN 在 debug 关闭时也写入日常日志。
459
+
460
+ 文件:`lib/compress/range.ts`、`lib/state/model-limits.ts`、`lib/state/types.ts`、`index.ts`、`lib/state/registry.ts`。测试:1003 通过。
461
+
462
+ **安装**:`opencode plugin opencode-acp@latest --global`
463
+
449
464
  ### v1.14.19 — 彻底修复发版流水线(npm 10 `--ignore-scripts` 失效)
450
465
 
451
466
  **问题**:v1.14.18(#306)已合并但依然没有发布 —— `release.yml` 又在同一步骤失败。v1.14.18 修复失败的根本原因:CI 运行器是 Node 22(npm 10.9.x),而 **npm 10 在 `npm pack` 时即使传了 `--ignore-scripts` 也照样执行 `prepare` 生命周期钩子**。v1.14.18 的修复(`npm pack --ignore-scripts`)在 npm 11 上有效,在 npm 10 上是空操作。prepare 一旦执行,tsup 会把 `CLI Building entry: index.ts` 写到 **stdout**,破坏 `verify-package.mjs` 里的 `JSON.parse`(`SyntaxError: Unexpected token`)。
@@ -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,MAiHK,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,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
  }
@@ -8281,7 +8348,7 @@ function createDecompressTool(factoryCtx) {
8281
8348
  const blockMessages = rawMessages.filter((m) => msgIdSet.has(extractMessageId(m)));
8282
8349
  const lines2 = blockMessages.map(extractMessageText2);
8283
8350
  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)";
8351
+ const fileContent = lines2.length > 0 ? lines2.join("\n\n---\n\n") : targets[0]?.blocks[0]?.summary ?? "(no content available)";
8285
8352
  await writeFile3(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.`;
@@ -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.316.31" : "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
  }
@@ -10047,6 +10121,7 @@ function applyMessageFilters(messages, config, logger, ctx) {
10047
10121
  }
10048
10122
  const result = { partsFiltered: 0, partsDropped: 0, partsModified: 0 };
10049
10123
  const total = messages.length;
10124
+ const warnedFilters = /* @__PURE__ */ new Set();
10050
10125
  const buildCtx = (text, role, i) => ({
10051
10126
  text,
10052
10127
  role,
@@ -10102,11 +10177,14 @@ function applyMessageFilters(messages, config, logger, ctx) {
10102
10177
  try {
10103
10178
  decision = filter.filter(filterCtx);
10104
10179
  } 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
- });
10180
+ if (!warnedFilters.has(filter.name)) {
10181
+ warnedFilters.add(filter.name);
10182
+ logger.warn("Message filter threw error", {
10183
+ filter: filter.name,
10184
+ error: err instanceof Error ? err.message : String(err),
10185
+ messageIndex: i
10186
+ });
10187
+ }
10110
10188
  continue;
10111
10189
  }
10112
10190
  if (decision.action === "keep") continue;
@@ -10514,9 +10592,16 @@ function isInternalAgentRequest(messages) {
10514
10592
  }
10515
10593
  function createSystemPromptHandler(registry4, logger, config, prompts) {
10516
10594
  return async (input, output) => {
10595
+ registry4.recordModelLimit(
10596
+ input.model?.providerID,
10597
+ input.model?.id,
10598
+ input.model?.limit?.context
10599
+ );
10517
10600
  const state = input.sessionID ? registry4.get(input.sessionID) : void 0;
10518
10601
  if (state && input.model?.limit?.context) {
10519
10602
  state.modelContextLimit = input.model.limit.context;
10603
+ state.modelProviderID = input.model?.providerID;
10604
+ state.modelID = input.model?.id;
10520
10605
  }
10521
10606
  if (!state || state.isSubAgent && !config.allowSubAgents) {
10522
10607
  return;
@@ -10569,6 +10654,30 @@ function createChatMessageTransformHandler(client, registry4, logger, config, pr
10569
10654
  messages,
10570
10655
  config
10571
10656
  );
10657
+ const requestModel = lastUserMessage.info.model;
10658
+ const requestModelLimit = registry4.resolveModelLimit(
10659
+ requestModel?.providerID,
10660
+ requestModel?.modelID
10661
+ );
10662
+ if (requestModelLimit !== void 0) {
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;
10680
+ }
10572
10681
  await updatePerTurnState(state, logger, messages);
10573
10682
  }
10574
10683
  syncCompressPermissionState(state, config, hostPermissions, output.messages);
@@ -10614,23 +10723,6 @@ function createChatMessageTransformHandler(client, registry4, logger, config, pr
10614
10723
  config.debug ? (text) => {
10615
10724
  logger.debug(`[ACP Debug] Nudge injected:
10616
10725
  ${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
10726
  client.tui.showToast({
10635
10727
  body: {
10636
10728
  title: "ACP: Nudge Injected",
@@ -10954,6 +11046,25 @@ var server = (async (ctx) => {
10954
11046
  if (isSecureMode()) {
10955
11047
  configureClientAuth(ctx.client);
10956
11048
  }
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
+ );
10957
11068
  logger.info("DCP initialized");
10958
11069
  startAutoUpdate(ctx, config.autoUpdate);
10959
11070
  const compressToolContext = {