opencode-anthropic-fix 0.1.8 → 0.1.11

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.
Files changed (3) hide show
  1. package/index.mjs +891 -36
  2. package/lib/config.mjs +121 -9
  3. package/package.json +1 -1
package/index.mjs CHANGED
@@ -184,6 +184,35 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
184
184
  lastHeader: null,
185
185
  };
186
186
 
187
+ // Context-hint controller (CC v2.1.110+). Mirrors real CC's `createContextHintController`:
188
+ // sticky on across requests until the server responds with a specific error family.
189
+ // - 422/424 → apply hint compaction (clear thinking + microcompact) and retry
190
+ // - 400 "Unexpected value" + "anthropic-beta" → disable for session (beta unsupported)
191
+ // - 409 → disable for session (conflict)
192
+ // - 529 / overloaded → disable for session (temporary overload)
193
+ // When disabled, we strip context-hint from betas + body on subsequent requests so
194
+ // we don't keep triggering the same rejection and churning the cache.
195
+ const contextHintState = {
196
+ /** Permanently disabled for this session after a server rejection. */
197
+ disabled: false,
198
+ /** Number of 422/424 compactions applied this session (for telemetry). */
199
+ compactionsApplied: 0,
200
+ };
201
+
202
+ // Token economy — session state for layered compaction strategies.
203
+ const tokenEconomySession = {
204
+ /** When thinking was last stripped (TTL-based strategy). 0 = never. */
205
+ lastThinkingStripMs: 0,
206
+ /** When proactive microcompact was last run (threshold-based). 0 = never. */
207
+ lastMicrocompactMs: 0,
208
+ /** Running count of tool_results client-compacted this session. */
209
+ toolResultsCompacted: 0,
210
+ /** Running count of thinking blocks stripped this session. */
211
+ thinkingStripped: 0,
212
+ /** Map of content-hash → first-seen tool_use_id for cross-turn dedupe. */
213
+ seenContentHashes: new Map(),
214
+ };
215
+
187
216
  // Cache TTL session latching: latch the cache policy at session start
188
217
  // so mid-session toggles don't bust the server-side prompt cache.
189
218
  let sessionCachePolicyLatched = false;
@@ -666,6 +695,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
666
695
  const secondary = (args[1] || "").toLowerCase();
667
696
  if (secondary === "reset") {
668
697
  sessionMetrics.turns = 0;
698
+ sessionMetrics.usedTools.clear();
669
699
  sessionMetrics.totalInput = 0;
670
700
  sessionMetrics.totalOutput = 0;
671
701
  sessionMetrics.totalCacheRead = 0;
@@ -1081,8 +1111,6 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
1081
1111
  const enabled = value === "on" || value === "true" || value === "1";
1082
1112
  const te = config.token_economy || {
1083
1113
  token_efficient_tools: true,
1084
- redact_thinking: false,
1085
- connector_text_summarization: true,
1086
1114
  };
1087
1115
  te.token_efficient_tools = enabled;
1088
1116
  saveConfig({ token_economy: te });
@@ -1094,25 +1122,35 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
1094
1122
  const enabled = value === "on" || value === "true" || value === "1";
1095
1123
  const te = config.token_economy || {
1096
1124
  token_efficient_tools: true,
1097
- redact_thinking: false,
1098
- connector_text_summarization: true,
1099
1125
  };
1100
1126
  te.redact_thinking = enabled;
1101
1127
  saveConfig({ token_economy: te });
1102
1128
  config.token_economy = te;
1103
1129
  betaLatchState.dirty = true;
1104
1130
  },
1105
- "connector-text": () => {
1131
+ "tool-deferral": () => {
1106
1132
  const enabled = value === "on" || value === "true" || value === "1";
1107
- const te = config.token_economy || {
1108
- token_efficient_tools: true,
1109
- redact_thinking: false,
1110
- connector_text_summarization: true,
1111
- };
1112
- te.connector_text_summarization = enabled;
1113
- saveConfig({ token_economy: te });
1114
- config.token_economy = te;
1115
- betaLatchState.dirty = true;
1133
+ saveConfig({ token_economy_strategies: { tool_deferral: enabled } });
1134
+ if (!config.token_economy_strategies) config.token_economy_strategies = {};
1135
+ config.token_economy_strategies.tool_deferral = enabled;
1136
+ },
1137
+ "tool-compaction": () => {
1138
+ const enabled = value === "on" || value === "true" || value === "1";
1139
+ saveConfig({ token_economy_strategies: { tool_description_compaction: enabled } });
1140
+ if (!config.token_economy_strategies) config.token_economy_strategies = {};
1141
+ config.token_economy_strategies.tool_description_compaction = enabled;
1142
+ },
1143
+ "adaptive-tools": () => {
1144
+ const enabled = value === "on" || value === "true" || value === "1";
1145
+ saveConfig({ token_economy_strategies: { adaptive_tool_set: enabled } });
1146
+ if (!config.token_economy_strategies) config.token_economy_strategies = {};
1147
+ config.token_economy_strategies.adaptive_tool_set = enabled;
1148
+ },
1149
+ "prompt-tailing": () => {
1150
+ const enabled = value === "on" || value === "true" || value === "1";
1151
+ saveConfig({ token_economy_strategies: { system_prompt_tailing: enabled } });
1152
+ if (!config.token_economy_strategies) config.token_economy_strategies = {};
1153
+ config.token_economy_strategies.system_prompt_tailing = enabled;
1116
1154
  },
1117
1155
  };
1118
1156
 
@@ -2703,8 +2741,19 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2703
2741
 
2704
2742
  const _adaptiveOverride = _adaptiveOverrideForRequest;
2705
2743
 
2706
- // Token economy config (resolved once, passed to beta builder)
2707
- const _tokenEconomy = config.token_economy || {};
2744
+ // Token economy config (resolved once, passed to beta builder).
2745
+ // If the server has rejected context-hint this session, reflect it here
2746
+ // so buildAnthropicBetaHeader (called again inside buildRequestHeaders)
2747
+ // drops the beta on subsequent requests.
2748
+ // Also classify the request role (CC's querySource analog) and
2749
+ // suppress context-hint for non-main-thread requests so we don't
2750
+ // opt subagent/title/tool one-shots into a server retry loop.
2751
+ const _requestRole = classifyRequestRole(_parsedBodyOnce);
2752
+ const _baseTE = config.token_economy || {};
2753
+ const _disableCtxHint = contextHintState.disabled || _requestRole !== "main";
2754
+ const _tokenEconomy = _disableCtxHint
2755
+ ? { ..._baseTE, context_hint: false, __requestRole: _requestRole }
2756
+ : { ..._baseTE, __requestRole: _requestRole };
2708
2757
 
2709
2758
  // Microcompact: inject clear betas at high context utilization
2710
2759
  let _microcompactBetas = null;
@@ -2760,6 +2809,12 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2760
2809
  // Merge latched betas that aren't in the current set
2761
2810
  const merged = new Set(currentBetas);
2762
2811
  for (const b of betaLatchState.sent) merged.add(b);
2812
+ // Context-hint kill switch: once server rejected it this session,
2813
+ // stop sending the beta (body field is gated on header, so it drops too).
2814
+ if (contextHintState.disabled) {
2815
+ merged.delete("context-hint-2026-04-09");
2816
+ betaLatchState.sent.delete("context-hint-2026-04-09");
2817
+ }
2763
2818
  computedBetaHeader = [...merged].join(",");
2764
2819
  betaLatchState.lastHeader = computedBetaHeader;
2765
2820
  }
@@ -2785,11 +2840,21 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2785
2840
  cachePolicy: effectiveCachePolicy,
2786
2841
  fastMode: config.fast_mode || false,
2787
2842
  strategy: getEffectiveStrategy(),
2843
+ toolDeferral: config.token_economy_strategies?.tool_deferral,
2844
+ toolDescriptionCompaction: config.token_economy_strategies?.tool_description_compaction,
2845
+ adaptiveToolSet: config.token_economy_strategies?.adaptive_tool_set,
2846
+ systemPromptTailing: config.token_economy_strategies?.system_prompt_tailing,
2847
+ systemPromptTailTurns: config.token_economy_strategies?.system_prompt_tail_turns,
2848
+ systemPromptTailMaxChars: config.token_economy_strategies?.system_prompt_tail_max_chars,
2788
2849
  },
2789
2850
  {
2790
2851
  persistentUserId: signatureUserId,
2791
2852
  sessionId: signatureSessionId,
2792
2853
  accountId: getAccountIdentifier(account),
2854
+ turns: sessionMetrics.turns,
2855
+ usedTools: sessionMetrics.usedTools,
2856
+ tokenEconomySession,
2857
+ requestRole: _requestRole,
2793
2858
  },
2794
2859
  computedBetaHeader,
2795
2860
  config,
@@ -3152,6 +3217,63 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3152
3217
  // Ignore read errors in debug logging path.
3153
3218
  }
3154
3219
 
3220
+ // Context-hint protocol (CC v2.1.110+): detect server rejections and
3221
+ // apply the same disable/compact semantics as real Claude Code.
3222
+ // - 400 w/ "Unexpected value" + "anthropic-beta" → beta unsupported, disable
3223
+ // - 409 / 529 / overloaded → temporary, disable
3224
+ // - 422 / 424 → compact messages (strip
3225
+ // thinking blocks + old tool_result content) and retry ONCE.
3226
+ // Disable is permanent for the session; the beta latch strips it from future
3227
+ // requests so we don't keep triggering the same rejection.
3228
+ if (!contextHintState.disabled) {
3229
+ if (
3230
+ response.status === 400 &&
3231
+ errorBody &&
3232
+ errorBody.includes("Unexpected value") &&
3233
+ errorBody.includes("anthropic-beta") &&
3234
+ errorBody.includes("context-hint")
3235
+ ) {
3236
+ contextHintState.disabled = true;
3237
+ betaLatchState.dirty = true;
3238
+ debugLog("context-hint: beta rejected by server (400), disabling for session");
3239
+ } else if (response.status === 409) {
3240
+ contextHintState.disabled = true;
3241
+ betaLatchState.dirty = true;
3242
+ debugLog("context-hint: 409 conflict, disabling for session");
3243
+ } else if (response.status === 529 && errorBody && errorBody.includes("context_hint")) {
3244
+ contextHintState.disabled = true;
3245
+ betaLatchState.dirty = true;
3246
+ debugLog("context-hint: 529 overloaded referencing hint, disabling for session");
3247
+ } else if (
3248
+ (response.status === 422 || response.status === 424) &&
3249
+ !requestInit._contextHintCompactAttempted
3250
+ ) {
3251
+ try {
3252
+ const hintBody = JSON.parse(requestInit.body);
3253
+ if (Array.isArray(hintBody.messages)) {
3254
+ const compacted = applyContextHintCompaction(hintBody.messages);
3255
+ if (compacted.changed) {
3256
+ hintBody.messages = compacted.messages;
3257
+ requestInit.body = JSON.stringify(hintBody);
3258
+ _parsedBodyOnce = null;
3259
+ requestInit._contextHintCompactAttempted = true;
3260
+ contextHintState.compactionsApplied += 1;
3261
+ attempt--;
3262
+ toast(
3263
+ `⚙ Context hint compaction (${response.status}) — cleared ${compacted.stats.thinkingCleared} thinking / ${compacted.stats.toolResultsCleared} tool results`,
3264
+ "info",
3265
+ { debounceKey: "context-hint-compact" },
3266
+ ).catch(() => {});
3267
+ debugLog("context-hint: applied compaction on status", response.status, compacted.stats);
3268
+ continue;
3269
+ }
3270
+ }
3271
+ } catch {
3272
+ // fall through to normal error handling
3273
+ }
3274
+ }
3275
+ }
3276
+
3155
3277
  // Reactive compaction: on "prompt too long" error, trim oldest messages and retry once
3156
3278
  if (
3157
3279
  response.status === 400 &&
@@ -3908,6 +4030,8 @@ const sessionMetrics = {
3908
4030
  continuations: 0,
3909
4031
  outputHistory: [], // last 5 output token deltas
3910
4032
  },
4033
+ /** Tools used in this session (populated from assistant tool_use blocks in messages) */
4034
+ usedTools: new Set(),
3911
4035
  };
3912
4036
 
3913
4037
  // ---------------------------------------------------------------------------
@@ -5058,17 +5182,25 @@ process.once("beforeExit", _beforeExitHandler);
5058
5182
  // Request building helpers (extracted from original fetch interceptor)
5059
5183
  // ---------------------------------------------------------------------------
5060
5184
 
5061
- const FALLBACK_CLAUDE_CLI_VERSION = "2.1.107";
5185
+ const FALLBACK_CLAUDE_CLI_VERSION = "2.1.114";
5062
5186
  const CLAUDE_CODE_NPM_LATEST_URL = "https://registry.npmjs.org/@anthropic-ai/claude-code/latest";
5063
- const CLAUDE_CODE_BUILD_TIME = "2026-04-14T03:13:25Z";
5187
+ const CLAUDE_CODE_BUILD_TIME = "2026-04-17T22:37:24Z";
5064
5188
 
5065
5189
  // The @anthropic-ai/sdk version bundled with Claude Code.
5066
5190
  // This is distinct from the CLI version and goes in X-Stainless-Package-Version.
5067
- // v2.1.107 switched from @anthropic-ai/sdk v0.208.0 to v0.81.0 (confirmed via proxy capture).
5191
+ // v2.1.107 switched from @anthropic-ai/sdk v0.208.0 to v0.81.0 (confirmed via bundle var x$H="0.81.0").
5192
+ // Still 0.81.0 in v2.1.114 (verified via strings extraction from native Bun binary).
5068
5193
  const ANTHROPIC_SDK_VERSION = "0.81.0";
5069
5194
 
5070
5195
  // Map of CLI version → bundled SDK version (update when CLI version changes)
5071
5196
  const CLI_TO_SDK_VERSION = new Map([
5197
+ ["2.1.114", "0.81.0"],
5198
+ ["2.1.113", "0.81.0"],
5199
+ ["2.1.112", "0.81.0"],
5200
+ ["2.1.111", "0.81.0"],
5201
+ ["2.1.110", "0.81.0"],
5202
+ ["2.1.109", "0.81.0"],
5203
+ ["2.1.108", "0.81.0"],
5072
5204
  ["2.1.107", "0.81.0"],
5073
5205
  ["2.1.105", "0.81.0"],
5074
5206
  ["2.1.97", "0.208.0"],
@@ -5186,6 +5318,334 @@ function computeBillingCacheHash(firstUserMessage, version) {
5186
5318
  * @param {Array} messages — The messages array from the parsed request body
5187
5319
  * @returns {Array} — Repaired messages array
5188
5320
  */
5321
+ /**
5322
+ * Apply context-hint compaction to a message array. Mirrors real CC's
5323
+ * `applyHintEdits` (d85) + `qD4` microcompact: clears thinking/redacted_thinking
5324
+ * blocks from assistant messages and replaces old tool_result content with a
5325
+ * placeholder, keeping the last few tool results intact. Used on 422/424
5326
+ * responses before retrying.
5327
+ *
5328
+ * @param {Array} messages — Parsed messages array
5329
+ * @param {object} [opts]
5330
+ * @param {number} [opts.keepRecentToolResults=8] — How many most-recent tool_result blocks to preserve verbatim
5331
+ * @param {string} [opts.clearedPlaceholder] — Replacement content for older tool_result blocks
5332
+ * @returns {{ messages: Array, changed: boolean, stats: { thinkingCleared: number, toolResultsCleared: number } }}
5333
+ */
5334
+ function applyContextHintCompaction(messages, opts = {}) {
5335
+ if (!Array.isArray(messages) || messages.length === 0) {
5336
+ return { messages, changed: false, stats: { thinkingCleared: 0, toolResultsCleared: 0 } };
5337
+ }
5338
+ const keepRecent = opts.keepRecentToolResults ?? 8;
5339
+ const placeholder = opts.clearedPlaceholder ?? "[Old tool result content cleared]";
5340
+
5341
+ // First pass: count tool_result blocks so we know which are "old" vs "recent".
5342
+ const toolResultRefs = [];
5343
+ for (let i = 0; i < messages.length; i++) {
5344
+ const msg = messages[i];
5345
+ if (msg.role !== "user" || !Array.isArray(msg.content)) continue;
5346
+ for (let j = 0; j < msg.content.length; j++) {
5347
+ if (msg.content[j]?.type === "tool_result") {
5348
+ toolResultRefs.push({ msgIdx: i, blockIdx: j });
5349
+ }
5350
+ }
5351
+ }
5352
+ const oldCutoff = Math.max(0, toolResultRefs.length - keepRecent);
5353
+ const oldSet = new Set(toolResultRefs.slice(0, oldCutoff).map((r) => `${r.msgIdx}:${r.blockIdx}`));
5354
+
5355
+ let thinkingCleared = 0;
5356
+ let toolResultsCleared = 0;
5357
+ const out = messages.map((msg, i) => {
5358
+ if (!Array.isArray(msg.content)) return msg;
5359
+ if (msg.role === "assistant") {
5360
+ const newContent = msg.content.filter((block) => {
5361
+ if (block?.type === "thinking" || block?.type === "redacted_thinking") {
5362
+ thinkingCleared += 1;
5363
+ return false;
5364
+ }
5365
+ return true;
5366
+ });
5367
+ if (newContent.length !== msg.content.length) {
5368
+ return { ...msg, content: newContent };
5369
+ }
5370
+ return msg;
5371
+ }
5372
+ if (msg.role === "user") {
5373
+ let mutated = false;
5374
+ const newContent = msg.content.map((block, j) => {
5375
+ if (block?.type !== "tool_result") return block;
5376
+ const key = `${i}:${j}`;
5377
+ if (!oldSet.has(key)) return block;
5378
+ toolResultsCleared += 1;
5379
+ mutated = true;
5380
+ // Replace content with placeholder, preserve tool_use_id
5381
+ return {
5382
+ ...block,
5383
+ content: placeholder,
5384
+ };
5385
+ });
5386
+ return mutated ? { ...msg, content: newContent } : msg;
5387
+ }
5388
+ return msg;
5389
+ });
5390
+
5391
+ return {
5392
+ messages: out,
5393
+ changed: thinkingCleared > 0 || toolResultsCleared > 0,
5394
+ stats: { thinkingCleared, toolResultsCleared },
5395
+ };
5396
+ }
5397
+
5398
+ /**
5399
+ * TTL-based thinking strip. When the time since the last strip exceeds the
5400
+ * cache TTL (roughly the point at which the prompt prefix cache would expire),
5401
+ * remove all `thinking` / `redacted_thinking` blocks from prior assistant
5402
+ * messages. Mirrors CC's `logThinkingClearLatched("ttl", ...)`.
5403
+ *
5404
+ * We keep the MOST RECENT assistant's thinking intact — chain-of-thought
5405
+ * continuity for the current turn matters; older ones don't.
5406
+ *
5407
+ * @param {Array} messages
5408
+ * @param {{ lastClearMs: number, ttlMs: number, now?: number }} ctx
5409
+ * @returns {{ messages: Array, changed: boolean, cleared: number, ranStripAt: number }}
5410
+ */
5411
+ function applyTtlThinkingStrip(messages, ctx) {
5412
+ const now = ctx.now ?? Date.now();
5413
+ if (!Array.isArray(messages) || messages.length === 0) {
5414
+ return { messages, changed: false, cleared: 0, ranStripAt: ctx.lastClearMs };
5415
+ }
5416
+ if (ctx.lastClearMs > 0 && now - ctx.lastClearMs < ctx.ttlMs) {
5417
+ return { messages, changed: false, cleared: 0, ranStripAt: ctx.lastClearMs };
5418
+ }
5419
+
5420
+ // Find the last assistant message index — preserve its thinking.
5421
+ let lastAsstIdx = -1;
5422
+ for (let i = messages.length - 1; i >= 0; i--) {
5423
+ if (messages[i]?.role === "assistant") {
5424
+ lastAsstIdx = i;
5425
+ break;
5426
+ }
5427
+ }
5428
+
5429
+ let cleared = 0;
5430
+ const out = messages.map((msg, i) => {
5431
+ if (msg.role !== "assistant" || i === lastAsstIdx || !Array.isArray(msg.content)) {
5432
+ return msg;
5433
+ }
5434
+ const newContent = msg.content.filter((b) => {
5435
+ if (b?.type === "thinking" || b?.type === "redacted_thinking") {
5436
+ cleared += 1;
5437
+ return false;
5438
+ }
5439
+ return true;
5440
+ });
5441
+ return newContent.length !== msg.content.length ? { ...msg, content: newContent } : msg;
5442
+ });
5443
+
5444
+ return { messages: out, changed: cleared > 0, cleared, ranStripAt: cleared > 0 ? now : ctx.lastClearMs };
5445
+ }
5446
+
5447
+ /**
5448
+ * Proactive microcompact — client-side, runs BEFORE the request goes out.
5449
+ * At or above `percent` of the model's context window, replace old
5450
+ * tool_result.content with a placeholder (keeping last `keepRecent` verbatim).
5451
+ *
5452
+ * Returns the new messages array + change stats.
5453
+ *
5454
+ * @param {Array} messages
5455
+ * @param {{ estimatedTokens: number, contextWindow: number, percent: number, keepRecent: number }} ctx
5456
+ * @returns {{ messages: Array, changed: boolean, cleared: number, triggered: boolean }}
5457
+ */
5458
+ function applyProactiveMicrocompact(messages, ctx) {
5459
+ const threshold = ctx.contextWindow * (ctx.percent / 100);
5460
+ if (ctx.estimatedTokens < threshold) {
5461
+ return { messages, changed: false, cleared: 0, triggered: false };
5462
+ }
5463
+ const result = applyContextHintCompaction(messages, { keepRecentToolResults: ctx.keepRecent });
5464
+ return {
5465
+ messages: result.messages,
5466
+ changed: result.changed,
5467
+ cleared: result.stats.toolResultsCleared,
5468
+ triggered: true,
5469
+ };
5470
+ }
5471
+
5472
+ /**
5473
+ * Stable tool ordering — sort tools by name so the system-prompt prefix stays
5474
+ * cache-stable across turns. Safe: tool semantics are name-based, not index-based.
5475
+ *
5476
+ * @param {any[]} tools
5477
+ * @returns {any[]}
5478
+ */
5479
+ function applyStableToolOrdering(tools) {
5480
+ if (!Array.isArray(tools) || tools.length < 2) return tools;
5481
+ // Preserve a pinned "first" for tools whose position is load-bearing (none today).
5482
+ return [...tools].sort((a, b) => {
5483
+ const an = typeof a?.name === "string" ? a.name : "";
5484
+ const bn = typeof b?.name === "string" ? b.name : "";
5485
+ return an < bn ? -1 : an > bn ? 1 : 0;
5486
+ });
5487
+ }
5488
+
5489
+ /**
5490
+ * Tool schema deferral — replace the `input_schema` of deferred tools with a
5491
+ * minimal placeholder until the tool has been invoked in this session.
5492
+ *
5493
+ * @param {any[]} tools
5494
+ * @param {{ deferred: Set<string>, invoked: Set<string> }} ctx
5495
+ * @returns {{ tools: any[], deferredCount: number }}
5496
+ */
5497
+ function applyToolSchemaDeferral(tools, ctx) {
5498
+ if (!Array.isArray(tools) || ctx.deferred.size === 0) {
5499
+ return { tools, deferredCount: 0 };
5500
+ }
5501
+ let deferredCount = 0;
5502
+ const out = tools.map((t) => {
5503
+ const name = typeof t?.name === "string" ? t.name : "";
5504
+ if (!ctx.deferred.has(name) || ctx.invoked.has(name)) return t;
5505
+ deferredCount += 1;
5506
+ // Minimal schema — `type:object` with no properties is accepted by the API.
5507
+ return {
5508
+ ...t,
5509
+ input_schema: { type: "object", properties: {}, additionalProperties: true },
5510
+ };
5511
+ });
5512
+ return { tools: out, deferredCount };
5513
+ }
5514
+
5515
+ /**
5516
+ * Adaptive thinking — zero the thinking budget for trivially simple follow-ups.
5517
+ * "Simple" heuristic: most recent user message is short (<200 chars), no file
5518
+ * references, and the conversation is past turn 1 (so we have context).
5519
+ *
5520
+ * @param {any} parsed Parsed request body (mutated in place if simple)
5521
+ * @returns {{ applied: boolean, previousBudget: number | null }}
5522
+ */
5523
+ function applyAdaptiveThinkingZero(parsed) {
5524
+ if (!parsed || !parsed.thinking || parsed.thinking.type !== "enabled") {
5525
+ return { applied: false, previousBudget: null };
5526
+ }
5527
+ const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
5528
+ if (messages.length < 2) return { applied: false, previousBudget: null };
5529
+ const last = messages[messages.length - 1];
5530
+ if (!last || last.role !== "user") return { applied: false, previousBudget: null };
5531
+
5532
+ let userText = "";
5533
+ if (typeof last.content === "string") userText = last.content;
5534
+ else if (Array.isArray(last.content)) {
5535
+ for (const b of last.content) {
5536
+ if (b?.type === "text" && typeof b.text === "string") userText += b.text;
5537
+ if (b?.type === "tool_result") return { applied: false, previousBudget: null };
5538
+ }
5539
+ }
5540
+ if (userText.length > 200) return { applied: false, previousBudget: null };
5541
+ if (/\b(analyze|refactor|design|review|audit|plan)\b/i.test(userText)) {
5542
+ return { applied: false, previousBudget: null };
5543
+ }
5544
+
5545
+ const previousBudget = typeof parsed.thinking.budget_tokens === "number" ? parsed.thinking.budget_tokens : null;
5546
+ // "Zero" means remove thinking entirely — API disallows budget_tokens:0.
5547
+ delete parsed.thinking;
5548
+ if (typeof parsed.temperature !== "number") parsed.temperature = 1;
5549
+ return { applied: true, previousBudget };
5550
+ }
5551
+
5552
+ /**
5553
+ * Cross-turn tool_result dedupe — when the same (tool name, input) pair
5554
+ * appeared earlier in the conversation, replace the later result content
5555
+ * with a pointer string. Safe-set only: Read, Grep, Glob, LS.
5556
+ *
5557
+ * @param {Array} messages
5558
+ * @param {{ seen: Map<string, string>, safeTools: Set<string> }} ctx
5559
+ * @returns {{ messages: Array, changed: boolean, deduped: number }}
5560
+ */
5561
+ function applyToolResultDedupe(messages, ctx) {
5562
+ if (!Array.isArray(messages) || messages.length === 0) {
5563
+ return { messages, changed: false, deduped: 0 };
5564
+ }
5565
+ // First pass: build map of tool_use_id → { name, inputHash } from assistant messages.
5566
+ const idToKey = new Map();
5567
+ for (const msg of messages) {
5568
+ if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
5569
+ for (const b of msg.content) {
5570
+ if (b?.type !== "tool_use") continue;
5571
+ if (!ctx.safeTools.has(b.name)) continue;
5572
+ const inputStr = JSON.stringify(b.input ?? {});
5573
+ idToKey.set(b.id, `${b.name}::${inputStr}`);
5574
+ }
5575
+ }
5576
+
5577
+ let deduped = 0;
5578
+ const out = messages.map((msg) => {
5579
+ if (msg.role !== "user" || !Array.isArray(msg.content)) return msg;
5580
+ let mutated = false;
5581
+ const newContent = msg.content.map((b) => {
5582
+ if (b?.type !== "tool_result") return b;
5583
+ const key = idToKey.get(b.tool_use_id);
5584
+ if (!key) return b;
5585
+ const firstSeen = ctx.seen.get(key);
5586
+ if (firstSeen && firstSeen !== b.tool_use_id) {
5587
+ deduped += 1;
5588
+ mutated = true;
5589
+ return { ...b, content: `[Identical to tool_use_id=${firstSeen}]` };
5590
+ }
5591
+ if (!firstSeen) ctx.seen.set(key, b.tool_use_id);
5592
+ return b;
5593
+ });
5594
+ return mutated ? { ...msg, content: newContent } : msg;
5595
+ });
5596
+
5597
+ return { messages: out, changed: deduped > 0, deduped };
5598
+ }
5599
+
5600
+ /**
5601
+ * Trailing-summary trimmer — strip the final text block of past assistant
5602
+ * messages if it looks like a summary (ends with "...done" / "summary" /
5603
+ * numbered list / "I've X'd Y"). Only applies to messages past the last one.
5604
+ *
5605
+ * @param {Array} messages
5606
+ * @returns {{ messages: Array, changed: boolean, trimmed: number }}
5607
+ */
5608
+ function applyTrailingSummaryTrim(messages) {
5609
+ if (!Array.isArray(messages) || messages.length < 2) {
5610
+ return { messages, changed: false, trimmed: 0 };
5611
+ }
5612
+ const SUMMARY_PATTERNS = [
5613
+ /\b(summary|summar(y|ised|ized)):/i,
5614
+ /\bto summari[sz]e\b/i,
5615
+ /^\s*in (summary|short|brief)/im,
5616
+ /\bi['']ve (done|completed|implemented|added|updated|fixed) /i,
5617
+ /\bthat's it\b/i,
5618
+ ];
5619
+
5620
+ let lastAsstIdx = -1;
5621
+ for (let i = messages.length - 1; i >= 0; i--) {
5622
+ if (messages[i]?.role === "assistant") {
5623
+ lastAsstIdx = i;
5624
+ break;
5625
+ }
5626
+ }
5627
+
5628
+ let trimmed = 0;
5629
+ const out = messages.map((msg, i) => {
5630
+ if (msg.role !== "assistant" || i === lastAsstIdx) return msg;
5631
+ if (!Array.isArray(msg.content)) return msg;
5632
+
5633
+ const last = msg.content[msg.content.length - 1];
5634
+ if (!last || last.type !== "text" || typeof last.text !== "string") return msg;
5635
+ const text = last.text;
5636
+ if (text.length < 80) return msg;
5637
+ const isSummary = SUMMARY_PATTERNS.some((p) => p.test(text));
5638
+ if (!isSummary) return msg;
5639
+
5640
+ trimmed += 1;
5641
+ const newContent = msg.content.slice(0, -1);
5642
+ if (newContent.length === 0) return msg; // keep at least one block
5643
+ return { ...msg, content: newContent };
5644
+ });
5645
+
5646
+ return { messages: out, changed: trimmed > 0, trimmed };
5647
+ }
5648
+
5189
5649
  function repairOrphanedToolUseBlocks(messages) {
5190
5650
  if (!Array.isArray(messages) || messages.length === 0) return messages;
5191
5651
 
@@ -5357,6 +5817,23 @@ const BEDROCK_UNSUPPORTED_BETAS = new Set([
5357
5817
  "context-1m-2025-08-07",
5358
5818
  "tool-search-tool-2025-10-19",
5359
5819
  ]);
5820
+ // OpenCode SDK betas that leak through the host's Anthropic SDK but are NOT
5821
+ // part of CC's beta vocabulary. Filtered out when signature emulation is on.
5822
+ // Core tool names (CC PascalCase) that are always eager-loaded.
5823
+ const CORE_TOOL_NAMES = new Set([
5824
+ "Bash",
5825
+ "Read",
5826
+ "Glob",
5827
+ "Grep",
5828
+ "Edit",
5829
+ "Write",
5830
+ "WebFetch",
5831
+ "TodoWrite",
5832
+ "Skill",
5833
+ "Task",
5834
+ "Compress",
5835
+ ]);
5836
+ const HOST_SDK_BETAS_BLOCKLIST = new Set(["fine-grained-tool-streaming-2025-05-14", "structured-outputs-2025-11-13"]);
5360
5837
  const EXPERIMENTAL_BETA_FLAGS = new Set([
5361
5838
  "adaptive-thinking-2026-01-28",
5362
5839
  "advanced-tool-use-2025-11-20",
@@ -5365,6 +5842,7 @@ const EXPERIMENTAL_BETA_FLAGS = new Set([
5365
5842
  "code-execution-2025-08-25",
5366
5843
  "compact-2026-01-12",
5367
5844
  "context-1m-2025-08-07",
5845
+ "context-hint-2026-04-09",
5368
5846
  "context-management-2025-06-27",
5369
5847
  "fast-mode-2026-02-01",
5370
5848
  "files-api-2025-04-14",
@@ -5380,6 +5858,8 @@ const BETA_SHORTCUTS = new Map([
5380
5858
  ["1m", "context-1m-2025-08-07"],
5381
5859
  ["1m-context", "context-1m-2025-08-07"],
5382
5860
  ["context-1m", "context-1m-2025-08-07"],
5861
+ ["context-hint", "context-hint-2026-04-09"],
5862
+ ["hint", "context-hint-2026-04-09"],
5383
5863
  ["fast", "fast-mode-2026-02-01"],
5384
5864
  ["fast-mode", "fast-mode-2026-02-01"],
5385
5865
  ["opus-fast", "fast-mode-2026-02-01"],
@@ -5693,6 +6173,46 @@ function detectProvider(requestUrl) {
5693
6173
  return "anthropic";
5694
6174
  }
5695
6175
 
6176
+ /**
6177
+ * Classify a request by inferred role, mirroring CC's `querySource` gate.
6178
+ * CC gates features like context-hint on `querySource.startsWith("repl_main_thread")`.
6179
+ * We don't have that string on the wire, so we infer from body shape.
6180
+ *
6181
+ * Returns one of:
6182
+ * - "main" → interactive main thread (long system, normal max_tokens, messages present)
6183
+ * - "title" → title / name generation (tiny max_tokens, 1 message)
6184
+ * - "small" → short background query (small max_tokens but not title)
6185
+ * - "empty" → pre-warm / no messages
6186
+ * - "unknown" → treat as main for safety
6187
+ *
6188
+ * @param {any} parsed Parsed request body
6189
+ * @returns {"main"|"title"|"small"|"empty"|"unknown"}
6190
+ */
6191
+ function classifyRequestRole(parsed) {
6192
+ if (!parsed || typeof parsed !== "object") return "unknown";
6193
+ const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
6194
+ const maxTokens = typeof parsed.max_tokens === "number" ? parsed.max_tokens : null;
6195
+
6196
+ if (messages.length === 0) return "empty";
6197
+ if (maxTokens != null) {
6198
+ if (maxTokens <= 256 && messages.length <= 2) return "title";
6199
+ if (maxTokens <= 1024) return "small";
6200
+ }
6201
+ // System prompt length heuristic
6202
+ let sysLen = 0;
6203
+ if (typeof parsed.system === "string") {
6204
+ sysLen = parsed.system.length;
6205
+ } else if (Array.isArray(parsed.system)) {
6206
+ for (const s of parsed.system) {
6207
+ if (s && typeof s.text === "string") sysLen += s.text.length;
6208
+ }
6209
+ }
6210
+ if (sysLen < 200 && messages.length <= 2 && (maxTokens == null || maxTokens <= 2048)) {
6211
+ return "small";
6212
+ }
6213
+ return "main";
6214
+ }
6215
+
5696
6216
  /**
5697
6217
  * @param {any} body
5698
6218
  * @returns {{model: string, tools: any[], messages: any[], hasFileReferences: boolean}}
@@ -5883,6 +6403,55 @@ function sanitizeSystemText(text) {
5883
6403
  * @param {'minimal' | 'off'} mode
5884
6404
  * @returns {string}
5885
6405
  */
6406
+ function tailSystemBlock(text, maxChars, turnThreshold) {
6407
+ const lines = text.split("\n");
6408
+ const kept = [];
6409
+ let charCount = 0;
6410
+ const importantRe = /\b(MUST|NEVER|CRITICAL|IMPORTANT|REQUIRED|DO NOT|ALWAYS|FORBIDDEN)\b/i;
6411
+ const headerRe = /^#{1,4}\s/;
6412
+ const listItemRe = /^\s*[-*]\s/;
6413
+ // Always keep the first paragraph (identity/role definition)
6414
+ let firstParaEnd = 0;
6415
+ for (let j = 0; j < lines.length; j++) {
6416
+ if (lines[j].trim() === "" && j > 0) {
6417
+ firstParaEnd = j;
6418
+ break;
6419
+ }
6420
+ }
6421
+ if (firstParaEnd === 0) firstParaEnd = Math.min(5, lines.length);
6422
+ for (let j = 0; j <= firstParaEnd; j++) {
6423
+ kept.push(lines[j]);
6424
+ charCount += (lines[j]?.length || 0) + 1;
6425
+ }
6426
+ // Scan remaining lines: keep headers, important constraints, short list items
6427
+ for (let j = firstParaEnd + 1; j < lines.length; j++) {
6428
+ const line = lines[j];
6429
+ const isHeader = headerRe.test(line);
6430
+ const isImportant = importantRe.test(line);
6431
+ const isShortListItem = listItemRe.test(line) && line.length < 120;
6432
+ if (isHeader || isImportant || isShortListItem) {
6433
+ if (charCount + line.length + 1 > maxChars) break;
6434
+ kept.push(line);
6435
+ charCount += line.length + 1;
6436
+ }
6437
+ }
6438
+ kept.push("", "[Verbose instructions trimmed after turn " + turnThreshold + ". Key constraints preserved above.]");
6439
+ return kept.join("\n");
6440
+ }
6441
+
6442
+ function compactToolDescription(text) {
6443
+ return text
6444
+ .replace(/<example[\s\S]*?<\/example>/gi, "")
6445
+ .replace(/\|[\s|:-]+\|/g, "")
6446
+ .replace(/^\|.*\|$/gm, "")
6447
+ .replace(/^(?:\s*[-*]\s+.{200,})$/gm, (m) => m.slice(0, 200) + "...")
6448
+ .replace(/^(#{1,3}\s+)/gm, "")
6449
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
6450
+ .replace(/`([^`]+)`/g, "$1")
6451
+ .replace(/\n{3,}/g, "\n\n")
6452
+ .trim();
6453
+ }
6454
+
5886
6455
  function compactSystemText(text, mode) {
5887
6456
  const withoutDuplicateIdentityPrefix = text.startsWith(`${CLAUDE_CODE_IDENTITY_STRING}\n`)
5888
6457
  ? text.slice(CLAUDE_CODE_IDENTITY_STRING.length).trimStart()
@@ -6231,6 +6800,21 @@ function buildSystemPromptBlocks(system, signature) {
6231
6800
  return sanitized;
6232
6801
  }
6233
6802
 
6803
+ // Lean system prompt for non-main requests (title-gen is already handled
6804
+ // above via the COMPACT_TITLE_GENERATOR_SYSTEM_PROMPT swap). For "title"
6805
+ // and "small" request roles — one-off queries that don't belong to the main
6806
+ // REPL thread — we skip billing identity + CC identity injection. This
6807
+ // matches the spirit of real CC's querySource gates: identity context is
6808
+ // for interactive main-thread conversations, not fire-and-forget calls.
6809
+ // Opt-in (default off) because it changes the system-prompt shape.
6810
+ const leanNonMain =
6811
+ signature.leanNonMain === true &&
6812
+ (signature.requestRole === "title" || signature.requestRole === "small") &&
6813
+ !titleGeneratorRequest;
6814
+ if (leanNonMain) {
6815
+ return sanitized;
6816
+ }
6817
+
6234
6818
  // Build attribution header
6235
6819
  const billingHeader = buildAnthropicBillingHeader(
6236
6820
  signature.claudeCliVersion,
@@ -6386,6 +6970,31 @@ function buildAnthropicBetaHeader(
6386
6970
  betas.push("web-search-2025-03-05");
6387
6971
  }
6388
6972
 
6973
+ // Advisor tool — in CC this is gated by server-side feature flag
6974
+ // (tengu_sage_compass2) and firstParty+isLoggedIn. Since we can't check
6975
+ // CC's feature flags, include it unconditionally for Claude 4+ models.
6976
+ // CC v108 sends it in MITM captures for Max/Pro users.
6977
+ if (!/claude-3-/i.test(model)) {
6978
+ betas.push("advisor-tool-2026-03-01");
6979
+ }
6980
+
6981
+ // context-hint-2026-04-09 — introduced in CC v2.1.110. Paired with the body
6982
+ // field `context_hint: { enabled: true }` (injected in transformRequestBody).
6983
+ // Real CC gates it on includeFirstPartyBetas + querySource startsWith
6984
+ // "repl_main_thread" (i.e. interactive sessions on 1P Anthropic only).
6985
+ // Server responds with hints suggesting which messages to forget for
6986
+ // token-efficient context compaction. Sticky: disabled permanently on
6987
+ // 400/409/529 errors referencing the hint. Users can opt out via
6988
+ // token_economy.context_hint = false.
6989
+ const isFirstPartyProvider = provider !== "vertex" && provider !== "bedrock" && provider !== "mantle";
6990
+ // Mimicry note: real CC gates context-hint on querySource.startsWith("repl_main_thread").
6991
+ // We infer main-thread from body shape via classifyRequestRole and pass it via
6992
+ // tokenEconomy.__requestRole. Treat absent marker as "main" for backward compat.
6993
+ const _isMainThread = te.__requestRole == null || te.__requestRole === "main";
6994
+ if (isFirstPartyProvider && !/claude-3-/i.test(model) && te.context_hint !== false && _isMainThread) {
6995
+ betas.push("context-hint-2026-04-09");
6996
+ }
6997
+
6389
6998
  // Files API — scoped to file endpoints/references
6390
6999
  if (isFilesEndpoint || hasFileReferences) {
6391
7000
  betas.push("files-api-2025-04-14");
@@ -6396,16 +7005,15 @@ function buildAnthropicBetaHeader(
6396
7005
  betas.push(TOKEN_COUNTING_BETA_FLAG);
6397
7006
  }
6398
7007
 
6399
- // === TOKEN ECONOMY BETAS (config-controlled) ===
7008
+ // === TOKEN ECONOMY BETAS (on by default for token savings) ===
6400
7009
 
6401
7010
  // redact-thinking: suppresses thinking summaries server-side.
6402
- // When enabled, API returns redacted_thinking blocks instead of summaries.
6403
- // Saves bandwidth and tokens for users who don't inspect thinking.
7011
+ // CC v108 enables this by default but we keep it off so thinking is visible.
7012
+ // Users can opt in via `/anthropic set redact-thinking on`.
6404
7013
  if (te.redact_thinking && !disableExperimentalBetas) {
6405
7014
  betas.push("redact-thinking-2026-02-12");
6406
7015
  }
6407
7016
 
6408
- // summarize-connector-text-2026-03-13 was removed in v2.1.90 (dead slot njq="").
6409
7017
  // compact-2026-01-12 and mcp-client-2025-11-20 exist only in docs, not runtime.
6410
7018
 
6411
7019
  // afk-mode — NOT auto-included (requires user opt-in)
@@ -6418,8 +7026,11 @@ function buildAnthropicBetaHeader(
6418
7026
  }
6419
7027
  }
6420
7028
 
6421
- // Merge incoming betas from the original request
6422
- let mergedBetas = [...new Set([...betas, ...incomingBetasList])];
7029
+ // Merge incoming betas from the original request, filtering out host-injected
7030
+ // betas (e.g. fine-grained-tool-streaming-2025-05-14, structured-outputs-2025-11-13)
7031
+ // that OpenCode's Anthropic SDK adds but real Claude Code never sends.
7032
+ const filteredIncoming = incomingBetasList.filter((b) => !HOST_SDK_BETAS_BLOCKLIST.has(b));
7033
+ let mergedBetas = [...new Set([...betas, ...filteredIncoming])];
6423
7034
 
6424
7035
  // Add custom betas from config
6425
7036
  if (customBetas?.length) {
@@ -6713,7 +7324,7 @@ function resolveMaxTokens(body, config) {
6713
7324
  *
6714
7325
  * @param {string | undefined} body
6715
7326
  * @param {{enabled: boolean, claudeCliVersion: string, promptCompactionMode: 'minimal' | 'off', provider?: string}} signature
6716
- * @param {{persistentUserId: string, sessionId: string, accountId: string}} runtime
7327
+ * @param {{persistentUserId: string, sessionId: string, accountId: string, turns?: number, usedTools?: Set<string>}} runtime
6717
7328
  * @param {string} [betaHeader] - Pre-computed anthropic-beta header value to inject into the body.
6718
7329
  * @param {import('./lib/config.mjs').AnthropicAuthConfig} [config] - Plugin configuration for output cap
6719
7330
  * @returns {string | undefined}
@@ -6805,14 +7416,127 @@ function transformRequestBody(body, signature, runtime, betaHeader, config) {
6805
7416
  parsed.messages = stripSlashCommandMessages(parsed.messages);
6806
7417
  }
6807
7418
 
7419
+ // === Token economy: layered message/history compaction ===
7420
+ // Only applies to main-thread requests (subagents/title-gen stay untouched).
7421
+ // Strategies stack in order: TTL thinking strip → proactive microcompact →
7422
+ // trailing-summary trim → tool_result dedupe. Each is independently gated.
7423
+ const te = config?.token_economy || {};
7424
+ const tes = runtime?.tokenEconomySession;
7425
+ const isMainRole = runtime?.requestRole === "main" || runtime?.requestRole == null;
7426
+
7427
+ if (isMainRole && Array.isArray(parsed.messages) && tes) {
7428
+ // (1) TTL-based thinking strip
7429
+ if (te.ttl_thinking_strip !== false) {
7430
+ const ttlMs = signature?.cachePolicy?.ttl === "5m" ? 5 * 60_000 : 60 * 60_000;
7431
+ const res = applyTtlThinkingStrip(parsed.messages, {
7432
+ lastClearMs: tes.lastThinkingStripMs,
7433
+ ttlMs,
7434
+ });
7435
+ if (res.changed) {
7436
+ parsed.messages = res.messages;
7437
+ tes.lastThinkingStripMs = res.ranStripAt;
7438
+ tes.thinkingStripped += res.cleared;
7439
+ }
7440
+ }
7441
+
7442
+ // (2) Proactive microcompact (client-side, pre-422)
7443
+ if (te.proactive_microcompact !== false) {
7444
+ const estimated = estimatePromptTokensFromParsed(parsed);
7445
+ const cw = 200_000; // conservative — 1M models still benefit
7446
+ const res = applyProactiveMicrocompact(parsed.messages, {
7447
+ estimatedTokens: estimated,
7448
+ contextWindow: cw,
7449
+ percent: te.microcompact_percent ?? 70,
7450
+ keepRecent: te.microcompact_keep_recent ?? 8,
7451
+ });
7452
+ if (res.changed) {
7453
+ parsed.messages = res.messages;
7454
+ tes.lastMicrocompactMs = Date.now();
7455
+ tes.toolResultsCompacted += res.cleared;
7456
+ }
7457
+ }
7458
+
7459
+ // (3) Trailing-summary trim (opt-in)
7460
+ if (te.trailing_summary_trim === true) {
7461
+ const res = applyTrailingSummaryTrim(parsed.messages);
7462
+ if (res.changed) parsed.messages = res.messages;
7463
+ }
7464
+
7465
+ // (4) Cross-turn tool_result dedupe (opt-in)
7466
+ if (te.tool_result_dedupe === true) {
7467
+ const SAFE_READ_TOOLS = new Set(["Read", "Grep", "Glob", "LS", "BashOutput"]);
7468
+ const res = applyToolResultDedupe(parsed.messages, {
7469
+ seen: tes.seenContentHashes,
7470
+ safeTools: SAFE_READ_TOOLS,
7471
+ });
7472
+ if (res.changed) parsed.messages = res.messages;
7473
+ }
7474
+ }
7475
+
7476
+ // === Token economy: tool-array transforms (stable ordering, deferral) ===
7477
+ if (isMainRole && Array.isArray(parsed.tools)) {
7478
+ if (te.stable_tool_ordering !== false) {
7479
+ parsed.tools = applyStableToolOrdering(parsed.tools);
7480
+ }
7481
+ if (Array.isArray(te.deferred_tool_names) && te.deferred_tool_names.length > 0) {
7482
+ // "Invoked" means any assistant message in the convo has used the tool.
7483
+ const invoked = new Set();
7484
+ if (Array.isArray(parsed.messages)) {
7485
+ for (const m of parsed.messages) {
7486
+ if (m.role !== "assistant" || !Array.isArray(m.content)) continue;
7487
+ for (const b of m.content) {
7488
+ if (b?.type === "tool_use" && typeof b.name === "string") invoked.add(b.name);
7489
+ }
7490
+ }
7491
+ }
7492
+ const res = applyToolSchemaDeferral(parsed.tools, {
7493
+ deferred: new Set(te.deferred_tool_names),
7494
+ invoked,
7495
+ });
7496
+ parsed.tools = res.tools;
7497
+ }
7498
+ }
7499
+
7500
+ // === Token economy: adaptive thinking zero-out for trivial follow-ups ===
7501
+ if (isMainRole && te.adaptive_thinking_zero_simple !== false) {
7502
+ applyAdaptiveThinkingZero(parsed);
7503
+ }
7504
+
6808
7505
  // QA fix H2: avoid mutating the signature parameter; capture modelId locally
6809
7506
  const modelId = parsed.model || "";
6810
7507
  // Extract first user message text for billing hash computation (cch)
6811
7508
  const firstUserMessage = extractFirstUserMessageText(parsed.messages);
6812
- const signatureWithModel = { ...signature, modelId, firstUserMessage, antiVerbosity: config?.anti_verbosity };
7509
+ const signatureWithModel = {
7510
+ ...signature,
7511
+ modelId,
7512
+ firstUserMessage,
7513
+ antiVerbosity: config?.anti_verbosity,
7514
+ // Role-aware system-prompt leaning: for non-main-thread requests (title,
7515
+ // small, empty shapes) strip billing identity + CC identity injection.
7516
+ // Title-gen path is handled separately by isTitleGeneratorSystemBlocks().
7517
+ // Default off — opt-in via `token_economy.lean_system_non_main: true`.
7518
+ requestRole: runtime?.requestRole,
7519
+ leanNonMain: config?.token_economy?.lean_system_non_main === true,
7520
+ };
6813
7521
  // Sanitize system prompt and optionally inject Claude Code identity/billing blocks.
6814
7522
  parsed.system = buildSystemPromptBlocks(normalizeSystemTextBlocks(parsed.system), signatureWithModel);
6815
7523
 
7524
+ // Strategy 5 — System prompt tailing: after N turns, trim large system blocks
7525
+ // to essential sections only. The model has internalized verbose instructions
7526
+ // (shell strategy, package manager tables, delegation protocols) by this point.
7527
+ // Preserves: first paragraph (identity/role), lines containing MUST/NEVER/CRITICAL/
7528
+ // IMPORTANT, section headers, and short blocks. Drops verbose body paragraphs.
7529
+ const tailThreshold = signature.systemPromptTailTurns ?? 6;
7530
+ if (signature.systemPromptTailing !== false && runtime.turns >= tailThreshold && Array.isArray(parsed.system)) {
7531
+ const maxChars = signature.systemPromptTailMaxChars ?? 2000;
7532
+ for (let i = 0; i < parsed.system.length; i++) {
7533
+ const block = parsed.system[i];
7534
+ if (block.type === "text" && block.text && block.text.length > maxChars * 2) {
7535
+ block.text = tailSystemBlock(block.text, maxChars, tailThreshold);
7536
+ }
7537
+ }
7538
+ }
7539
+
6816
7540
  // Token budget (A9): parse NL budget from last user message, inject status block
6817
7541
  if (config?.token_budget?.enabled && Array.isArray(parsed.messages)) {
6818
7542
  const budgetExpr = parseNaturalLanguageBudget(parsed.messages);
@@ -6861,13 +7585,24 @@ function transformRequestBody(body, signature, runtime, betaHeader, config) {
6861
7585
  ) {
6862
7586
  // Strip ALL incoming cache_control from tools and messages to prevent
6863
7587
  // TTL ordering violations (host SDK may set ttl=5m which conflicts with
6864
- // our system prompt ttl=1h). Then add our own 1h to the last user message
7588
+ // our system prompt ttl=1h). Then add our own to the last user message
6865
7589
  // (matching real CC behavior seen in proxy capture).
6866
- const ccTtl = signature.cachePolicy?.ttl || "1h";
6867
- if (Array.isArray(parsed.tools)) {
7590
+ //
7591
+ // Role-scoped TTL: real CC's MoY(querySource) gates the `ttl:"1h"` field
7592
+ // on an allowlist of query sources (`repl_main_thread*`, `sdk`,
7593
+ // `auto_mode`). Non-matching requests fall back to the default 5m tier
7594
+ // — which is cheaper to write. We match this via classifyRequestRole
7595
+ // (main → configured TTL, usually 1h; everything else → 5m).
7596
+ const configuredTtl = signature.cachePolicy?.ttl || "1h";
7597
+ const roleScopedTtl = config?.token_economy?.role_scoped_cache_ttl !== false;
7598
+ const isMainForCache = runtime?.requestRole === "main" || runtime?.requestRole == null;
7599
+ const ccTtl = roleScopedTtl && !isMainForCache ? "5m" : configuredTtl;
7600
+ if (Array.isArray(parsed.tools) && parsed.tools.length > 0) {
6868
7601
  for (const tool of parsed.tools) {
6869
7602
  if (tool.cache_control) delete tool.cache_control;
6870
7603
  }
7604
+ // Add cache_control to last tool as prompt-cache breakpoint (CC does this)
7605
+ parsed.tools[parsed.tools.length - 1].cache_control = { type: "ephemeral", ttl: ccTtl };
6871
7606
  }
6872
7607
  if (Array.isArray(parsed.messages)) {
6873
7608
  for (const msg of parsed.messages) {
@@ -6925,6 +7660,69 @@ function transformRequestBody(body, signature, runtime, betaHeader, config) {
6925
7660
  }
6926
7661
  }
6927
7662
  }
7663
+ // Track which tools the model has used (from assistant tool_use blocks).
7664
+ // Names are already CC PascalCase after renaming above.
7665
+ if (Array.isArray(parsed.messages)) {
7666
+ for (const msg of parsed.messages) {
7667
+ if (msg.role === "assistant" && Array.isArray(msg.content)) {
7668
+ for (const block of msg.content) {
7669
+ if (block.type === "tool_use" && block.name) {
7670
+ runtime.usedTools.add(block.name);
7671
+ }
7672
+ }
7673
+ }
7674
+ }
7675
+ }
7676
+
7677
+ // Strategy 4 — Adaptive tool set: after turn 3, defer non-core tools that
7678
+ // the model hasn't used yet. This saves schema bytes on turns where tools
7679
+ // are unlikely to be needed. Core tools stay eager. Resets on /clear.
7680
+ if (
7681
+ Array.isArray(parsed.tools) &&
7682
+ signature.adaptiveToolSet !== false &&
7683
+ runtime.turns >= 3 &&
7684
+ parsed.model &&
7685
+ !/claude-3-|haiku/i.test(parsed.model)
7686
+ ) {
7687
+ const used = runtime.usedTools;
7688
+ for (const tool of parsed.tools) {
7689
+ if (tool.name && !used.has(tool.name) && !CORE_TOOL_NAMES.has(tool.name)) {
7690
+ tool.defer_loading = true;
7691
+ }
7692
+ }
7693
+ }
7694
+
7695
+ // Tool description compaction: apply the same compaction logic used for
7696
+ // system prompts (strip examples, collapse whitespace, dedup lines) to tool
7697
+ // descriptions. The top 4 tools (Bash 10.6K, TodoWrite 9.7K, Task 5.5K,
7698
+ // Compress 4.5K) account for 30KB. Compaction typically saves 30-50%.
7699
+ if (Array.isArray(parsed.tools) && signature.toolDescriptionCompaction !== false) {
7700
+ for (const tool of parsed.tools) {
7701
+ if (tool.description && tool.description.length > 500) {
7702
+ tool.description = compactToolDescription(tool.description);
7703
+ }
7704
+ }
7705
+ }
7706
+
7707
+ // MCP tool deferral: mark non-core tools with defer_loading: true.
7708
+ // CC defers all MCP tools by default — the API omits their full schemas from token
7709
+ // counting, only sending the tool name. When the model needs a deferred tool, it uses
7710
+ // tool_reference to load the schema on demand. This saves ~20KB per turn.
7711
+ // Core tools (OC_TO_CC_TOOL_NAMES) are always eager-loaded.
7712
+ if (
7713
+ Array.isArray(parsed.tools) &&
7714
+ signature.toolDeferral !== false &&
7715
+ parsed.model &&
7716
+ !/claude-3-|haiku/i.test(parsed.model)
7717
+ ) {
7718
+ const coreToolNames = new Set(Object.values(OC_TO_CC_TOOL_NAMES));
7719
+ for (const tool of parsed.tools) {
7720
+ if (tool.name && !coreToolNames.has(tool.name)) {
7721
+ tool.defer_loading = true;
7722
+ }
7723
+ }
7724
+ }
7725
+
6928
7726
  // Task budgets: when the task-budgets beta is active, preserve or inject output_config.
6929
7727
  // The beta unlocks output_config.max_output_tokens for per-task budget control.
6930
7728
  // Model-router compatibility: the beta header + output_config body are forwarded as-is.
@@ -6936,10 +7734,48 @@ function transformRequestBody(body, signature, runtime, betaHeader, config) {
6936
7734
  }
6937
7735
  }
6938
7736
 
7737
+ // Context hint (CC v2.1.110+): pair the context-hint-2026-04-09 beta with
7738
+ // the body field context_hint:{enabled:true}. Extracted from CC bundle
7739
+ // buildRequestParams(): `{betaHeader, body:{context_hint:{enabled:!0}}}`.
7740
+ // When active, the server responds with optional hints suggesting which
7741
+ // messages to drop for token-efficient context compaction.
7742
+ if (betaHeader && betaHeader.includes("context-hint-2026-04-09") && !parsed.context_hint) {
7743
+ parsed.context_hint = { enabled: true };
7744
+ }
7745
+
6939
7746
  // Fast mode: inject speed parameter for Opus 4.6 only (v2.1.97 restriction).
6940
7747
  // Real CC v2.1.97 xJ() checks: model.includes("opus-4-6") — Sonnet is NOT eligible.
6941
7748
  const fastModeEnabled = signature.fastMode && !isFalsyEnv(process.env.OPENCODE_ANTHROPIC_DISABLE_FAST_MODE);
6942
- if (fastModeEnabled && parsed.model && isOpus46Model(parsed.model)) {
7749
+ let fastModeAutoApplied = false;
7750
+ if (
7751
+ !fastModeEnabled &&
7752
+ te.fast_mode_auto === true &&
7753
+ isMainRole &&
7754
+ parsed.model &&
7755
+ isOpus46Model(parsed.model) &&
7756
+ Array.isArray(parsed.messages) &&
7757
+ parsed.messages.length >= 2
7758
+ ) {
7759
+ // Simple exchange heuristic: last user message is short, no tool_result,
7760
+ // no file references. Suggests a follow-up question that doesn't need
7761
+ // deep reasoning.
7762
+ const last = parsed.messages[parsed.messages.length - 1];
7763
+ if (last && last.role === "user") {
7764
+ let txt = "";
7765
+ let hasToolResult = false;
7766
+ if (typeof last.content === "string") txt = last.content;
7767
+ else if (Array.isArray(last.content)) {
7768
+ for (const b of last.content) {
7769
+ if (b?.type === "tool_result") hasToolResult = true;
7770
+ if (b?.type === "text" && typeof b.text === "string") txt += b.text;
7771
+ }
7772
+ }
7773
+ if (!hasToolResult && txt.length < 240 && !/\bfile:|\.md\b|\.mjs\b|\.ts\b/i.test(txt)) {
7774
+ fastModeAutoApplied = true;
7775
+ }
7776
+ }
7777
+ }
7778
+ if ((fastModeEnabled || fastModeAutoApplied) && parsed.model && isOpus46Model(parsed.model)) {
6943
7779
  parsed.speed = "fast";
6944
7780
  }
6945
7781
 
@@ -7019,6 +7855,22 @@ function transformRequestUrl(input) {
7019
7855
  }
7020
7856
  }
7021
7857
 
7858
+ // MITM proxy redirect: rewrite host/port/protocol when OPENCODE_MITM_BASE_URL is set.
7859
+ // This allows capturing the exact over-the-wire request for conformance testing.
7860
+ // Example: OPENCODE_MITM_BASE_URL=http://localhost:9999
7861
+ const mitmBase = process.env.OPENCODE_MITM_BASE_URL;
7862
+ if (mitmBase && requestUrl) {
7863
+ try {
7864
+ const mitmUrl = new URL(mitmBase);
7865
+ requestUrl.protocol = mitmUrl.protocol;
7866
+ requestUrl.hostname = mitmUrl.hostname;
7867
+ requestUrl.port = mitmUrl.port;
7868
+ requestInput = input instanceof Request ? new Request(requestUrl.toString(), input) : requestUrl;
7869
+ } catch {
7870
+ // Invalid MITM URL — ignore silently
7871
+ }
7872
+ }
7873
+
7022
7874
  return { requestInput, requestUrl };
7023
7875
  }
7024
7876
 
@@ -7189,10 +8041,10 @@ function stripMcpPrefixFromParsedEvent(parsed) {
7189
8041
 
7190
8042
  let modified = false;
7191
8043
 
7192
- // content_block_start: { content_block: { type: "tool_use", name: "..." } }
8044
+ // content_block_start: { content_block: { type: "tool_use"|"tool_reference", name: "..." } }
7193
8045
  if (
7194
8046
  parsed.content_block &&
7195
- parsed.content_block.type === "tool_use" &&
8047
+ (parsed.content_block.type === "tool_use" || parsed.content_block.type === "tool_reference") &&
7196
8048
  typeof parsed.content_block.name === "string"
7197
8049
  ) {
7198
8050
  const mapped = reverseMapToolName(parsed.content_block.name);
@@ -7202,10 +8054,10 @@ function stripMcpPrefixFromParsedEvent(parsed) {
7202
8054
  }
7203
8055
  }
7204
8056
 
7205
- // message_start: { message: { content: [{ type: "tool_use", name: "..." }] } }
8057
+ // message_start: { message: { content: [{ type: "tool_use"|"tool_reference", name: "..." }] } }
7206
8058
  if (parsed.message && Array.isArray(parsed.message.content)) {
7207
8059
  for (const block of parsed.message.content) {
7208
- if (block.type === "tool_use" && typeof block.name === "string") {
8060
+ if ((block.type === "tool_use" || block.type === "tool_reference") && typeof block.name === "string") {
7209
8061
  const mapped = reverseMapToolName(block.name);
7210
8062
  if (mapped !== block.name) {
7211
8063
  block.name = mapped;
@@ -7218,7 +8070,7 @@ function stripMcpPrefixFromParsedEvent(parsed) {
7218
8070
  // Top-level content array (non-streaming responses forwarded through SSE)
7219
8071
  if (Array.isArray(parsed.content)) {
7220
8072
  for (const block of parsed.content) {
7221
- if (block.type === "tool_use" && typeof block.name === "string") {
8073
+ if ((block.type === "tool_use" || block.type === "tool_reference") && typeof block.name === "string") {
7222
8074
  const mapped = reverseMapToolName(block.name);
7223
8075
  if (mapped !== block.name) {
7224
8076
  block.name = mapped;
@@ -7674,9 +8526,12 @@ function extractFileIds(body) {
7674
8526
  AnthropicAuthPlugin.__testing__ = {
7675
8527
  sanitizeSystemText,
7676
8528
  compactSystemText,
8529
+ compactToolDescription,
7677
8530
  dedupeSystemBlocks,
7678
8531
  normalizeSystemTextBlocks,
7679
8532
  buildSystemPromptBlocks,
8533
+ stripMcpPrefixFromParsedEvent,
8534
+ CORE_TOOL_NAMES,
7680
8535
  get cachedCCPrompt() {
7681
8536
  return cachedCCPrompt;
7682
8537
  },
package/lib/config.mjs CHANGED
@@ -190,12 +190,78 @@ export const DEFAULT_CONFIG = {
190
190
  * This flag is retained for config backward compatibility but has no effect. */
191
191
  token_efficient_tools: false,
192
192
  /** Enable redact-thinking-2026-02-12 beta (suppresses thinking summaries
193
- * server-side for lower bandwidth). Off by default u2014 set to true if you
194
- * don't need to inspect thinking blocks. */
193
+ * server-side for lower bandwidth). Off by default so thinking stays
194
+ * visible. Opt in via `/anthropic set redact-thinking on`. */
195
195
  redact_thinking: false,
196
- /** [DEPRECATED] summarize-connector-text-2026-03-13 was removed in CC v2.1.90.
197
- * This flag is retained for config backward compatibility but has no effect. */
198
- connector_text_summarization: false,
196
+ /** Enable context-hint-2026-04-09 beta (CC v2.1.110+). When on, the server
197
+ * MAY reject 422/424 and trigger client-side compaction retries.
198
+ * Server-side rollout is partial — many accounts 400-reject the beta.
199
+ * Off by default; the plugin still mimics CC's error-handling semantics
200
+ * when a user opts in. Even when on, the beta is only sent for requests
201
+ * classified as "main-thread" (see classifyRequestRole). */
202
+ context_hint: false,
203
+
204
+ /** TTL-based thinking strip (CC parity). When the time since the last
205
+ * thinking strip exceeds the cache TTL, drop all `thinking` /
206
+ * `redacted_thinking` blocks from prior assistant messages. Mirrors CC's
207
+ * `logThinkingClearLatched("ttl", ...)` behavior. Zero quality impact
208
+ * because stale thinking blocks don't influence the next response. */
209
+ ttl_thinking_strip: true,
210
+
211
+ /** Proactive client-side microcompact. At or above `microcompact_percent`
212
+ * of the model context window, replace old tool_result content with a
213
+ * placeholder (keeping the last `microcompact_keep_recent` verbatim).
214
+ * Runs BEFORE the request hits the server — preempts 422s. */
215
+ proactive_microcompact: true,
216
+ microcompact_percent: 70,
217
+ microcompact_keep_recent: 8,
218
+
219
+ /** Stable tool ordering — sort tools deterministically across turns so
220
+ * the system-prompt prefix stays cache-stable. Safe and cheap. */
221
+ stable_tool_ordering: true,
222
+
223
+ /** Tool schema deferral — ship minimal (name+description only) schemas
224
+ * for tools below the defer threshold until they're actually invoked in
225
+ * the session. Accepts a list of tool names to defer. Empty = feature off. */
226
+ deferred_tool_names: [],
227
+
228
+ /** Adaptive thinking — zero the thinking budget for simple follow-up
229
+ * user turns (short user message, no file refs, no tool_use in prior
230
+ * assistant turn). Reduces thinking tokens on trivial exchanges. */
231
+ adaptive_thinking_zero_simple: true,
232
+
233
+ /** Cross-turn tool_result dedupe — when the same (tool_use_name + input)
234
+ * appears twice, replace the later result content with a pointer to the
235
+ * earlier tool_use_id. Opt-in; can surprise models that expected a fresh
236
+ * read. Applies only to read-only tools in the safe set. */
237
+ tool_result_dedupe: false,
238
+
239
+ /** Fast-mode auto-opt-in — detect simple exchanges (short user msg, no
240
+ * tools likely needed) and emit speed:fast for that turn. Opt-in. */
241
+ fast_mode_auto: false,
242
+
243
+ /** Trailing-summary trimmer — detect trailing summary paragraphs in past
244
+ * assistant text blocks and strip them from the outgoing messages array.
245
+ * Opt-in; can hide context the model relied on. */
246
+ trailing_summary_trim: false,
247
+
248
+ /** Role-scoped cache TTL (CC parity). Real CC's MoY(querySource) gates
249
+ * `ttl:"1h"` on an allowlist of query sources (`repl_main_thread*`, `sdk`,
250
+ * `auto_mode`). Everything else falls back to the 5m tier, which is
251
+ * cheaper to write. Matches by classifying request role: main → 1h (or
252
+ * whatever signature.cachePolicy.ttl is configured as), else → 5m. */
253
+ role_scoped_cache_ttl: true,
254
+
255
+ /** Lean system prompt for non-main requests. For title-gen / small /
256
+ * subagent-shaped requests, strip billing identity + CC identity
257
+ * injection. Saves ~1-2kB per request. Opt-in: changes the system-prompt
258
+ * shape that downstream observers (billing/telemetry) may rely on, so
259
+ * enable deliberately rather than by default. */
260
+ lean_system_non_main: false,
261
+
262
+ // NOTE: identical-tool-call short-circuit was considered but requires SSE
263
+ // response-stream rewriting that breaks normal tool-execution semantics.
264
+ // Tracked as future work — not exposed as a config flag here.
199
265
  },
200
266
  /** Output cap: default max_tokens to save context window. */
201
267
  output_cap: {
@@ -577,10 +643,56 @@ function validateConfig(raw) {
577
643
  : DEFAULT_CONFIG.token_economy.token_efficient_tools,
578
644
  redact_thinking:
579
645
  typeof te.redact_thinking === "boolean" ? te.redact_thinking : DEFAULT_CONFIG.token_economy.redact_thinking,
580
- connector_text_summarization:
581
- typeof te.connector_text_summarization === "boolean"
582
- ? te.connector_text_summarization
583
- : DEFAULT_CONFIG.token_economy.connector_text_summarization,
646
+ context_hint: typeof te.context_hint === "boolean" ? te.context_hint : DEFAULT_CONFIG.token_economy.context_hint,
647
+ ttl_thinking_strip:
648
+ typeof te.ttl_thinking_strip === "boolean"
649
+ ? te.ttl_thinking_strip
650
+ : DEFAULT_CONFIG.token_economy.ttl_thinking_strip,
651
+ proactive_microcompact:
652
+ typeof te.proactive_microcompact === "boolean"
653
+ ? te.proactive_microcompact
654
+ : DEFAULT_CONFIG.token_economy.proactive_microcompact,
655
+ microcompact_percent: clampNumber(
656
+ te.microcompact_percent,
657
+ 30,
658
+ 95,
659
+ DEFAULT_CONFIG.token_economy.microcompact_percent,
660
+ ),
661
+ microcompact_keep_recent: clampNumber(
662
+ te.microcompact_keep_recent,
663
+ 1,
664
+ 64,
665
+ DEFAULT_CONFIG.token_economy.microcompact_keep_recent,
666
+ ),
667
+ stable_tool_ordering:
668
+ typeof te.stable_tool_ordering === "boolean"
669
+ ? te.stable_tool_ordering
670
+ : DEFAULT_CONFIG.token_economy.stable_tool_ordering,
671
+ deferred_tool_names: Array.isArray(te.deferred_tool_names)
672
+ ? te.deferred_tool_names.filter((n) => typeof n === "string")
673
+ : DEFAULT_CONFIG.token_economy.deferred_tool_names,
674
+ adaptive_thinking_zero_simple:
675
+ typeof te.adaptive_thinking_zero_simple === "boolean"
676
+ ? te.adaptive_thinking_zero_simple
677
+ : DEFAULT_CONFIG.token_economy.adaptive_thinking_zero_simple,
678
+ tool_result_dedupe:
679
+ typeof te.tool_result_dedupe === "boolean"
680
+ ? te.tool_result_dedupe
681
+ : DEFAULT_CONFIG.token_economy.tool_result_dedupe,
682
+ fast_mode_auto:
683
+ typeof te.fast_mode_auto === "boolean" ? te.fast_mode_auto : DEFAULT_CONFIG.token_economy.fast_mode_auto,
684
+ trailing_summary_trim:
685
+ typeof te.trailing_summary_trim === "boolean"
686
+ ? te.trailing_summary_trim
687
+ : DEFAULT_CONFIG.token_economy.trailing_summary_trim,
688
+ role_scoped_cache_ttl:
689
+ typeof te.role_scoped_cache_ttl === "boolean"
690
+ ? te.role_scoped_cache_ttl
691
+ : DEFAULT_CONFIG.token_economy.role_scoped_cache_ttl,
692
+ lean_system_non_main:
693
+ typeof te.lean_system_non_main === "boolean"
694
+ ? te.lean_system_non_main
695
+ : DEFAULT_CONFIG.token_economy.lean_system_non_main,
584
696
  };
585
697
  }
586
698
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "0.1.8",
3
+ "version": "0.1.11",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "main": "./index.mjs",
6
6
  "files": [