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 +346 -77
- package/lib/config.mjs +70 -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 ?? {}),
|
|
@@ -2882,11 +2896,36 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2882
2896
|
}
|
|
2883
2897
|
}
|
|
2884
2898
|
|
|
2885
|
-
//
|
|
2886
|
-
|
|
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
|
|
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`),
|
|
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
|
-
|
|
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
|
-
|
|
4020
|
-
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
6839
|
-
if (
|
|
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
|
|
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
|
-
*
|
|
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,
|
|
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
|
+
}
|