opencode-anthropic-fix 0.1.14 → 0.1.18

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 ?? {}),
@@ -2902,6 +2916,33 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2902
2916
  // v2.1.107: cch attestation via xxHash64(body, seed) & 0xFFFFF
2903
2917
  const finalBody = await computeAndReplaceCCH(body);
2904
2918
 
2919
+ // Opt-in: dump the OUTGOING body (post-cch) so diagnostics reflect
2920
+ // exactly what went on the wire. Previously dumped `body` which
2921
+ // still had the cch=00000 placeholder — that confused debugging.
2922
+ // Rotates at 10 files to cap disk usage. Files live under
2923
+ // ~/.opencode/opencode-anthropic-fix/request-dumps/.
2924
+ if (config.token_economy?.debug_dump_bodies === true && typeof finalBody === "string") {
2925
+ try {
2926
+ const fs = await import("node:fs");
2927
+ const path = await import("node:path");
2928
+ const os = await import("node:os");
2929
+ const dir = path.join(os.homedir(), ".opencode", "opencode-anthropic-fix", "request-dumps");
2930
+ fs.mkdirSync(dir, { recursive: true });
2931
+ // Rotate: keep last 10
2932
+ const existing = fs
2933
+ .readdirSync(dir)
2934
+ .filter((f) => f.startsWith("req-") && f.endsWith(".json"))
2935
+ .sort();
2936
+ while (existing.length >= 10) {
2937
+ fs.unlinkSync(path.join(dir, existing.shift()));
2938
+ }
2939
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
2940
+ fs.writeFileSync(path.join(dir, `req-${ts}.json`), finalBody);
2941
+ } catch {
2942
+ // Disk full, permissions, whatever — never block the request.
2943
+ }
2944
+ }
2945
+
2905
2946
  // Execute the request
2906
2947
  let response;
2907
2948
  try {
@@ -3235,15 +3276,31 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3235
3276
  ) {
3236
3277
  contextHintState.disabled = true;
3237
3278
  betaLatchState.dirty = true;
3238
- debugLog("context-hint: beta rejected by server (400), disabling for session");
3279
+ // Persist across sessions: a 400 "Unexpected value" on the
3280
+ // context-hint beta means the account lacks access to the
3281
+ // beta entirely. Re-attempting next session would burn
3282
+ // another turn to the same rejection.
3283
+ saveContextHintDisabledFlag({
3284
+ reason: "beta_unsupported_400",
3285
+ status: 400,
3286
+ });
3287
+ debugLog("context-hint: beta rejected by server (400), disabling + persisting");
3288
+ // Retry without the beta. The latch above drops the header
3289
+ // and the body field on the rebuilt request.
3290
+ attempt--;
3291
+ continue;
3239
3292
  } else if (response.status === 409) {
3240
3293
  contextHintState.disabled = true;
3241
3294
  betaLatchState.dirty = true;
3242
3295
  debugLog("context-hint: 409 conflict, disabling for session");
3296
+ attempt--;
3297
+ continue;
3243
3298
  } else if (response.status === 529 && errorBody && errorBody.includes("context_hint")) {
3244
3299
  contextHintState.disabled = true;
3245
3300
  betaLatchState.dirty = true;
3246
3301
  debugLog("context-hint: 529 overloaded referencing hint, disabling for session");
3302
+ attempt--;
3303
+ continue;
3247
3304
  } else if (
3248
3305
  (response.status === 422 || response.status === 424) &&
3249
3306
  !requestInit._contextHintCompactAttempted
@@ -3978,6 +4035,11 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3978
4035
  }
3979
4036
 
3980
4037
  output.context.push(contextParts.join("\n"));
4038
+
4039
+ // TODO(B3): wire rolling-summarizer once per-session message capture exists
4040
+ // (requires chat.messages.transform accumulator). Module exists at
4041
+ // lib/rolling-summarizer.mjs — call summarize(messages, {haikuCall}) here
4042
+ // behind config.token_economy.rolling_summarizer when messages are available.
3981
4043
  },
3982
4044
  };
3983
4045
  }
@@ -3990,49 +4052,59 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3990
4052
  /** Module-level config ref for functions outside AnthropicAuthPlugin closure. */
3991
4053
  let _pluginConfig = null;
3992
4054
 
3993
- const sessionMetrics = {
3994
- turns: 0,
3995
- totalInput: 0,
3996
- totalOutput: 0,
3997
- totalCacheRead: 0,
3998
- totalCacheWrite: 0,
3999
- totalWebSearchRequests: 0,
4000
- recentCacheRates: [], // rolling window of last 5 turns
4001
- sessionCostUsd: 0,
4002
- costBreakdown: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
4003
- sessionStartTime: Date.now(),
4004
- lastQuota: {
4005
- tokens: 0,
4006
- requests: 0,
4007
- inputTokens: 0,
4008
- updatedAt: 0,
4009
- // Window-based unified headers from response
4010
- fiveHour: { utilization: 0, resets_at: null, status: null, surpassedThreshold: null },
4011
- sevenDay: { utilization: 0, resets_at: null, status: null, surpassedThreshold: null },
4012
- // Overall/fallback/overage from response headers
4013
- overallStatus: null,
4014
- representativeClaim: null,
4015
- fallback: null,
4016
- fallbackPercentage: null,
4017
- overageStatus: null,
4018
- overageReason: null,
4019
- // Usage endpoint polling (A6)
4020
- lastPollAt: 0,
4021
- },
4022
- lastStopReason: null, // tracks most recent stop_reason for output cap escalation
4023
- perModel: {}, // Map<modelId, { input, output, cacheRead, cacheWrite, costUsd, turns }>
4024
- lastModelId: null,
4025
- lastRequestBody: null, // Last intercepted request body (JSON string, capped 2MB) for /anthropic context
4026
- /** Token budget tracking (A9) */
4027
- tokenBudget: {
4028
- limit: 0, // 0 = unset
4029
- used: 0, // accumulated output tokens
4030
- continuations: 0,
4031
- outputHistory: [], // last 5 output token deltas
4032
- },
4033
- /** Tools used in this session (populated from assistant tool_use blocks in messages) */
4034
- usedTools: new Set(),
4035
- };
4055
+ /**
4056
+ * Factory for the initial sessionMetrics shape. Returns a fresh object each
4057
+ * call so the reset helper (and any future test hook) doesn't alias nested
4058
+ * state (lastQuota, perModel, costBreakdown, tokenBudget, usedTools Set).
4059
+ * Keep this in sync with the type annotation above.
4060
+ */
4061
+ function createInitialSessionMetrics() {
4062
+ return {
4063
+ turns: 0,
4064
+ totalInput: 0,
4065
+ totalOutput: 0,
4066
+ totalCacheRead: 0,
4067
+ totalCacheWrite: 0,
4068
+ totalWebSearchRequests: 0,
4069
+ recentCacheRates: [], // rolling window of last 5 turns
4070
+ sessionCostUsd: 0,
4071
+ costBreakdown: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
4072
+ sessionStartTime: Date.now(),
4073
+ lastQuota: {
4074
+ tokens: 0,
4075
+ requests: 0,
4076
+ inputTokens: 0,
4077
+ updatedAt: 0,
4078
+ // Window-based unified headers from response
4079
+ fiveHour: { utilization: 0, resets_at: null, status: null, surpassedThreshold: null },
4080
+ sevenDay: { utilization: 0, resets_at: null, status: null, surpassedThreshold: null },
4081
+ // Overall/fallback/overage from response headers
4082
+ overallStatus: null,
4083
+ representativeClaim: null,
4084
+ fallback: null,
4085
+ fallbackPercentage: null,
4086
+ overageStatus: null,
4087
+ overageReason: null,
4088
+ // Usage endpoint polling (A6)
4089
+ lastPollAt: 0,
4090
+ },
4091
+ lastStopReason: null, // tracks most recent stop_reason for output cap escalation
4092
+ perModel: {}, // Map<modelId, { input, output, cacheRead, cacheWrite, costUsd, turns }>
4093
+ lastModelId: null,
4094
+ lastRequestBody: null, // Last intercepted request body (JSON string, capped 2MB) for /anthropic context
4095
+ /** Token budget tracking (A9) */
4096
+ tokenBudget: {
4097
+ limit: 0, // 0 = unset
4098
+ used: 0, // accumulated output tokens
4099
+ continuations: 0,
4100
+ outputHistory: [], // last 5 output token deltas
4101
+ },
4102
+ /** Tools used in this session (populated from assistant tool_use blocks in messages) */
4103
+ usedTools: new Set(),
4104
+ };
4105
+ }
4106
+
4107
+ const sessionMetrics = createInitialSessionMetrics();
4036
4108
 
4037
4109
  // ---------------------------------------------------------------------------
4038
4110
  // Adaptive 1M context state
@@ -5419,6 +5491,174 @@ function applyContextHintCompaction(messages, opts = {}) {
5419
5491
  };
5420
5492
  }
5421
5493
 
5494
+ /**
5495
+ * Tools whose output is trivially reproducible by re-running with the same
5496
+ * arguments. Stateful tools (bash, edit, write, etc.) never dedupe — their
5497
+ * outputs may reflect non-idempotent side effects that the transcript needs
5498
+ * to preserve.
5499
+ */
5500
+ const REPRODUCIBLE_TOOL_NAMES = new Set(["read", "grep", "glob", "ls", "list", "find"]);
5501
+
5502
+ /**
5503
+ * Title-case a tool name for the stub string ("read" → "Read", "grep" → "Grep").
5504
+ * Pure; no locale.
5505
+ */
5506
+ function titleCaseToolName(name) {
5507
+ if (typeof name !== "string" || name.length === 0) return "";
5508
+ return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase();
5509
+ }
5510
+
5511
+ /**
5512
+ * Minimal deterministic stable-stringify for args canonicalization.
5513
+ * Sorts object keys at every depth before serialization so that
5514
+ * `{a:1,b:2}` and `{b:2,a:1}` produce the same string.
5515
+ *
5516
+ * Arrays are traversed in order. Non-plain objects (Date, Map, etc.) fall
5517
+ * through to `JSON.stringify` — we don't expect them in tool args, but the
5518
+ * fallback keeps the function total.
5519
+ */
5520
+ function stableStringifyForDedupe(value) {
5521
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
5522
+ if (Array.isArray(value)) {
5523
+ return "[" + value.map((v) => stableStringifyForDedupe(v)).join(",") + "]";
5524
+ }
5525
+ const keys = Object.keys(value).sort();
5526
+ const parts = [];
5527
+ for (const k of keys) {
5528
+ parts.push(JSON.stringify(k) + ":" + stableStringifyForDedupe(value[k]));
5529
+ }
5530
+ return "{" + parts.join(",") + "}";
5531
+ }
5532
+
5533
+ /**
5534
+ * Session-wide tool-result dedupe. Pure over `messages`.
5535
+ *
5536
+ * Walks the conversation once to collect every `tool_use` + `tool_result`
5537
+ * pair where the tool is in `REPRODUCIBLE_TOOL_NAMES` (case-insensitive).
5538
+ * Groups them by `(toolName, stableStringify(args))`. For every group
5539
+ * containing more than one call, keeps the LATEST result verbatim and
5540
+ * replaces each earlier result's `content` with a stub:
5541
+ *
5542
+ * `[<ToolTitleCase> of <argsKey> superseded by later read at msg #<N>]`
5543
+ *
5544
+ * where `N` is the message index of the latest call's user-message.
5545
+ *
5546
+ * `tool_use_id` and any other `tool_result` fields are preserved. Assistant
5547
+ * `tool_use` blocks are NEVER modified — only the paired user `tool_result`.
5548
+ *
5549
+ * Cache-stable: decision is a pure function of message history, so rerunning
5550
+ * over an unchanged prefix yields byte-identical output.
5551
+ *
5552
+ * @param {Array} messages — Parsed messages array
5553
+ * @returns {{ messages: Array, changed: boolean, stats: { deduped: number } }}
5554
+ */
5555
+ function applySessionToolResultDedupe(messages) {
5556
+ if (!Array.isArray(messages) || messages.length === 0) {
5557
+ return { messages, changed: false, stats: { deduped: 0 } };
5558
+ }
5559
+
5560
+ // Pass 1: build tool_use_id → { name, argsKey } for reproducible tools.
5561
+ const idToMeta = new Map();
5562
+ for (let i = 0; i < messages.length; i++) {
5563
+ const msg = messages[i];
5564
+ if (msg?.role !== "assistant" || !Array.isArray(msg.content)) continue;
5565
+ for (const block of msg.content) {
5566
+ if (block?.type !== "tool_use") continue;
5567
+ const name = typeof block.name === "string" ? block.name : "";
5568
+ if (!REPRODUCIBLE_TOOL_NAMES.has(name.toLowerCase())) continue;
5569
+ const argsKey = stableStringifyForDedupe(block.input ?? {});
5570
+ idToMeta.set(block.id, { name, argsKey });
5571
+ }
5572
+ }
5573
+
5574
+ // Pass 2: collect tool_result locations by group key, preserving order.
5575
+ /** @type {Map<string, Array<{msgIdx: number, blockIdx: number, toolUseId: string, name: string, argsKey: string}>>} */
5576
+ const groups = new Map();
5577
+ for (let i = 0; i < messages.length; i++) {
5578
+ const msg = messages[i];
5579
+ if (msg?.role !== "user" || !Array.isArray(msg.content)) continue;
5580
+ for (let j = 0; j < msg.content.length; j++) {
5581
+ const block = msg.content[j];
5582
+ if (block?.type !== "tool_result") continue;
5583
+ const meta = idToMeta.get(block.tool_use_id);
5584
+ if (!meta) continue;
5585
+ const groupKey = meta.name.toLowerCase() + "\u0000" + meta.argsKey;
5586
+ let arr = groups.get(groupKey);
5587
+ if (!arr) {
5588
+ arr = [];
5589
+ groups.set(groupKey, arr);
5590
+ }
5591
+ arr.push({
5592
+ msgIdx: i,
5593
+ blockIdx: j,
5594
+ toolUseId: block.tool_use_id,
5595
+ name: meta.name,
5596
+ argsKey: meta.argsKey,
5597
+ });
5598
+ }
5599
+ }
5600
+
5601
+ // Build supersede map: "msgIdx:blockIdx" → stub string.
5602
+ const supersedeStubs = new Map();
5603
+ let deduped = 0;
5604
+ // Deterministic iteration: sort group entries by group key before processing.
5605
+ const sortedEntries = Array.from(groups.entries()).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
5606
+ for (const [, occurrences] of sortedEntries) {
5607
+ if (occurrences.length < 2) continue;
5608
+ const latest = occurrences[occurrences.length - 1];
5609
+ const stub =
5610
+ "[" +
5611
+ titleCaseToolName(latest.name) +
5612
+ " of " +
5613
+ latest.argsKey +
5614
+ " superseded by later read at msg #" +
5615
+ latest.msgIdx +
5616
+ "]";
5617
+ for (let k = 0; k < occurrences.length - 1; k++) {
5618
+ const occ = occurrences[k];
5619
+ supersedeStubs.set(occ.msgIdx + ":" + occ.blockIdx, stub);
5620
+ deduped += 1;
5621
+ }
5622
+ }
5623
+
5624
+ if (deduped === 0) {
5625
+ return { messages, changed: false, stats: { deduped: 0 } };
5626
+ }
5627
+
5628
+ // Pass 3: rewrite. Preserve tool_use_id and other fields.
5629
+ const out = messages.map((msg, i) => {
5630
+ if (msg?.role !== "user" || !Array.isArray(msg.content)) return msg;
5631
+ let mutated = false;
5632
+ const newContent = msg.content.map((block, j) => {
5633
+ if (block?.type !== "tool_result") return block;
5634
+ const stub = supersedeStubs.get(i + ":" + j);
5635
+ if (!stub) return block;
5636
+ mutated = true;
5637
+ return { ...block, content: stub };
5638
+ });
5639
+ return mutated ? { ...msg, content: newContent } : msg;
5640
+ });
5641
+
5642
+ return { messages: out, changed: true, stats: { deduped } };
5643
+ }
5644
+
5645
+ /**
5646
+ * Dispatch wrapper: only apply `applySessionToolResultDedupe` when the
5647
+ * `token_economy_strategies.tool_result_dedupe_session_wide` flag is true.
5648
+ * When disabled, returns the input `messages` by identity (not a copy) so
5649
+ * callers can cheaply detect "no-op" with reference equality.
5650
+ *
5651
+ * @param {Array} messages
5652
+ * @param {object} [config]
5653
+ * @returns {Array}
5654
+ */
5655
+ function maybeApplySessionToolResultDedupe(messages, config) {
5656
+ const flag = config?.token_economy_strategies?.tool_result_dedupe_session_wide;
5657
+ if (flag !== true) return messages;
5658
+ const result = applySessionToolResultDedupe(messages);
5659
+ return result.messages;
5660
+ }
5661
+
5422
5662
  /**
5423
5663
  * TTL-based thinking strip. When the time since the last strip exceeds the
5424
5664
  * cache TTL (roughly the point at which the prompt prefix cache would expire),
@@ -6112,6 +6352,16 @@ function isOpus46Model(model) {
6112
6352
  return /claude-opus-4[._-]6|opus[._-]4[._-]6/i.test(model);
6113
6353
  }
6114
6354
 
6355
+ /**
6356
+ * Detects claude-opus-4.7 / claude-opus-4-7 model IDs.
6357
+ * @param {string | undefined} model
6358
+ * @returns {boolean}
6359
+ */
6360
+ function isOpus47Model(model) {
6361
+ if (!model) return false;
6362
+ return /claude-opus-4[._-]7|opus[._-]4[._-]7/i.test(model);
6363
+ }
6364
+
6115
6365
  /**
6116
6366
  * Detects claude-sonnet-4.6 / claude-sonnet-4-6 model IDs.
6117
6367
  * @param {string | undefined} body
@@ -6124,12 +6374,12 @@ function isSonnet46Model(model) {
6124
6374
 
6125
6375
  /**
6126
6376
  * Detects models that support adaptive thinking ({type: "adaptive"}).
6127
- * Currently: Opus 4.6 and Sonnet 4.6.
6377
+ * Currently: Opus 4.6, Opus 4.7, and Sonnet 4.6.
6128
6378
  * @param {string | undefined} body
6129
6379
  * @returns {boolean}
6130
6380
  */
6131
6381
  function isAdaptiveThinkingModel(model) {
6132
- return isOpus46Model(model) || isSonnet46Model(model);
6382
+ return isOpus46Model(model) || isOpus47Model(model) || isSonnet46Model(model);
6133
6383
  }
6134
6384
 
6135
6385
  /**
@@ -6143,8 +6393,9 @@ function isEligibleFor1MContext(model) {
6143
6393
  if (!model) return false;
6144
6394
  // Explicit 1m suffix/tag in model name
6145
6395
  if (/(^|[-_ ])1m($|[-_ ])|context[-_]?1m|\[1m\]/i.test(model)) return true;
6146
- // CC v2.1.97 U01: claude-sonnet-4* (any Sonnet 4.x) or opus-4-6
6147
- return /claude-sonnet-4|sonnet[._-]4/i.test(model) || isOpus46Model(model);
6396
+ // CC v2.1.97 U01: claude-sonnet-4* (any Sonnet 4.x) or opus-4-6.
6397
+ // Opus 4.7 (successor to 4.6) is also 1M-context eligible.
6398
+ return /claude-sonnet-4|sonnet[._-]4/i.test(model) || isOpus46Model(model) || isOpus47Model(model);
6148
6399
  }
6149
6400
 
6150
6401
  /**
@@ -6809,8 +7060,12 @@ function buildSystemPromptBlocks(system, signature) {
6809
7060
  }
6810
7061
 
6811
7062
  // Anti-verbosity injection (CC v2.1.100 quiet_salted_ember equivalent).
6812
- // Only for Opus 4.6 and non-title-generator requests.
6813
- if (!titleGeneratorRequest && signature.modelId && isOpus46Model(signature.modelId)) {
7063
+ // Applies to Opus 4.6 / 4.7 for non-title-generator requests.
7064
+ if (
7065
+ !titleGeneratorRequest &&
7066
+ signature.modelId &&
7067
+ (isOpus46Model(signature.modelId) || isOpus47Model(signature.modelId))
7068
+ ) {
6814
7069
  const avConfig = signature.antiVerbosity;
6815
7070
  if (avConfig?.enabled !== false) {
6816
7071
  sanitized.push({ type: "text", text: ANTI_VERBOSITY_SYSTEM_PROMPT });
@@ -7473,6 +7728,15 @@ function transformRequestBody(body, signature, runtime, betaHeader, config) {
7473
7728
  }
7474
7729
  }
7475
7730
 
7731
+ // (1b) Session-wide reproducible-tool result dedupe (Phase C C3, opt-in)
7732
+ // Pure over message history; runs before microcompact so dedup'd stubs
7733
+ // are visible to downstream size estimation. Gated by
7734
+ // token_economy_strategies.tool_result_dedupe_session_wide (default off).
7735
+ if (config?.token_economy_strategies?.tool_result_dedupe_session_wide === true) {
7736
+ const res = applySessionToolResultDedupe(parsed.messages);
7737
+ if (res.changed) parsed.messages = res.messages;
7738
+ }
7739
+
7476
7740
  // (2) Proactive microcompact (client-side, pre-422)
7477
7741
  if (te.proactive_microcompact !== false) {
7478
7742
  const estimated = estimatePromptTokensFromParsed(parsed);
@@ -7561,7 +7825,7 @@ function transformRequestBody(body, signature, runtime, betaHeader, config) {
7561
7825
  // Preserves: first paragraph (identity/role), lines containing MUST/NEVER/CRITICAL/
7562
7826
  // IMPORTANT, section headers, and short blocks. Drops verbose body paragraphs.
7563
7827
  const tailThreshold = signature.systemPromptTailTurns ?? 6;
7564
- if (signature.systemPromptTailing !== false && runtime.turns >= tailThreshold && Array.isArray(parsed.system)) {
7828
+ if (signature.systemPromptTailing === true && runtime.turns >= tailThreshold && Array.isArray(parsed.system)) {
7565
7829
  const maxChars = signature.systemPromptTailMaxChars ?? 2000;
7566
7830
  for (let i = 0; i < parsed.system.length; i++) {
7567
7831
  const block = parsed.system[i];
@@ -7779,6 +8043,10 @@ function transformRequestBody(body, signature, runtime, betaHeader, config) {
7779
8043
 
7780
8044
  // Fast mode: inject speed parameter for Opus 4.6 only (v2.1.97 restriction).
7781
8045
  // Real CC v2.1.97 xJ() checks: model.includes("opus-4-6") — Sonnet is NOT eligible.
8046
+ // NOTE: Deliberately NOT extended to Opus 4.7. xJ() is a fingerprint-sensitive
8047
+ // check tied to the exact real-CC version we mirror; blindly enabling
8048
+ // speed:"fast" for 4.7 could diverge from real CC's behavior for that model.
8049
+ // Revisit once a real-CC dump confirms xJ() matches opus-4-7.
7782
8050
  const fastModeEnabled = signature.fastMode && !isFalsyEnv(process.env.OPENCODE_ANTHROPIC_DISABLE_FAST_MODE);
7783
8051
  let fastModeAutoApplied = false;
7784
8052
  if (
@@ -8566,6 +8834,11 @@ AnthropicAuthPlugin.__testing__ = {
8566
8834
  buildSystemPromptBlocks,
8567
8835
  stripMcpPrefixFromParsedEvent,
8568
8836
  CORE_TOOL_NAMES,
8837
+ // exposed for determinism regression tests (phase C1)
8838
+ applyContextHintCompaction,
8839
+ // exposed for session-dedupe regression tests (phase C3)
8840
+ applySessionToolResultDedupe,
8841
+ maybeApplySessionToolResultDedupe,
8569
8842
  get cachedCCPrompt() {
8570
8843
  return cachedCCPrompt;
8571
8844
  },
@@ -8574,6 +8847,24 @@ AnthropicAuthPlugin.__testing__ = {
8574
8847
  },
8575
8848
  SUBAGENT_CC_ANCHOR,
8576
8849
  CLAUDE_CODE_IDENTITY_STRING,
8850
+ /** Test-only: drive the session turn counter so code paths gated on
8851
+ * `sessionMetrics.turns >= N` can be exercised without a real SSE stream. */
8852
+ setSessionTurnsForTest(n) {
8853
+ sessionMetrics.turns = n;
8854
+ },
8855
+ /** Test-only: reset session metrics between tests.
8856
+ * Uses createInitialSessionMetrics() so every tracked field — including
8857
+ * nested objects (lastQuota, perModel, costBreakdown, tokenBudget) and the
8858
+ * usedTools Set — is restored to its initial value. Mutates the existing
8859
+ * sessionMetrics object in place because many module-level references
8860
+ * close over it. */
8861
+ resetSessionMetricsForTest() {
8862
+ const fresh = createInitialSessionMetrics();
8863
+ for (const key of Object.keys(sessionMetrics)) {
8864
+ delete sessionMetrics[key];
8865
+ }
8866
+ Object.assign(sessionMetrics, fresh);
8867
+ },
8577
8868
  };
8578
8869
 
8579
8870
  export default AnthropicAuthPlugin;
package/lib/config.mjs CHANGED
@@ -195,11 +195,20 @@ 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,
205
+
206
+ /** Dump outgoing request bodies to
207
+ * `~/.opencode/opencode-anthropic-fix/request-dumps/` (rotating, last 10).
208
+ * Diagnostic tool — enable, run 3-4 conversation turns, ship the files
209
+ * so we can diff what's changing between turns. Writes raw JSON bodies,
210
+ * so contains user conversation content — don't enable on a shared host. */
211
+ debug_dump_bodies: false,
203
212
 
204
213
  /** Conservative mode (default ON). Disables every history-rewriting and
205
214
  * tool-array transform below that can invalidate the prompt-cache prefix
@@ -274,6 +283,29 @@ export const DEFAULT_CONFIG = {
274
283
  // response-stream rewriting that breaks normal tool-execution semantics.
275
284
  // Tracked as future work — not exposed as a config flag here.
276
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
+ },
277
309
  /** Output cap: default max_tokens to save context window. */
278
310
  output_cap: {
279
311
  enabled: true,
@@ -369,6 +401,7 @@ function createDefaultConfig() {
369
401
  adaptive_context: { ...DEFAULT_CONFIG.adaptive_context },
370
402
  willow_mode: { ...DEFAULT_CONFIG.willow_mode },
371
403
  token_economy: { ...DEFAULT_CONFIG.token_economy },
404
+ token_economy_strategies: { ...DEFAULT_CONFIG.token_economy_strategies },
372
405
  output_cap: { ...DEFAULT_CONFIG.output_cap },
373
406
  preconnect: { ...DEFAULT_CONFIG.preconnect },
374
407
  overflow_recovery: { ...DEFAULT_CONFIG.overflow_recovery },
@@ -656,6 +689,10 @@ function validateConfig(raw) {
656
689
  typeof te.redact_thinking === "boolean" ? te.redact_thinking : DEFAULT_CONFIG.token_economy.redact_thinking,
657
690
  context_hint: typeof te.context_hint === "boolean" ? te.context_hint : DEFAULT_CONFIG.token_economy.context_hint,
658
691
  conservative: typeof te.conservative === "boolean" ? te.conservative : DEFAULT_CONFIG.token_economy.conservative,
692
+ debug_dump_bodies:
693
+ typeof te.debug_dump_bodies === "boolean"
694
+ ? te.debug_dump_bodies
695
+ : DEFAULT_CONFIG.token_economy.debug_dump_bodies,
659
696
  ttl_thinking_strip:
660
697
  typeof te.ttl_thinking_strip === "boolean"
661
698
  ? te.ttl_thinking_strip
@@ -708,6 +745,45 @@ function validateConfig(raw) {
708
745
  };
709
746
  }
710
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
+
711
787
  // Output cap sub-config
712
788
  if (raw.output_cap && typeof raw.output_cap === "object") {
713
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.14",
3
+ "version": "0.1.18",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "main": "./index.mjs",
6
6
  "files": [