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 +344 -53
- package/lib/config.mjs +81 -5
- package/lib/context-hint-persist.mjs +68 -0
- package/lib/rolling-summarizer.mjs +215 -0
- package/package.json +1 -1
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:
|
|
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
|
-
|
|
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
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
|
|
4020
|
-
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
6813
|
-
if (
|
|
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
|
|
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
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
|
|
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
|
+
}
|