opencode-anthropic-fix 0.1.15 → 0.1.19

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/index.mjs CHANGED
@@ -7,6 +7,7 @@ import xxhashInit from "xxhash-wasm";
7
7
  import { AccountManager } from "./lib/accounts.mjs";
8
8
  import { authorize as oauthAuthorize, exchange as oauthExchange, refreshToken } from "./lib/oauth.mjs";
9
9
  import { loadConfig, loadConfigFresh, saveConfig, CLIENT_ID, getConfigDir } from "./lib/config.mjs";
10
+ import { loadContextHintDisabledFlag, saveContextHintDisabledFlag } from "./lib/context-hint-persist.mjs";
10
11
  import { loadAccounts, saveAccounts, clearAccounts, createDefaultStats } from "./lib/storage.mjs";
11
12
  import { applyOAuthCredentials, resetAccountTracking } from "./lib/account-state.mjs";
12
13
  import { acquireRefreshLock, releaseRefreshLock } from "./lib/refresh-lock.mjs";
@@ -192,12 +193,25 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
192
193
  // - 529 / overloaded → disable for session (temporary overload)
193
194
  // When disabled, we strip context-hint from betas + body on subsequent requests so
194
195
  // we don't keep triggering the same rejection and churning the cache.
196
+ // Persisted disable: if a prior session saw a 400 rejecting the context-hint
197
+ // beta (account lacks access), skip the beta from turn 1 of every subsequent
198
+ // session. Delete ~/.config/opencode/context-hint-disabled.flag (or the
199
+ // %APPDATA%\opencode equivalent on Windows) to re-enable once access is
200
+ // granted. See lib/context-hint-persist.mjs.
201
+ const _persistedCtxHint = loadContextHintDisabledFlag();
195
202
  const contextHintState = {
196
203
  /** Permanently disabled for this session after a server rejection. */
197
- disabled: false,
204
+ disabled: _persistedCtxHint.disabled === true,
198
205
  /** Number of 422/424 compactions applied this session (for telemetry). */
199
206
  compactionsApplied: 0,
200
207
  };
208
+ if (contextHintState.disabled) {
209
+ debugLog(
210
+ "context-hint: loaded persisted disable flag",
211
+ _persistedCtxHint.status ? `status=${_persistedCtxHint.status}` : "",
212
+ _persistedCtxHint.timestamp ? `ts=${new Date(_persistedCtxHint.timestamp).toISOString()}` : "",
213
+ );
214
+ }
201
215
 
202
216
  // Token economy — session state for layered compaction strategies.
203
217
  const tokenEconomySession = {
@@ -2364,7 +2378,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2364
2378
  if (
2365
2379
  config.override_model_limits.enabled &&
2366
2380
  !isTruthyEnv(process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT) &&
2367
- (hasOneMillionContext(model.id) || isOpus46Model(model.id))
2381
+ (hasOneMillionContext(model.id) || isOpus46Model(model.id) || isOpus47Model(model.id))
2368
2382
  ) {
2369
2383
  model.limit = {
2370
2384
  ...(model.limit ?? {}),
@@ -2882,11 +2896,36 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2882
2896
  }
2883
2897
  }
2884
2898
 
2885
- // Opt-in: dump outgoing request bodies to disk so the user can
2886
- // diff them across turns and see what's invalidating the cache.
2899
+ // Build headers with the selected account's token
2900
+ const requestHeaders = buildRequestHeaders(
2901
+ input,
2902
+ requestInit,
2903
+ accessToken,
2904
+ body,
2905
+ requestUrl,
2906
+ {
2907
+ enabled: getSignatureEmulationEnabled(),
2908
+ claudeCliVersion,
2909
+ customBetas: config.custom_betas,
2910
+ strategy: getEffectiveStrategy(),
2911
+ sessionId: signatureSessionId,
2912
+ },
2913
+ _adaptiveOverride,
2914
+ _tokenEconomy,
2915
+ );
2916
+ // cch stays as the static "00000" placeholder — cc-107 and cc-108
2917
+ // JS bundles both emit `cch=00000;` unconditionally in the billing
2918
+ // header. The Bun-binary Attestation.zig xxHash64 mechanism lives in
2919
+ // a SEPARATE header path, not in this body field. Re-hashing here
2920
+ // mutates system[0] each turn, invalidating the prompt cache.
2921
+ const finalBody = body;
2922
+
2923
+ // Opt-in: dump the OUTGOING body (post-cch) so diagnostics reflect
2924
+ // exactly what went on the wire. Previously dumped `body` which
2925
+ // still had the cch=00000 placeholder — that confused debugging.
2887
2926
  // Rotates at 10 files to cap disk usage. Files live under
2888
2927
  // ~/.opencode/opencode-anthropic-fix/request-dumps/.
2889
- if (config.token_economy?.debug_dump_bodies === true && typeof body === "string") {
2928
+ if (config.token_economy?.debug_dump_bodies === true && typeof finalBody === "string") {
2890
2929
  try {
2891
2930
  const fs = await import("node:fs");
2892
2931
  const path = await import("node:path");
@@ -2902,32 +2941,12 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2902
2941
  fs.unlinkSync(path.join(dir, existing.shift()));
2903
2942
  }
2904
2943
  const ts = new Date().toISOString().replace(/[:.]/g, "-");
2905
- fs.writeFileSync(path.join(dir, `req-${ts}.json`), body);
2944
+ fs.writeFileSync(path.join(dir, `req-${ts}.json`), finalBody);
2906
2945
  } catch {
2907
2946
  // Disk full, permissions, whatever — never block the request.
2908
2947
  }
2909
2948
  }
2910
2949
 
2911
- // Build headers with the selected account's token
2912
- const requestHeaders = buildRequestHeaders(
2913
- input,
2914
- requestInit,
2915
- accessToken,
2916
- body,
2917
- requestUrl,
2918
- {
2919
- enabled: getSignatureEmulationEnabled(),
2920
- claudeCliVersion,
2921
- customBetas: config.custom_betas,
2922
- strategy: getEffectiveStrategy(),
2923
- sessionId: signatureSessionId,
2924
- },
2925
- _adaptiveOverride,
2926
- _tokenEconomy,
2927
- );
2928
- // v2.1.107: cch attestation via xxHash64(body, seed) & 0xFFFFF
2929
- const finalBody = await computeAndReplaceCCH(body);
2930
-
2931
2950
  // Execute the request
2932
2951
  let response;
2933
2952
  try {
@@ -3261,15 +3280,31 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3261
3280
  ) {
3262
3281
  contextHintState.disabled = true;
3263
3282
  betaLatchState.dirty = true;
3264
- debugLog("context-hint: beta rejected by server (400), disabling for session");
3283
+ // Persist across sessions: a 400 "Unexpected value" on the
3284
+ // context-hint beta means the account lacks access to the
3285
+ // beta entirely. Re-attempting next session would burn
3286
+ // another turn to the same rejection.
3287
+ saveContextHintDisabledFlag({
3288
+ reason: "beta_unsupported_400",
3289
+ status: 400,
3290
+ });
3291
+ debugLog("context-hint: beta rejected by server (400), disabling + persisting");
3292
+ // Retry without the beta. The latch above drops the header
3293
+ // and the body field on the rebuilt request.
3294
+ attempt--;
3295
+ continue;
3265
3296
  } else if (response.status === 409) {
3266
3297
  contextHintState.disabled = true;
3267
3298
  betaLatchState.dirty = true;
3268
3299
  debugLog("context-hint: 409 conflict, disabling for session");
3300
+ attempt--;
3301
+ continue;
3269
3302
  } else if (response.status === 529 && errorBody && errorBody.includes("context_hint")) {
3270
3303
  contextHintState.disabled = true;
3271
3304
  betaLatchState.dirty = true;
3272
3305
  debugLog("context-hint: 529 overloaded referencing hint, disabling for session");
3306
+ attempt--;
3307
+ continue;
3273
3308
  } else if (
3274
3309
  (response.status === 422 || response.status === 424) &&
3275
3310
  !requestInit._contextHintCompactAttempted
@@ -4004,6 +4039,11 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
4004
4039
  }
4005
4040
 
4006
4041
  output.context.push(contextParts.join("\n"));
4042
+
4043
+ // TODO(B3): wire rolling-summarizer once per-session message capture exists
4044
+ // (requires chat.messages.transform accumulator). Module exists at
4045
+ // lib/rolling-summarizer.mjs — call summarize(messages, {haikuCall}) here
4046
+ // behind config.token_economy.rolling_summarizer when messages are available.
4007
4047
  },
4008
4048
  };
4009
4049
  }
@@ -4016,49 +4056,59 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
4016
4056
  /** Module-level config ref for functions outside AnthropicAuthPlugin closure. */
4017
4057
  let _pluginConfig = null;
4018
4058
 
4019
- const sessionMetrics = {
4020
- turns: 0,
4021
- totalInput: 0,
4022
- totalOutput: 0,
4023
- totalCacheRead: 0,
4024
- totalCacheWrite: 0,
4025
- totalWebSearchRequests: 0,
4026
- recentCacheRates: [], // rolling window of last 5 turns
4027
- sessionCostUsd: 0,
4028
- costBreakdown: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
4029
- sessionStartTime: Date.now(),
4030
- lastQuota: {
4031
- tokens: 0,
4032
- requests: 0,
4033
- inputTokens: 0,
4034
- updatedAt: 0,
4035
- // Window-based unified headers from response
4036
- fiveHour: { utilization: 0, resets_at: null, status: null, surpassedThreshold: null },
4037
- sevenDay: { utilization: 0, resets_at: null, status: null, surpassedThreshold: null },
4038
- // Overall/fallback/overage from response headers
4039
- overallStatus: null,
4040
- representativeClaim: null,
4041
- fallback: null,
4042
- fallbackPercentage: null,
4043
- overageStatus: null,
4044
- overageReason: null,
4045
- // Usage endpoint polling (A6)
4046
- lastPollAt: 0,
4047
- },
4048
- lastStopReason: null, // tracks most recent stop_reason for output cap escalation
4049
- perModel: {}, // Map<modelId, { input, output, cacheRead, cacheWrite, costUsd, turns }>
4050
- lastModelId: null,
4051
- lastRequestBody: null, // Last intercepted request body (JSON string, capped 2MB) for /anthropic context
4052
- /** Token budget tracking (A9) */
4053
- tokenBudget: {
4054
- limit: 0, // 0 = unset
4055
- used: 0, // accumulated output tokens
4056
- continuations: 0,
4057
- outputHistory: [], // last 5 output token deltas
4058
- },
4059
- /** Tools used in this session (populated from assistant tool_use blocks in messages) */
4060
- usedTools: new Set(),
4061
- };
4059
+ /**
4060
+ * Factory for the initial sessionMetrics shape. Returns a fresh object each
4061
+ * call so the reset helper (and any future test hook) doesn't alias nested
4062
+ * state (lastQuota, perModel, costBreakdown, tokenBudget, usedTools Set).
4063
+ * Keep this in sync with the type annotation above.
4064
+ */
4065
+ function createInitialSessionMetrics() {
4066
+ return {
4067
+ turns: 0,
4068
+ totalInput: 0,
4069
+ totalOutput: 0,
4070
+ totalCacheRead: 0,
4071
+ totalCacheWrite: 0,
4072
+ totalWebSearchRequests: 0,
4073
+ recentCacheRates: [], // rolling window of last 5 turns
4074
+ sessionCostUsd: 0,
4075
+ costBreakdown: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
4076
+ sessionStartTime: Date.now(),
4077
+ lastQuota: {
4078
+ tokens: 0,
4079
+ requests: 0,
4080
+ inputTokens: 0,
4081
+ updatedAt: 0,
4082
+ // Window-based unified headers from response
4083
+ fiveHour: { utilization: 0, resets_at: null, status: null, surpassedThreshold: null },
4084
+ sevenDay: { utilization: 0, resets_at: null, status: null, surpassedThreshold: null },
4085
+ // Overall/fallback/overage from response headers
4086
+ overallStatus: null,
4087
+ representativeClaim: null,
4088
+ fallback: null,
4089
+ fallbackPercentage: null,
4090
+ overageStatus: null,
4091
+ overageReason: null,
4092
+ // Usage endpoint polling (A6)
4093
+ lastPollAt: 0,
4094
+ },
4095
+ lastStopReason: null, // tracks most recent stop_reason for output cap escalation
4096
+ perModel: {}, // Map<modelId, { input, output, cacheRead, cacheWrite, costUsd, turns }>
4097
+ lastModelId: null,
4098
+ lastRequestBody: null, // Last intercepted request body (JSON string, capped 2MB) for /anthropic context
4099
+ /** Token budget tracking (A9) */
4100
+ tokenBudget: {
4101
+ limit: 0, // 0 = unset
4102
+ used: 0, // accumulated output tokens
4103
+ continuations: 0,
4104
+ outputHistory: [], // last 5 output token deltas
4105
+ },
4106
+ /** Tools used in this session (populated from assistant tool_use blocks in messages) */
4107
+ usedTools: new Set(),
4108
+ };
4109
+ }
4110
+
4111
+ const sessionMetrics = createInitialSessionMetrics();
4062
4112
 
4063
4113
  // ---------------------------------------------------------------------------
4064
4114
  // Adaptive 1M context state
@@ -5445,6 +5495,174 @@ function applyContextHintCompaction(messages, opts = {}) {
5445
5495
  };
5446
5496
  }
5447
5497
 
5498
+ /**
5499
+ * Tools whose output is trivially reproducible by re-running with the same
5500
+ * arguments. Stateful tools (bash, edit, write, etc.) never dedupe — their
5501
+ * outputs may reflect non-idempotent side effects that the transcript needs
5502
+ * to preserve.
5503
+ */
5504
+ const REPRODUCIBLE_TOOL_NAMES = new Set(["read", "grep", "glob", "ls", "list", "find"]);
5505
+
5506
+ /**
5507
+ * Title-case a tool name for the stub string ("read" → "Read", "grep" → "Grep").
5508
+ * Pure; no locale.
5509
+ */
5510
+ function titleCaseToolName(name) {
5511
+ if (typeof name !== "string" || name.length === 0) return "";
5512
+ return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase();
5513
+ }
5514
+
5515
+ /**
5516
+ * Minimal deterministic stable-stringify for args canonicalization.
5517
+ * Sorts object keys at every depth before serialization so that
5518
+ * `{a:1,b:2}` and `{b:2,a:1}` produce the same string.
5519
+ *
5520
+ * Arrays are traversed in order. Non-plain objects (Date, Map, etc.) fall
5521
+ * through to `JSON.stringify` — we don't expect them in tool args, but the
5522
+ * fallback keeps the function total.
5523
+ */
5524
+ function stableStringifyForDedupe(value) {
5525
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
5526
+ if (Array.isArray(value)) {
5527
+ return "[" + value.map((v) => stableStringifyForDedupe(v)).join(",") + "]";
5528
+ }
5529
+ const keys = Object.keys(value).sort();
5530
+ const parts = [];
5531
+ for (const k of keys) {
5532
+ parts.push(JSON.stringify(k) + ":" + stableStringifyForDedupe(value[k]));
5533
+ }
5534
+ return "{" + parts.join(",") + "}";
5535
+ }
5536
+
5537
+ /**
5538
+ * Session-wide tool-result dedupe. Pure over `messages`.
5539
+ *
5540
+ * Walks the conversation once to collect every `tool_use` + `tool_result`
5541
+ * pair where the tool is in `REPRODUCIBLE_TOOL_NAMES` (case-insensitive).
5542
+ * Groups them by `(toolName, stableStringify(args))`. For every group
5543
+ * containing more than one call, keeps the LATEST result verbatim and
5544
+ * replaces each earlier result's `content` with a stub:
5545
+ *
5546
+ * `[<ToolTitleCase> of <argsKey> superseded by later read at msg #<N>]`
5547
+ *
5548
+ * where `N` is the message index of the latest call's user-message.
5549
+ *
5550
+ * `tool_use_id` and any other `tool_result` fields are preserved. Assistant
5551
+ * `tool_use` blocks are NEVER modified — only the paired user `tool_result`.
5552
+ *
5553
+ * Cache-stable: decision is a pure function of message history, so rerunning
5554
+ * over an unchanged prefix yields byte-identical output.
5555
+ *
5556
+ * @param {Array} messages — Parsed messages array
5557
+ * @returns {{ messages: Array, changed: boolean, stats: { deduped: number } }}
5558
+ */
5559
+ function applySessionToolResultDedupe(messages) {
5560
+ if (!Array.isArray(messages) || messages.length === 0) {
5561
+ return { messages, changed: false, stats: { deduped: 0 } };
5562
+ }
5563
+
5564
+ // Pass 1: build tool_use_id → { name, argsKey } for reproducible tools.
5565
+ const idToMeta = new Map();
5566
+ for (let i = 0; i < messages.length; i++) {
5567
+ const msg = messages[i];
5568
+ if (msg?.role !== "assistant" || !Array.isArray(msg.content)) continue;
5569
+ for (const block of msg.content) {
5570
+ if (block?.type !== "tool_use") continue;
5571
+ const name = typeof block.name === "string" ? block.name : "";
5572
+ if (!REPRODUCIBLE_TOOL_NAMES.has(name.toLowerCase())) continue;
5573
+ const argsKey = stableStringifyForDedupe(block.input ?? {});
5574
+ idToMeta.set(block.id, { name, argsKey });
5575
+ }
5576
+ }
5577
+
5578
+ // Pass 2: collect tool_result locations by group key, preserving order.
5579
+ /** @type {Map<string, Array<{msgIdx: number, blockIdx: number, toolUseId: string, name: string, argsKey: string}>>} */
5580
+ const groups = new Map();
5581
+ for (let i = 0; i < messages.length; i++) {
5582
+ const msg = messages[i];
5583
+ if (msg?.role !== "user" || !Array.isArray(msg.content)) continue;
5584
+ for (let j = 0; j < msg.content.length; j++) {
5585
+ const block = msg.content[j];
5586
+ if (block?.type !== "tool_result") continue;
5587
+ const meta = idToMeta.get(block.tool_use_id);
5588
+ if (!meta) continue;
5589
+ const groupKey = meta.name.toLowerCase() + "\u0000" + meta.argsKey;
5590
+ let arr = groups.get(groupKey);
5591
+ if (!arr) {
5592
+ arr = [];
5593
+ groups.set(groupKey, arr);
5594
+ }
5595
+ arr.push({
5596
+ msgIdx: i,
5597
+ blockIdx: j,
5598
+ toolUseId: block.tool_use_id,
5599
+ name: meta.name,
5600
+ argsKey: meta.argsKey,
5601
+ });
5602
+ }
5603
+ }
5604
+
5605
+ // Build supersede map: "msgIdx:blockIdx" → stub string.
5606
+ const supersedeStubs = new Map();
5607
+ let deduped = 0;
5608
+ // Deterministic iteration: sort group entries by group key before processing.
5609
+ const sortedEntries = Array.from(groups.entries()).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
5610
+ for (const [, occurrences] of sortedEntries) {
5611
+ if (occurrences.length < 2) continue;
5612
+ const latest = occurrences[occurrences.length - 1];
5613
+ const stub =
5614
+ "[" +
5615
+ titleCaseToolName(latest.name) +
5616
+ " of " +
5617
+ latest.argsKey +
5618
+ " superseded by later read at msg #" +
5619
+ latest.msgIdx +
5620
+ "]";
5621
+ for (let k = 0; k < occurrences.length - 1; k++) {
5622
+ const occ = occurrences[k];
5623
+ supersedeStubs.set(occ.msgIdx + ":" + occ.blockIdx, stub);
5624
+ deduped += 1;
5625
+ }
5626
+ }
5627
+
5628
+ if (deduped === 0) {
5629
+ return { messages, changed: false, stats: { deduped: 0 } };
5630
+ }
5631
+
5632
+ // Pass 3: rewrite. Preserve tool_use_id and other fields.
5633
+ const out = messages.map((msg, i) => {
5634
+ if (msg?.role !== "user" || !Array.isArray(msg.content)) return msg;
5635
+ let mutated = false;
5636
+ const newContent = msg.content.map((block, j) => {
5637
+ if (block?.type !== "tool_result") return block;
5638
+ const stub = supersedeStubs.get(i + ":" + j);
5639
+ if (!stub) return block;
5640
+ mutated = true;
5641
+ return { ...block, content: stub };
5642
+ });
5643
+ return mutated ? { ...msg, content: newContent } : msg;
5644
+ });
5645
+
5646
+ return { messages: out, changed: true, stats: { deduped } };
5647
+ }
5648
+
5649
+ /**
5650
+ * Dispatch wrapper: only apply `applySessionToolResultDedupe` when the
5651
+ * `token_economy_strategies.tool_result_dedupe_session_wide` flag is true.
5652
+ * When disabled, returns the input `messages` by identity (not a copy) so
5653
+ * callers can cheaply detect "no-op" with reference equality.
5654
+ *
5655
+ * @param {Array} messages
5656
+ * @param {object} [config]
5657
+ * @returns {Array}
5658
+ */
5659
+ function maybeApplySessionToolResultDedupe(messages, config) {
5660
+ const flag = config?.token_economy_strategies?.tool_result_dedupe_session_wide;
5661
+ if (flag !== true) return messages;
5662
+ const result = applySessionToolResultDedupe(messages);
5663
+ return result.messages;
5664
+ }
5665
+
5448
5666
  /**
5449
5667
  * TTL-based thinking strip. When the time since the last strip exceeds the
5450
5668
  * cache TTL (roughly the point at which the prompt prefix cache would expire),
@@ -6138,6 +6356,16 @@ function isOpus46Model(model) {
6138
6356
  return /claude-opus-4[._-]6|opus[._-]4[._-]6/i.test(model);
6139
6357
  }
6140
6358
 
6359
+ /**
6360
+ * Detects claude-opus-4.7 / claude-opus-4-7 model IDs.
6361
+ * @param {string | undefined} model
6362
+ * @returns {boolean}
6363
+ */
6364
+ function isOpus47Model(model) {
6365
+ if (!model) return false;
6366
+ return /claude-opus-4[._-]7|opus[._-]4[._-]7/i.test(model);
6367
+ }
6368
+
6141
6369
  /**
6142
6370
  * Detects claude-sonnet-4.6 / claude-sonnet-4-6 model IDs.
6143
6371
  * @param {string | undefined} body
@@ -6150,12 +6378,12 @@ function isSonnet46Model(model) {
6150
6378
 
6151
6379
  /**
6152
6380
  * Detects models that support adaptive thinking ({type: "adaptive"}).
6153
- * Currently: Opus 4.6 and Sonnet 4.6.
6381
+ * Currently: Opus 4.6, Opus 4.7, and Sonnet 4.6.
6154
6382
  * @param {string | undefined} body
6155
6383
  * @returns {boolean}
6156
6384
  */
6157
6385
  function isAdaptiveThinkingModel(model) {
6158
- return isOpus46Model(model) || isSonnet46Model(model);
6386
+ return isOpus46Model(model) || isOpus47Model(model) || isSonnet46Model(model);
6159
6387
  }
6160
6388
 
6161
6389
  /**
@@ -6169,8 +6397,9 @@ function isEligibleFor1MContext(model) {
6169
6397
  if (!model) return false;
6170
6398
  // Explicit 1m suffix/tag in model name
6171
6399
  if (/(^|[-_ ])1m($|[-_ ])|context[-_]?1m|\[1m\]/i.test(model)) return true;
6172
- // CC v2.1.97 U01: claude-sonnet-4* (any Sonnet 4.x) or opus-4-6
6173
- return /claude-sonnet-4|sonnet[._-]4/i.test(model) || isOpus46Model(model);
6400
+ // CC v2.1.97 U01: claude-sonnet-4* (any Sonnet 4.x) or opus-4-6.
6401
+ // Opus 4.7 (successor to 4.6) is also 1M-context eligible.
6402
+ return /claude-sonnet-4|sonnet[._-]4/i.test(model) || isOpus46Model(model) || isOpus47Model(model);
6174
6403
  }
6175
6404
 
6176
6405
  /**
@@ -6835,8 +7064,12 @@ function buildSystemPromptBlocks(system, signature) {
6835
7064
  }
6836
7065
 
6837
7066
  // Anti-verbosity injection (CC v2.1.100 quiet_salted_ember equivalent).
6838
- // Only for Opus 4.6 and non-title-generator requests.
6839
- if (!titleGeneratorRequest && signature.modelId && isOpus46Model(signature.modelId)) {
7067
+ // Applies to Opus 4.6 / 4.7 for non-title-generator requests.
7068
+ if (
7069
+ !titleGeneratorRequest &&
7070
+ signature.modelId &&
7071
+ (isOpus46Model(signature.modelId) || isOpus47Model(signature.modelId))
7072
+ ) {
6840
7073
  const avConfig = signature.antiVerbosity;
6841
7074
  if (avConfig?.enabled !== false) {
6842
7075
  sanitized.push({ type: "text", text: ANTI_VERBOSITY_SYSTEM_PROMPT });
@@ -7499,6 +7732,15 @@ function transformRequestBody(body, signature, runtime, betaHeader, config) {
7499
7732
  }
7500
7733
  }
7501
7734
 
7735
+ // (1b) Session-wide reproducible-tool result dedupe (Phase C C3, opt-in)
7736
+ // Pure over message history; runs before microcompact so dedup'd stubs
7737
+ // are visible to downstream size estimation. Gated by
7738
+ // token_economy_strategies.tool_result_dedupe_session_wide (default off).
7739
+ if (config?.token_economy_strategies?.tool_result_dedupe_session_wide === true) {
7740
+ const res = applySessionToolResultDedupe(parsed.messages);
7741
+ if (res.changed) parsed.messages = res.messages;
7742
+ }
7743
+
7502
7744
  // (2) Proactive microcompact (client-side, pre-422)
7503
7745
  if (te.proactive_microcompact !== false) {
7504
7746
  const estimated = estimatePromptTokensFromParsed(parsed);
@@ -7587,7 +7829,7 @@ function transformRequestBody(body, signature, runtime, betaHeader, config) {
7587
7829
  // Preserves: first paragraph (identity/role), lines containing MUST/NEVER/CRITICAL/
7588
7830
  // IMPORTANT, section headers, and short blocks. Drops verbose body paragraphs.
7589
7831
  const tailThreshold = signature.systemPromptTailTurns ?? 6;
7590
- if (signature.systemPromptTailing !== false && runtime.turns >= tailThreshold && Array.isArray(parsed.system)) {
7832
+ if (signature.systemPromptTailing === true && runtime.turns >= tailThreshold && Array.isArray(parsed.system)) {
7591
7833
  const maxChars = signature.systemPromptTailMaxChars ?? 2000;
7592
7834
  for (let i = 0; i < parsed.system.length; i++) {
7593
7835
  const block = parsed.system[i];
@@ -7805,6 +8047,10 @@ function transformRequestBody(body, signature, runtime, betaHeader, config) {
7805
8047
 
7806
8048
  // Fast mode: inject speed parameter for Opus 4.6 only (v2.1.97 restriction).
7807
8049
  // Real CC v2.1.97 xJ() checks: model.includes("opus-4-6") — Sonnet is NOT eligible.
8050
+ // NOTE: Deliberately NOT extended to Opus 4.7. xJ() is a fingerprint-sensitive
8051
+ // check tied to the exact real-CC version we mirror; blindly enabling
8052
+ // speed:"fast" for 4.7 could diverge from real CC's behavior for that model.
8053
+ // Revisit once a real-CC dump confirms xJ() matches opus-4-7.
7808
8054
  const fastModeEnabled = signature.fastMode && !isFalsyEnv(process.env.OPENCODE_ANTHROPIC_DISABLE_FAST_MODE);
7809
8055
  let fastModeAutoApplied = false;
7810
8056
  if (
@@ -8592,6 +8838,11 @@ AnthropicAuthPlugin.__testing__ = {
8592
8838
  buildSystemPromptBlocks,
8593
8839
  stripMcpPrefixFromParsedEvent,
8594
8840
  CORE_TOOL_NAMES,
8841
+ // exposed for determinism regression tests (phase C1)
8842
+ applyContextHintCompaction,
8843
+ // exposed for session-dedupe regression tests (phase C3)
8844
+ applySessionToolResultDedupe,
8845
+ maybeApplySessionToolResultDedupe,
8595
8846
  get cachedCCPrompt() {
8596
8847
  return cachedCCPrompt;
8597
8848
  },
@@ -8600,6 +8851,24 @@ AnthropicAuthPlugin.__testing__ = {
8600
8851
  },
8601
8852
  SUBAGENT_CC_ANCHOR,
8602
8853
  CLAUDE_CODE_IDENTITY_STRING,
8854
+ /** Test-only: drive the session turn counter so code paths gated on
8855
+ * `sessionMetrics.turns >= N` can be exercised without a real SSE stream. */
8856
+ setSessionTurnsForTest(n) {
8857
+ sessionMetrics.turns = n;
8858
+ },
8859
+ /** Test-only: reset session metrics between tests.
8860
+ * Uses createInitialSessionMetrics() so every tracked field — including
8861
+ * nested objects (lastQuota, perModel, costBreakdown, tokenBudget) and the
8862
+ * usedTools Set — is restored to its initial value. Mutates the existing
8863
+ * sessionMetrics object in place because many module-level references
8864
+ * close over it. */
8865
+ resetSessionMetricsForTest() {
8866
+ const fresh = createInitialSessionMetrics();
8867
+ for (const key of Object.keys(sessionMetrics)) {
8868
+ delete sessionMetrics[key];
8869
+ }
8870
+ Object.assign(sessionMetrics, fresh);
8871
+ },
8603
8872
  };
8604
8873
 
8605
8874
  export default AnthropicAuthPlugin;
package/lib/config.mjs CHANGED
@@ -195,11 +195,13 @@ export const DEFAULT_CONFIG = {
195
195
  redact_thinking: false,
196
196
  /** Enable context-hint-2026-04-09 beta (CC v2.1.110+). When on, the server
197
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,
198
+ * Default ON (Phase C2). Server-side gating + contextHintState.disabled
199
+ * latching means compatible servers use it and incompatible ones fall
200
+ * back cleanly via the 400/409/529 error paths. Explicit opt-out via
201
+ * `token_economy.context_hint: false` is respected. Even when on, the
202
+ * beta is only sent for requests classified as "main-thread" (see
203
+ * classifyRequestRole). */
204
+ context_hint: true,
203
205
 
204
206
  /** Dump outgoing request bodies to
205
207
  * `~/.opencode/opencode-anthropic-fix/request-dumps/` (rotating, last 10).
@@ -281,6 +283,29 @@ export const DEFAULT_CONFIG = {
281
283
  // response-stream rewriting that breaks normal tool-execution semantics.
282
284
  // Tracked as future work — not exposed as a config flag here.
283
285
  },
286
+ /** Advanced strategies that may trade cache stability for verbosity reduction.
287
+ * Default OFF because most produce a net cache loss on long sessions. */
288
+ token_economy_strategies: {
289
+ /** System prompt tailing: after N turns, trim large system blocks.
290
+ * Default OFF — causes cache break over history cached under the
291
+ * pre-tail system hash. Opt in for short sessions with huge prompts. */
292
+ system_prompt_tailing: false,
293
+ /** Turn count at which tailing starts (if enabled). */
294
+ system_prompt_tail_turns: 6,
295
+ /** Max chars per system block after tailing. */
296
+ system_prompt_tail_max_chars: 2000,
297
+ /** Tool deferral (send sparse schemas until first use). Default OFF. */
298
+ tool_deferral: false,
299
+ /** Tool description compaction (strip example output). Default OFF. */
300
+ tool_description_compaction: false,
301
+ /** Adaptive tool set (main vs subagent roles). Default OFF. */
302
+ adaptive_tool_set: false,
303
+ /** Replace old reproducible-tool results (Read/Grep/Glob/LS) with stubs
304
+ * when a later call with identical args produces a fresh result. Saves
305
+ * 10-20% on long sessions. Pure over message history → cache-stable.
306
+ * Off by default (conservative mode territory). */
307
+ tool_result_dedupe_session_wide: false,
308
+ },
284
309
  /** Output cap: default max_tokens to save context window. */
285
310
  output_cap: {
286
311
  enabled: true,
@@ -376,6 +401,7 @@ function createDefaultConfig() {
376
401
  adaptive_context: { ...DEFAULT_CONFIG.adaptive_context },
377
402
  willow_mode: { ...DEFAULT_CONFIG.willow_mode },
378
403
  token_economy: { ...DEFAULT_CONFIG.token_economy },
404
+ token_economy_strategies: { ...DEFAULT_CONFIG.token_economy_strategies },
379
405
  output_cap: { ...DEFAULT_CONFIG.output_cap },
380
406
  preconnect: { ...DEFAULT_CONFIG.preconnect },
381
407
  overflow_recovery: { ...DEFAULT_CONFIG.overflow_recovery },
@@ -719,6 +745,45 @@ function validateConfig(raw) {
719
745
  };
720
746
  }
721
747
 
748
+ // Token-economy strategies sub-config (A2: default-off advanced knobs)
749
+ if (raw.token_economy_strategies && typeof raw.token_economy_strategies === "object") {
750
+ const tes = /** @type {Record<string, unknown>} */ (raw.token_economy_strategies);
751
+ config.token_economy_strategies = {
752
+ system_prompt_tailing:
753
+ typeof tes.system_prompt_tailing === "boolean"
754
+ ? tes.system_prompt_tailing
755
+ : DEFAULT_CONFIG.token_economy_strategies.system_prompt_tailing,
756
+ system_prompt_tail_turns: clampNumber(
757
+ tes.system_prompt_tail_turns,
758
+ 1,
759
+ 1000,
760
+ DEFAULT_CONFIG.token_economy_strategies.system_prompt_tail_turns,
761
+ ),
762
+ system_prompt_tail_max_chars: clampNumber(
763
+ tes.system_prompt_tail_max_chars,
764
+ 100,
765
+ 50_000,
766
+ DEFAULT_CONFIG.token_economy_strategies.system_prompt_tail_max_chars,
767
+ ),
768
+ tool_deferral:
769
+ typeof tes.tool_deferral === "boolean"
770
+ ? tes.tool_deferral
771
+ : DEFAULT_CONFIG.token_economy_strategies.tool_deferral,
772
+ tool_description_compaction:
773
+ typeof tes.tool_description_compaction === "boolean"
774
+ ? tes.tool_description_compaction
775
+ : DEFAULT_CONFIG.token_economy_strategies.tool_description_compaction,
776
+ adaptive_tool_set:
777
+ typeof tes.adaptive_tool_set === "boolean"
778
+ ? tes.adaptive_tool_set
779
+ : DEFAULT_CONFIG.token_economy_strategies.adaptive_tool_set,
780
+ tool_result_dedupe_session_wide:
781
+ typeof tes.tool_result_dedupe_session_wide === "boolean"
782
+ ? tes.tool_result_dedupe_session_wide
783
+ : DEFAULT_CONFIG.token_economy_strategies.tool_result_dedupe_session_wide,
784
+ };
785
+ }
786
+
722
787
  // Output cap sub-config
723
788
  if (raw.output_cap && typeof raw.output_cap === "object") {
724
789
  const oc = /** @type {Record<string, unknown>} */ (raw.output_cap);
@@ -0,0 +1,68 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { getConfigDir } from "./config.mjs";
4
+
5
+ const FLAG_FILENAME = "context-hint-disabled.flag";
6
+ const FLAG_VERSION = 1;
7
+
8
+ /**
9
+ * @typedef {object} ContextHintFlag
10
+ * @property {true} disabled
11
+ * @property {string} [reason]
12
+ * @property {number} [status]
13
+ * @property {number} [timestamp]
14
+ * @property {number} [version]
15
+ */
16
+
17
+ /**
18
+ * @returns {string}
19
+ */
20
+ export function getContextHintFlagPath() {
21
+ return join(getConfigDir(), FLAG_FILENAME);
22
+ }
23
+
24
+ /**
25
+ * @returns {{ disabled: boolean, reason?: string, status?: number, timestamp?: number }}
26
+ */
27
+ export function loadContextHintDisabledFlag() {
28
+ const p = getContextHintFlagPath();
29
+ if (!existsSync(p)) return { disabled: false };
30
+ try {
31
+ const raw = readFileSync(p, "utf-8");
32
+ const parsed = JSON.parse(raw);
33
+ if (parsed && parsed.disabled === true) {
34
+ return {
35
+ disabled: true,
36
+ reason: typeof parsed.reason === "string" ? parsed.reason : undefined,
37
+ status: typeof parsed.status === "number" ? parsed.status : undefined,
38
+ timestamp: typeof parsed.timestamp === "number" ? parsed.timestamp : undefined,
39
+ };
40
+ }
41
+ } catch {
42
+ // Corrupted or unreadable — treat as not disabled.
43
+ }
44
+ return { disabled: false };
45
+ }
46
+
47
+ /**
48
+ * Persist a permanent disable flag for the context-hint beta. Best-effort;
49
+ * disk failures are swallowed so they never abort a session.
50
+ * @param {{ reason: string, status: number }} params
51
+ */
52
+ export function saveContextHintDisabledFlag({ reason, status }) {
53
+ const p = getContextHintFlagPath();
54
+ try {
55
+ mkdirSync(dirname(p), { recursive: true });
56
+ /** @type {ContextHintFlag} */
57
+ const payload = {
58
+ disabled: true,
59
+ reason,
60
+ status,
61
+ timestamp: Date.now(),
62
+ version: FLAG_VERSION,
63
+ };
64
+ writeFileSync(p, JSON.stringify(payload, null, 2), "utf-8");
65
+ } catch {
66
+ // Best-effort.
67
+ }
68
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Deterministic rolling summarizer — Phase B Task B3.
3
+ *
4
+ * Produces a byte-identical string summary for a given message array so the
5
+ * Anthropic prompt cache stays warm across compaction boundaries.
6
+ *
7
+ * Invariants (enforced by test/rolling-summarizer.test.mjs):
8
+ * - No Date.now / new Date / Math.random / performance.now anywhere.
9
+ * - temperature: 0 on the Haiku call.
10
+ * - Object iteration is sorted before join.
11
+ * - The Haiku call is injected as `haikuCall(request)` so tests stay offline.
12
+ *
13
+ * @module lib/rolling-summarizer
14
+ */
15
+
16
+ /** Haiku 4.5 — deterministic, cheap summarization model. */
17
+ export const MODEL = "claude-haiku-4-5-20251001";
18
+
19
+ /** Forced to 0 — non-zero temperature would break the byte-equality guarantee. */
20
+ export const TEMPERATURE = 0;
21
+
22
+ /** Default length bound for the full rendered `<session-summary>` block. */
23
+ export const DEFAULT_MAX_CHARS = 2000;
24
+
25
+ /** Canonical template — every field is a placeholder, never a timestamp. */
26
+ export const TEMPLATE = [
27
+ "<session-summary>",
28
+ "Previous conversation summarized for context efficiency.",
29
+ "",
30
+ "Key topics covered:",
31
+ "{topics}",
32
+ "",
33
+ "Outstanding state:",
34
+ "{outstanding}",
35
+ "",
36
+ "Files touched:",
37
+ "{files}",
38
+ "</session-summary>",
39
+ ].join("\n");
40
+
41
+ /** Fixed sections the Haiku response is expected to emit, in this exact order. */
42
+ const SECTIONS = ["TOPICS", "OUTSTANDING", "FILES"];
43
+
44
+ /** Always-present fallback when a section is missing from the Haiku reply. */
45
+ const EMPTY_SECTION = "(none)";
46
+
47
+ /** @typedef {{role: string, content: string}} Message */
48
+ /** @typedef {{topics: string, outstanding: string, files: string}} ParsedSections */
49
+ /** @typedef {{model: string, temperature: number, prompt: string}} HaikuRequest */
50
+ /** @typedef {(req: HaikuRequest) => Promise<string>} HaikuCall */
51
+
52
+ /**
53
+ * Serialize an object with keys in sorted order. JSON.stringify follows
54
+ * insertion order in V8, so sorting first guarantees byte-equality across
55
+ * property-order-varied inputs.
56
+ * @param {Record<string, unknown>} obj
57
+ * @returns {string}
58
+ */
59
+ function stableStringify(obj) {
60
+ const keys = Object.keys(obj).sort();
61
+ const parts = keys.map((k) => JSON.stringify(k) + ":" + JSON.stringify(obj[k]));
62
+ return "{" + parts.join(",") + "}";
63
+ }
64
+
65
+ /**
66
+ * Build the prompt text fed to Haiku. Pure; no timestamps, no IDs.
67
+ * Uses sorted-key serialization so the output doesn't change if the caller
68
+ * built `{role, content}` vs `{content, role}`.
69
+ * @param {Message[]} messages
70
+ * @param {number} maxChars — soft cap used to trim oldest messages first.
71
+ * @returns {string}
72
+ */
73
+ export function buildPrompt(messages, maxChars) {
74
+ const rendered = messages.map((m) => {
75
+ // Re-shape every message in a canonical sorted form so insertion order
76
+ // of {role, content} vs {content, role} cannot affect the bytes.
77
+ const norm = stableStringify({ role: String(m.role ?? ""), content: String(m.content ?? "") });
78
+ // Render as "role: content" for the model — deterministic and readable.
79
+ const parsed = JSON.parse(norm);
80
+ return `${parsed.role}: ${parsed.content}`;
81
+ });
82
+
83
+ // Keep under the cap by trimming the oldest entries first. Deterministic:
84
+ // no randomness, purely length-driven.
85
+ let body = rendered.join("\n");
86
+ let dropped = 0;
87
+ while (body.length > maxChars && rendered.length - dropped > 1) {
88
+ dropped += 1;
89
+ body = rendered.slice(dropped).join("\n");
90
+ }
91
+
92
+ const header = [
93
+ "Summarize the conversation below for context compaction.",
94
+ "Respond with EXACTLY these three sections, in this order:",
95
+ "TOPICS:",
96
+ "- <bullet per topic>",
97
+ "OUTSTANDING:",
98
+ "- <bullet per outstanding item, or (none)>",
99
+ "FILES:",
100
+ "- <bullet per touched file, or (none)>",
101
+ "Do not include dates, times, IDs, greetings, or apologies.",
102
+ "---",
103
+ ].join("\n");
104
+
105
+ return `${header}\n${body}`;
106
+ }
107
+
108
+ /**
109
+ * Parse Haiku's "TOPICS:\n...\nOUTSTANDING:\n...\nFILES:\n..." reply into
110
+ * the three fields the template expects. Unknown sections are ignored.
111
+ * Missing sections fall back to EMPTY_SECTION so the output stays stable.
112
+ * @param {string} raw
113
+ * @returns {ParsedSections}
114
+ */
115
+ export function parseHaikuResponse(raw) {
116
+ /** @type {Record<string, string[]>} */
117
+ const buckets = {};
118
+ for (const name of SECTIONS) buckets[name] = [];
119
+
120
+ let current = /** @type {string | null} */ (null);
121
+ const lines = String(raw ?? "").split(/\r?\n/);
122
+ for (const line of lines) {
123
+ const match = line.match(/^([A-Z]+)\s*:\s*$/);
124
+ if (match && SECTIONS.includes(match[1])) {
125
+ current = match[1];
126
+ continue;
127
+ }
128
+ if (current && line.trim() !== "") {
129
+ buckets[current].push(line);
130
+ }
131
+ }
132
+
133
+ const pick = (name) => {
134
+ const arr = buckets[name];
135
+ if (!arr || arr.length === 0) return EMPTY_SECTION;
136
+ return arr.join("\n");
137
+ };
138
+
139
+ return {
140
+ topics: pick("TOPICS"),
141
+ outstanding: pick("OUTSTANDING"),
142
+ files: pick("FILES"),
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Render the canonical TEMPLATE with parsed sections substituted in.
148
+ * If the result exceeds `maxChars`, sections are truncated deterministically
149
+ * from the longest one first until the total fits.
150
+ * @param {ParsedSections} parsed
151
+ * @param {number} maxChars
152
+ * @returns {string}
153
+ */
154
+ export function formatTemplate(parsed, maxChars) {
155
+ /** @type {ParsedSections} */
156
+ let current = {
157
+ topics: parsed.topics || EMPTY_SECTION,
158
+ outstanding: parsed.outstanding || EMPTY_SECTION,
159
+ files: parsed.files || EMPTY_SECTION,
160
+ };
161
+
162
+ const render = (sec) =>
163
+ TEMPLATE.replace("{topics}", sec.topics).replace("{outstanding}", sec.outstanding).replace("{files}", sec.files);
164
+
165
+ let out = render(current);
166
+ // Deterministic truncation: iterate sections in a fixed order and shorten
167
+ // the longest until we fit. No Math.random, no timing-based bail.
168
+ let guard = 0;
169
+ while (out.length > maxChars && guard < 1000) {
170
+ guard += 1;
171
+ const names = /** @type {(keyof ParsedSections)[]} */ (["topics", "outstanding", "files"]);
172
+ // Pick the longest section; ties broken by fixed order above.
173
+ let longestName = names[0];
174
+ for (const n of names) {
175
+ if (current[n].length > current[longestName].length) longestName = n;
176
+ }
177
+ const longest = current[longestName];
178
+ if (longest.length <= EMPTY_SECTION.length) break;
179
+ // Chop 10% (at least 8 chars) off the end of the longest section.
180
+ const chop = Math.max(8, Math.floor(longest.length * 0.1));
181
+ current = { ...current, [longestName]: longest.slice(0, Math.max(EMPTY_SECTION.length, longest.length - chop)) };
182
+ out = render(current);
183
+ }
184
+
185
+ // Final hard cap: if we somehow still exceed, slice the envelope. Still
186
+ // deterministic (input-driven only).
187
+ if (out.length > maxChars) {
188
+ const closing = "\n</session-summary>";
189
+ const head = out.slice(0, Math.max(0, maxChars - closing.length));
190
+ out = head + closing;
191
+ }
192
+ return out;
193
+ }
194
+
195
+ /**
196
+ * Summarize `messages` into a canonical `<session-summary>` string.
197
+ *
198
+ * @param {Message[]} messages
199
+ * @param {{maxChars?: number, haikuCall: HaikuCall}} opts
200
+ * - maxChars: soft cap on the returned string (default 2000).
201
+ * - haikuCall: dependency-injected Haiku invoker — `(request) => Promise<string>`.
202
+ * @returns {Promise<string>}
203
+ */
204
+ export async function summarize(messages, opts) {
205
+ if (!opts || typeof opts.haikuCall !== "function") {
206
+ throw new Error("summarize() requires opts.haikuCall");
207
+ }
208
+ const maxChars = typeof opts.maxChars === "number" ? opts.maxChars : DEFAULT_MAX_CHARS;
209
+ const prompt = buildPrompt(Array.isArray(messages) ? messages : [], maxChars);
210
+ /** @type {HaikuRequest} */
211
+ const request = { model: MODEL, prompt, temperature: TEMPERATURE };
212
+ const raw = await opts.haikuCall(request);
213
+ const parsed = parseHaikuResponse(String(raw ?? ""));
214
+ return formatTemplate(parsed, maxChars);
215
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "0.1.15",
3
+ "version": "0.1.19",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "main": "./index.mjs",
6
6
  "files": [