opencode-anthropic-fix 0.1.32 → 0.2.0
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/README.md +2 -0
- package/cli.mjs +42 -35
- package/index.mjs +399 -3591
- package/lib/config.test.mjs +298 -1
- package/lib/diagnose.mjs +184 -0
- package/lib/diagnose.test.mjs +122 -0
- package/lib/env.mjs +19 -0
- package/lib/env.test.mjs +25 -0
- package/lib/mimicry/cache.mjs +62 -0
- package/lib/mimicry/cache.test.mjs +112 -0
- package/lib/mimicry/headers.mjs +530 -0
- package/lib/mimicry/headers.test.mjs +50 -0
- package/lib/mimicry/models.mjs +146 -0
- package/lib/mimicry/models.test.mjs +132 -0
- package/lib/mimicry/request-body.mjs +684 -0
- package/lib/mimicry/request-body.test.mjs +34 -0
- package/lib/mimicry/request-helpers.mjs +243 -0
- package/lib/mimicry/request-helpers.test.mjs +155 -0
- package/lib/mimicry/response-stream.mjs +429 -0
- package/lib/mimicry/response-stream.test.mjs +127 -0
- package/lib/mimicry/system-prompt.mjs +638 -0
- package/lib/mimicry/system-prompt.test.mjs +73 -0
- package/lib/redact.mjs +147 -0
- package/lib/redact.test.mjs +154 -0
- package/lib/refresh-lock.test.mjs +52 -0
- package/lib/retry/overload-loop.mjs +83 -0
- package/lib/retry/overload-loop.test.mjs +107 -0
- package/lib/session-metrics.mjs +75 -0
- package/lib/session-metrics.test.mjs +67 -0
- package/lib/storage.test.mjs +82 -0
- package/lib/token-economy/microcompact.mjs +35 -0
- package/lib/token-economy/microcompact.test.mjs +28 -0
- package/lib/token-economy/transforms.mjs +637 -0
- package/lib/token-economy/transforms.test.mjs +192 -0
- package/lib/tuning.mjs +70 -0
- package/lib/tuning.test.mjs +89 -0
- package/package.json +4 -1
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse natural-language budget expressions from user messages.
|
|
3
|
+
* Supports: +500k, 500,000, 2M, 2 million, "spend 500k", "use 2M tokens", "budget: 1M".
|
|
4
|
+
* Only scans the last user message to avoid re-triggering from history.
|
|
5
|
+
*
|
|
6
|
+
* @param {Array<{role: string, content: string | Array<{type: string, text?: string}>}>} messages
|
|
7
|
+
* @returns {number} Parsed token count, or 0 if no budget expression found
|
|
8
|
+
*/
|
|
9
|
+
function parseNaturalLanguageBudget(messages) {
|
|
10
|
+
if (!Array.isArray(messages) || messages.length === 0) return 0;
|
|
11
|
+
|
|
12
|
+
// Find the last user message
|
|
13
|
+
let lastUserText = "";
|
|
14
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
15
|
+
if (messages[i].role === "user") {
|
|
16
|
+
const content = messages[i].content;
|
|
17
|
+
if (typeof content === "string") {
|
|
18
|
+
lastUserText = content;
|
|
19
|
+
} else if (Array.isArray(content)) {
|
|
20
|
+
lastUserText = content
|
|
21
|
+
.filter((b) => b.type === "text" && typeof b.text === "string")
|
|
22
|
+
.map((b) => b.text)
|
|
23
|
+
.join(" ");
|
|
24
|
+
}
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (!lastUserText) return 0;
|
|
29
|
+
|
|
30
|
+
// Patterns ordered from most specific to least
|
|
31
|
+
const patterns = [
|
|
32
|
+
/\buse\s+(\d[\d,]*)\s*([mk])\s*tokens?\b/i,
|
|
33
|
+
/\bspend\s+(\d[\d,]*)\s*([mk])?\b/i,
|
|
34
|
+
/\bbudget[:\s]+(\d[\d,]*)\s*([mk])?\b/i,
|
|
35
|
+
/\+(\d[\d,]*)\s*([mk])\b/i,
|
|
36
|
+
/\b(\d[\d,]*)\s*million\s*tokens?\b/i,
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
for (const re of patterns) {
|
|
40
|
+
const m = lastUserText.match(re);
|
|
41
|
+
if (m) {
|
|
42
|
+
const num = parseFloat(m[1].replace(/,/g, ""));
|
|
43
|
+
if (isNaN(num) || num <= 0) continue;
|
|
44
|
+
const suffix = (m[2] || "").toLowerCase();
|
|
45
|
+
if (re === patterns[4]) {
|
|
46
|
+
// "N million tokens" — the regex has no suffix group
|
|
47
|
+
return num * 1_000_000;
|
|
48
|
+
}
|
|
49
|
+
if (suffix === "m") return num * 1_000_000;
|
|
50
|
+
if (suffix === "k") return num * 1_000;
|
|
51
|
+
// No suffix — treat as absolute count
|
|
52
|
+
return num;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Inject a token budget status block into the system prompt.
|
|
60
|
+
* Prepends a text block with budget progress and threshold info.
|
|
61
|
+
*
|
|
62
|
+
* @param {Array<{type: string, text?: string, [k: string]: any}>} systemBlocks
|
|
63
|
+
* @param {{limit: number, used: number, continuations: number}} budget
|
|
64
|
+
* @param {number} threshold - Completion threshold (0-1, e.g. 0.9)
|
|
65
|
+
* @returns {Array<{type: string, text?: string, [k: string]: any}>}
|
|
66
|
+
*/
|
|
67
|
+
function injectTokenBudgetBlock(systemBlocks, budget, threshold) {
|
|
68
|
+
if (!budget || budget.limit <= 0) return systemBlocks;
|
|
69
|
+
const pct = ((budget.used / budget.limit) * 100).toFixed(0);
|
|
70
|
+
const thresholdTokens = Math.round(budget.limit * threshold);
|
|
71
|
+
const remaining = Math.max(0, budget.limit - budget.used);
|
|
72
|
+
const block = {
|
|
73
|
+
type: "text",
|
|
74
|
+
text: `Token budget: ${budget.used.toLocaleString()}/${budget.limit.toLocaleString()} tokens used (${pct}%). Stop generating at ${thresholdTokens.toLocaleString()} tokens. Remaining: ${remaining.toLocaleString()} tokens.`,
|
|
75
|
+
};
|
|
76
|
+
return [block, ...(systemBlocks || [])];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Estimate prompt tokens from an already-parsed request body object.
|
|
81
|
+
* Avoids redundant JSON.parse when the caller already has the parsed object.
|
|
82
|
+
* @param {object} parsed - The parsed request body
|
|
83
|
+
* @returns {number} Estimated token count
|
|
84
|
+
*/
|
|
85
|
+
function estimatePromptTokensFromParsed(parsed) {
|
|
86
|
+
if (!parsed || typeof parsed !== "object") return 0;
|
|
87
|
+
let charCount = 0;
|
|
88
|
+
|
|
89
|
+
// Count system prompt text
|
|
90
|
+
if (Array.isArray(parsed.system)) {
|
|
91
|
+
for (const block of parsed.system) {
|
|
92
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
93
|
+
charCount += block.text.length;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} else if (typeof parsed.system === "string") {
|
|
97
|
+
charCount += parsed.system.length;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Count messages content (text blocks, tool results) — skip tool definitions
|
|
101
|
+
if (Array.isArray(parsed.messages)) {
|
|
102
|
+
for (const msg of parsed.messages) {
|
|
103
|
+
if (typeof msg.content === "string") {
|
|
104
|
+
charCount += msg.content.length;
|
|
105
|
+
} else if (Array.isArray(msg.content)) {
|
|
106
|
+
for (const block of msg.content) {
|
|
107
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
108
|
+
charCount += block.text.length;
|
|
109
|
+
} else if (block.type === "tool_result" && typeof block.content === "string") {
|
|
110
|
+
charCount += block.content.length;
|
|
111
|
+
} else if (block.type === "tool_use") {
|
|
112
|
+
// Count serialized input as tokens
|
|
113
|
+
charCount += JSON.stringify(block.input || {}).length;
|
|
114
|
+
} else if (block.type === "image" || block.type === "image_url") {
|
|
115
|
+
// Images: ~2000 tokens per image (Anthropic tile-based counting)
|
|
116
|
+
charCount += 8000; // 2000 tokens * 4 chars/token
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 4 chars/token heuristic for text content (reasonable for English + code + JSON)
|
|
124
|
+
return Math.ceil(charCount / 4);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Apply context-hint compaction to a message array. Mirrors real CC's
|
|
129
|
+
* `applyHintEdits` (d85) + `qD4` microcompact: clears thinking/redacted_thinking
|
|
130
|
+
* blocks from assistant messages and replaces old tool_result content with a
|
|
131
|
+
* placeholder, keeping the last few tool results intact. Used on 422/424
|
|
132
|
+
* responses before retrying.
|
|
133
|
+
*
|
|
134
|
+
* @param {Array} messages — Parsed messages array
|
|
135
|
+
* @param {object} [opts]
|
|
136
|
+
* @param {number} [opts.keepRecentToolResults=8] — How many most-recent tool_result blocks to preserve verbatim
|
|
137
|
+
* @param {string} [opts.clearedPlaceholder] — Replacement content for older tool_result blocks
|
|
138
|
+
* @returns {{ messages: Array, changed: boolean, stats: { thinkingCleared: number, toolResultsCleared: number } }}
|
|
139
|
+
*/
|
|
140
|
+
function applyContextHintCompaction(messages, opts = {}) {
|
|
141
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
142
|
+
return { messages, changed: false, stats: { thinkingCleared: 0, toolResultsCleared: 0 } };
|
|
143
|
+
}
|
|
144
|
+
const keepRecent = opts.keepRecentToolResults ?? 8;
|
|
145
|
+
const placeholder = opts.clearedPlaceholder ?? "[Old tool result content cleared]";
|
|
146
|
+
|
|
147
|
+
// First pass: count tool_result blocks so we know which are "old" vs "recent".
|
|
148
|
+
const toolResultRefs = [];
|
|
149
|
+
for (let i = 0; i < messages.length; i++) {
|
|
150
|
+
const msg = messages[i];
|
|
151
|
+
if (msg.role !== "user" || !Array.isArray(msg.content)) continue;
|
|
152
|
+
for (let j = 0; j < msg.content.length; j++) {
|
|
153
|
+
if (msg.content[j]?.type === "tool_result") {
|
|
154
|
+
toolResultRefs.push({ msgIdx: i, blockIdx: j });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const oldCutoff = Math.max(0, toolResultRefs.length - keepRecent);
|
|
159
|
+
const oldSet = new Set(toolResultRefs.slice(0, oldCutoff).map((r) => `${r.msgIdx}:${r.blockIdx}`));
|
|
160
|
+
|
|
161
|
+
let thinkingCleared = 0;
|
|
162
|
+
let toolResultsCleared = 0;
|
|
163
|
+
const out = messages.map((msg, i) => {
|
|
164
|
+
if (!Array.isArray(msg.content)) return msg;
|
|
165
|
+
if (msg.role === "assistant") {
|
|
166
|
+
const newContent = msg.content.filter((block) => {
|
|
167
|
+
if (block?.type === "thinking" || block?.type === "redacted_thinking") {
|
|
168
|
+
thinkingCleared += 1;
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
return true;
|
|
172
|
+
});
|
|
173
|
+
if (newContent.length !== msg.content.length) {
|
|
174
|
+
return { ...msg, content: newContent };
|
|
175
|
+
}
|
|
176
|
+
return msg;
|
|
177
|
+
}
|
|
178
|
+
if (msg.role === "user") {
|
|
179
|
+
let mutated = false;
|
|
180
|
+
const newContent = msg.content.map((block, j) => {
|
|
181
|
+
if (block?.type !== "tool_result") return block;
|
|
182
|
+
const key = `${i}:${j}`;
|
|
183
|
+
if (!oldSet.has(key)) return block;
|
|
184
|
+
toolResultsCleared += 1;
|
|
185
|
+
mutated = true;
|
|
186
|
+
// Replace content with placeholder, preserve tool_use_id
|
|
187
|
+
return {
|
|
188
|
+
...block,
|
|
189
|
+
content: placeholder,
|
|
190
|
+
};
|
|
191
|
+
});
|
|
192
|
+
return mutated ? { ...msg, content: newContent } : msg;
|
|
193
|
+
}
|
|
194
|
+
return msg;
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
messages: out,
|
|
199
|
+
changed: thinkingCleared > 0 || toolResultsCleared > 0,
|
|
200
|
+
stats: { thinkingCleared, toolResultsCleared },
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Tools whose output is trivially reproducible by re-running with the same
|
|
206
|
+
* arguments. Stateful tools (bash, edit, write, etc.) never dedupe — their
|
|
207
|
+
* outputs may reflect non-idempotent side effects that the transcript needs
|
|
208
|
+
* to preserve.
|
|
209
|
+
*/
|
|
210
|
+
const REPRODUCIBLE_TOOL_NAMES = new Set(["read", "grep", "glob", "ls", "list", "find"]);
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Title-case a tool name for the stub string ("read" → "Read", "grep" → "Grep").
|
|
214
|
+
* Pure; no locale.
|
|
215
|
+
*/
|
|
216
|
+
function titleCaseToolName(name) {
|
|
217
|
+
if (typeof name !== "string" || name.length === 0) return "";
|
|
218
|
+
return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Minimal deterministic stable-stringify for args canonicalization.
|
|
223
|
+
* Sorts object keys at every depth before serialization so that
|
|
224
|
+
* `{a:1,b:2}` and `{b:2,a:1}` produce the same string.
|
|
225
|
+
*
|
|
226
|
+
* Arrays are traversed in order. Non-plain objects (Date, Map, etc.) fall
|
|
227
|
+
* through to `JSON.stringify` — we don't expect them in tool args, but the
|
|
228
|
+
* fallback keeps the function total.
|
|
229
|
+
*/
|
|
230
|
+
function stableStringifyForDedupe(value) {
|
|
231
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
232
|
+
if (Array.isArray(value)) {
|
|
233
|
+
return "[" + value.map((v) => stableStringifyForDedupe(v)).join(",") + "]";
|
|
234
|
+
}
|
|
235
|
+
const keys = Object.keys(value).sort();
|
|
236
|
+
const parts = [];
|
|
237
|
+
for (const k of keys) {
|
|
238
|
+
parts.push(JSON.stringify(k) + ":" + stableStringifyForDedupe(value[k]));
|
|
239
|
+
}
|
|
240
|
+
return "{" + parts.join(",") + "}";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Session-wide tool-result dedupe. Pure over `messages`.
|
|
245
|
+
*
|
|
246
|
+
* Walks the conversation once to collect every `tool_use` + `tool_result`
|
|
247
|
+
* pair where the tool is in `REPRODUCIBLE_TOOL_NAMES` (case-insensitive).
|
|
248
|
+
* Groups them by `(toolName, stableStringify(args))`. For every group
|
|
249
|
+
* containing more than one call, keeps the LATEST result verbatim and
|
|
250
|
+
* replaces each earlier result's `content` with a stub:
|
|
251
|
+
*
|
|
252
|
+
* `[<ToolTitleCase> of <argsKey> superseded by later read at msg #<N>]`
|
|
253
|
+
*
|
|
254
|
+
* where `N` is the message index of the latest call's user-message.
|
|
255
|
+
*
|
|
256
|
+
* `tool_use_id` and any other `tool_result` fields are preserved. Assistant
|
|
257
|
+
* `tool_use` blocks are NEVER modified — only the paired user `tool_result`.
|
|
258
|
+
*
|
|
259
|
+
* Cache-stable: decision is a pure function of message history, so rerunning
|
|
260
|
+
* over an unchanged prefix yields byte-identical output.
|
|
261
|
+
*
|
|
262
|
+
* @param {Array} messages — Parsed messages array
|
|
263
|
+
* @returns {{ messages: Array, changed: boolean, stats: { deduped: number } }}
|
|
264
|
+
*/
|
|
265
|
+
function applySessionToolResultDedupe(messages) {
|
|
266
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
267
|
+
return { messages, changed: false, stats: { deduped: 0 } };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Pass 1: build tool_use_id → { name, argsKey } for reproducible tools.
|
|
271
|
+
const idToMeta = new Map();
|
|
272
|
+
for (let i = 0; i < messages.length; i++) {
|
|
273
|
+
const msg = messages[i];
|
|
274
|
+
if (msg?.role !== "assistant" || !Array.isArray(msg.content)) continue;
|
|
275
|
+
for (const block of msg.content) {
|
|
276
|
+
if (block?.type !== "tool_use") continue;
|
|
277
|
+
const name = typeof block.name === "string" ? block.name : "";
|
|
278
|
+
if (!REPRODUCIBLE_TOOL_NAMES.has(name.toLowerCase())) continue;
|
|
279
|
+
const argsKey = stableStringifyForDedupe(block.input ?? {});
|
|
280
|
+
idToMeta.set(block.id, { name, argsKey });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Pass 2: collect tool_result locations by group key, preserving order.
|
|
285
|
+
/** @type {Map<string, Array<{msgIdx: number, blockIdx: number, toolUseId: string, name: string, argsKey: string}>>} */
|
|
286
|
+
const groups = new Map();
|
|
287
|
+
for (let i = 0; i < messages.length; i++) {
|
|
288
|
+
const msg = messages[i];
|
|
289
|
+
if (msg?.role !== "user" || !Array.isArray(msg.content)) continue;
|
|
290
|
+
for (let j = 0; j < msg.content.length; j++) {
|
|
291
|
+
const block = msg.content[j];
|
|
292
|
+
if (block?.type !== "tool_result") continue;
|
|
293
|
+
const meta = idToMeta.get(block.tool_use_id);
|
|
294
|
+
if (!meta) continue;
|
|
295
|
+
const groupKey = meta.name.toLowerCase() + "\u0000" + meta.argsKey;
|
|
296
|
+
let arr = groups.get(groupKey);
|
|
297
|
+
if (!arr) {
|
|
298
|
+
arr = [];
|
|
299
|
+
groups.set(groupKey, arr);
|
|
300
|
+
}
|
|
301
|
+
arr.push({
|
|
302
|
+
msgIdx: i,
|
|
303
|
+
blockIdx: j,
|
|
304
|
+
toolUseId: block.tool_use_id,
|
|
305
|
+
name: meta.name,
|
|
306
|
+
argsKey: meta.argsKey,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Build supersede map: "msgIdx:blockIdx" → stub string.
|
|
312
|
+
const supersedeStubs = new Map();
|
|
313
|
+
let deduped = 0;
|
|
314
|
+
// Deterministic iteration: sort group entries by group key before processing.
|
|
315
|
+
const sortedEntries = Array.from(groups.entries()).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
316
|
+
for (const [, occurrences] of sortedEntries) {
|
|
317
|
+
if (occurrences.length < 2) continue;
|
|
318
|
+
const latest = occurrences[occurrences.length - 1];
|
|
319
|
+
const stub =
|
|
320
|
+
"[" +
|
|
321
|
+
titleCaseToolName(latest.name) +
|
|
322
|
+
" of " +
|
|
323
|
+
latest.argsKey +
|
|
324
|
+
" superseded by later read at msg #" +
|
|
325
|
+
latest.msgIdx +
|
|
326
|
+
"]";
|
|
327
|
+
for (let k = 0; k < occurrences.length - 1; k++) {
|
|
328
|
+
const occ = occurrences[k];
|
|
329
|
+
supersedeStubs.set(occ.msgIdx + ":" + occ.blockIdx, stub);
|
|
330
|
+
deduped += 1;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (deduped === 0) {
|
|
335
|
+
return { messages, changed: false, stats: { deduped: 0 } };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Pass 3: rewrite. Preserve tool_use_id and other fields.
|
|
339
|
+
const out = messages.map((msg, i) => {
|
|
340
|
+
if (msg?.role !== "user" || !Array.isArray(msg.content)) return msg;
|
|
341
|
+
let mutated = false;
|
|
342
|
+
const newContent = msg.content.map((block, j) => {
|
|
343
|
+
if (block?.type !== "tool_result") return block;
|
|
344
|
+
const stub = supersedeStubs.get(i + ":" + j);
|
|
345
|
+
if (!stub) return block;
|
|
346
|
+
mutated = true;
|
|
347
|
+
return { ...block, content: stub };
|
|
348
|
+
});
|
|
349
|
+
return mutated ? { ...msg, content: newContent } : msg;
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
return { messages: out, changed: true, stats: { deduped } };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Dispatch wrapper: only apply `applySessionToolResultDedupe` when the
|
|
357
|
+
* `token_economy_strategies.tool_result_dedupe_session_wide` flag is true.
|
|
358
|
+
* When disabled, returns the input `messages` by identity (not a copy) so
|
|
359
|
+
* callers can cheaply detect "no-op" with reference equality.
|
|
360
|
+
*
|
|
361
|
+
* @param {Array} messages
|
|
362
|
+
* @param {object} [config]
|
|
363
|
+
* @returns {Array}
|
|
364
|
+
*/
|
|
365
|
+
function maybeApplySessionToolResultDedupe(messages, config) {
|
|
366
|
+
const flag = config?.token_economy_strategies?.tool_result_dedupe_session_wide;
|
|
367
|
+
if (flag !== true) return messages;
|
|
368
|
+
const result = applySessionToolResultDedupe(messages);
|
|
369
|
+
return result.messages;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* TTL-based thinking strip. When the time since the last strip exceeds the
|
|
374
|
+
* cache TTL (roughly the point at which the prompt prefix cache would expire),
|
|
375
|
+
* remove all `thinking` / `redacted_thinking` blocks from prior assistant
|
|
376
|
+
* messages. Mirrors CC's `logThinkingClearLatched("ttl", ...)`.
|
|
377
|
+
*
|
|
378
|
+
* We keep the MOST RECENT assistant's thinking intact — chain-of-thought
|
|
379
|
+
* continuity for the current turn matters; older ones don't.
|
|
380
|
+
*
|
|
381
|
+
* @param {Array} messages
|
|
382
|
+
* @param {{ lastClearMs: number, ttlMs: number, now?: number }} ctx
|
|
383
|
+
* @returns {{ messages: Array, changed: boolean, cleared: number, ranStripAt: number }}
|
|
384
|
+
*/
|
|
385
|
+
function applyTtlThinkingStrip(messages, ctx) {
|
|
386
|
+
const now = ctx.now ?? Date.now();
|
|
387
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
388
|
+
return { messages, changed: false, cleared: 0, ranStripAt: ctx.lastClearMs };
|
|
389
|
+
}
|
|
390
|
+
if (ctx.lastClearMs > 0 && now - ctx.lastClearMs < ctx.ttlMs) {
|
|
391
|
+
return { messages, changed: false, cleared: 0, ranStripAt: ctx.lastClearMs };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Find the last assistant message index — preserve its thinking.
|
|
395
|
+
let lastAsstIdx = -1;
|
|
396
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
397
|
+
if (messages[i]?.role === "assistant") {
|
|
398
|
+
lastAsstIdx = i;
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
let cleared = 0;
|
|
404
|
+
const out = messages.map((msg, i) => {
|
|
405
|
+
if (msg.role !== "assistant" || i === lastAsstIdx || !Array.isArray(msg.content)) {
|
|
406
|
+
return msg;
|
|
407
|
+
}
|
|
408
|
+
const newContent = msg.content.filter((b) => {
|
|
409
|
+
if (b?.type === "thinking" || b?.type === "redacted_thinking") {
|
|
410
|
+
cleared += 1;
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
return true;
|
|
414
|
+
});
|
|
415
|
+
return newContent.length !== msg.content.length ? { ...msg, content: newContent } : msg;
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
return { messages: out, changed: cleared > 0, cleared, ranStripAt: cleared > 0 ? now : ctx.lastClearMs };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Proactive microcompact — client-side, runs BEFORE the request goes out.
|
|
423
|
+
* At or above `percent` of the model's context window, replace old
|
|
424
|
+
* tool_result.content with a placeholder (keeping last `keepRecent` verbatim).
|
|
425
|
+
*
|
|
426
|
+
* Returns the new messages array + change stats.
|
|
427
|
+
*
|
|
428
|
+
* @param {Array} messages
|
|
429
|
+
* @param {{ estimatedTokens: number, contextWindow: number, percent: number, keepRecent: number }} ctx
|
|
430
|
+
* @returns {{ messages: Array, changed: boolean, cleared: number, triggered: boolean }}
|
|
431
|
+
*/
|
|
432
|
+
function applyProactiveMicrocompact(messages, ctx) {
|
|
433
|
+
const threshold = ctx.contextWindow * (ctx.percent / 100);
|
|
434
|
+
if (ctx.estimatedTokens < threshold) {
|
|
435
|
+
return { messages, changed: false, cleared: 0, triggered: false };
|
|
436
|
+
}
|
|
437
|
+
const result = applyContextHintCompaction(messages, { keepRecentToolResults: ctx.keepRecent });
|
|
438
|
+
return {
|
|
439
|
+
messages: result.messages,
|
|
440
|
+
changed: result.changed,
|
|
441
|
+
cleared: result.stats.toolResultsCleared,
|
|
442
|
+
triggered: true,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Stable tool ordering — sort tools by name so the system-prompt prefix stays
|
|
448
|
+
* cache-stable across turns. Safe: tool semantics are name-based, not index-based.
|
|
449
|
+
*
|
|
450
|
+
* @param {any[]} tools
|
|
451
|
+
* @returns {any[]}
|
|
452
|
+
*/
|
|
453
|
+
function applyStableToolOrdering(tools) {
|
|
454
|
+
if (!Array.isArray(tools) || tools.length < 2) return tools;
|
|
455
|
+
// Preserve a pinned "first" for tools whose position is load-bearing (none today).
|
|
456
|
+
return [...tools].sort((a, b) => {
|
|
457
|
+
const an = typeof a?.name === "string" ? a.name : "";
|
|
458
|
+
const bn = typeof b?.name === "string" ? b.name : "";
|
|
459
|
+
return an < bn ? -1 : an > bn ? 1 : 0;
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Tool schema deferral — replace the `input_schema` of deferred tools with a
|
|
465
|
+
* minimal placeholder until the tool has been invoked in this session.
|
|
466
|
+
*
|
|
467
|
+
* @param {any[]} tools
|
|
468
|
+
* @param {{ deferred: Set<string>, invoked: Set<string> }} ctx
|
|
469
|
+
* @returns {{ tools: any[], deferredCount: number }}
|
|
470
|
+
*/
|
|
471
|
+
function applyToolSchemaDeferral(tools, ctx) {
|
|
472
|
+
if (!Array.isArray(tools) || ctx.deferred.size === 0) {
|
|
473
|
+
return { tools, deferredCount: 0 };
|
|
474
|
+
}
|
|
475
|
+
let deferredCount = 0;
|
|
476
|
+
const out = tools.map((t) => {
|
|
477
|
+
const name = typeof t?.name === "string" ? t.name : "";
|
|
478
|
+
if (!ctx.deferred.has(name) || ctx.invoked.has(name)) return t;
|
|
479
|
+
deferredCount += 1;
|
|
480
|
+
// Minimal schema — `type:object` with no properties is accepted by the API.
|
|
481
|
+
return {
|
|
482
|
+
...t,
|
|
483
|
+
input_schema: { type: "object", properties: {}, additionalProperties: true },
|
|
484
|
+
};
|
|
485
|
+
});
|
|
486
|
+
return { tools: out, deferredCount };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Adaptive thinking — zero the thinking budget for trivially simple follow-ups.
|
|
491
|
+
* "Simple" heuristic: most recent user message is short (<200 chars), no file
|
|
492
|
+
* references, and the conversation is past turn 1 (so we have context).
|
|
493
|
+
*
|
|
494
|
+
* @param {any} parsed Parsed request body (mutated in place if simple)
|
|
495
|
+
* @returns {{ applied: boolean, previousBudget: number | null }}
|
|
496
|
+
*/
|
|
497
|
+
function applyAdaptiveThinkingZero(parsed) {
|
|
498
|
+
if (!parsed || !parsed.thinking || parsed.thinking.type !== "enabled") {
|
|
499
|
+
return { applied: false, previousBudget: null };
|
|
500
|
+
}
|
|
501
|
+
const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
|
|
502
|
+
if (messages.length < 2) return { applied: false, previousBudget: null };
|
|
503
|
+
const last = messages[messages.length - 1];
|
|
504
|
+
if (!last || last.role !== "user") return { applied: false, previousBudget: null };
|
|
505
|
+
|
|
506
|
+
let userText = "";
|
|
507
|
+
if (typeof last.content === "string") userText = last.content;
|
|
508
|
+
else if (Array.isArray(last.content)) {
|
|
509
|
+
for (const b of last.content) {
|
|
510
|
+
if (b?.type === "text" && typeof b.text === "string") userText += b.text;
|
|
511
|
+
if (b?.type === "tool_result") return { applied: false, previousBudget: null };
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (userText.length > 200) return { applied: false, previousBudget: null };
|
|
515
|
+
if (/\b(analyze|refactor|design|review|audit|plan)\b/i.test(userText)) {
|
|
516
|
+
return { applied: false, previousBudget: null };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const previousBudget = typeof parsed.thinking.budget_tokens === "number" ? parsed.thinking.budget_tokens : null;
|
|
520
|
+
// "Zero" means remove thinking entirely — API disallows budget_tokens:0.
|
|
521
|
+
delete parsed.thinking;
|
|
522
|
+
if (typeof parsed.temperature !== "number") parsed.temperature = 1;
|
|
523
|
+
return { applied: true, previousBudget };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Cross-turn tool_result dedupe — when the same (tool name, input) pair
|
|
528
|
+
* appeared earlier in the conversation, replace the later result content
|
|
529
|
+
* with a pointer string. Safe-set only: Read, Grep, Glob, LS.
|
|
530
|
+
*
|
|
531
|
+
* @param {Array} messages
|
|
532
|
+
* @param {{ seen: Map<string, string>, safeTools: Set<string> }} ctx
|
|
533
|
+
* @returns {{ messages: Array, changed: boolean, deduped: number }}
|
|
534
|
+
*/
|
|
535
|
+
function applyToolResultDedupe(messages, ctx) {
|
|
536
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
537
|
+
return { messages, changed: false, deduped: 0 };
|
|
538
|
+
}
|
|
539
|
+
// First pass: build map of tool_use_id → { name, inputHash } from assistant messages.
|
|
540
|
+
const idToKey = new Map();
|
|
541
|
+
for (const msg of messages) {
|
|
542
|
+
if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
|
|
543
|
+
for (const b of msg.content) {
|
|
544
|
+
if (b?.type !== "tool_use") continue;
|
|
545
|
+
if (!ctx.safeTools.has(b.name)) continue;
|
|
546
|
+
const inputStr = JSON.stringify(b.input ?? {});
|
|
547
|
+
idToKey.set(b.id, `${b.name}::${inputStr}`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
let deduped = 0;
|
|
552
|
+
const out = messages.map((msg) => {
|
|
553
|
+
if (msg.role !== "user" || !Array.isArray(msg.content)) return msg;
|
|
554
|
+
let mutated = false;
|
|
555
|
+
const newContent = msg.content.map((b) => {
|
|
556
|
+
if (b?.type !== "tool_result") return b;
|
|
557
|
+
const key = idToKey.get(b.tool_use_id);
|
|
558
|
+
if (!key) return b;
|
|
559
|
+
const firstSeen = ctx.seen.get(key);
|
|
560
|
+
if (firstSeen && firstSeen !== b.tool_use_id) {
|
|
561
|
+
deduped += 1;
|
|
562
|
+
mutated = true;
|
|
563
|
+
return { ...b, content: `[Identical to tool_use_id=${firstSeen}]` };
|
|
564
|
+
}
|
|
565
|
+
if (!firstSeen) ctx.seen.set(key, b.tool_use_id);
|
|
566
|
+
return b;
|
|
567
|
+
});
|
|
568
|
+
return mutated ? { ...msg, content: newContent } : msg;
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
return { messages: out, changed: deduped > 0, deduped };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Trailing-summary trimmer — strip the final text block of past assistant
|
|
576
|
+
* messages if it looks like a summary (ends with "...done" / "summary" /
|
|
577
|
+
* numbered list / "I've X'd Y"). Only applies to messages past the last one.
|
|
578
|
+
*
|
|
579
|
+
* @param {Array} messages
|
|
580
|
+
* @returns {{ messages: Array, changed: boolean, trimmed: number }}
|
|
581
|
+
*/
|
|
582
|
+
function applyTrailingSummaryTrim(messages) {
|
|
583
|
+
if (!Array.isArray(messages) || messages.length < 2) {
|
|
584
|
+
return { messages, changed: false, trimmed: 0 };
|
|
585
|
+
}
|
|
586
|
+
const SUMMARY_PATTERNS = [
|
|
587
|
+
/\b(summary|summar(y|ised|ized)):/i,
|
|
588
|
+
/\bto summari[sz]e\b/i,
|
|
589
|
+
/^\s*in (summary|short|brief)/im,
|
|
590
|
+
/\bi['']ve (done|completed|implemented|added|updated|fixed) /i,
|
|
591
|
+
/\bthat's it\b/i,
|
|
592
|
+
];
|
|
593
|
+
|
|
594
|
+
let lastAsstIdx = -1;
|
|
595
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
596
|
+
if (messages[i]?.role === "assistant") {
|
|
597
|
+
lastAsstIdx = i;
|
|
598
|
+
break;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
let trimmed = 0;
|
|
603
|
+
const out = messages.map((msg, i) => {
|
|
604
|
+
if (msg.role !== "assistant" || i === lastAsstIdx) return msg;
|
|
605
|
+
if (!Array.isArray(msg.content)) return msg;
|
|
606
|
+
|
|
607
|
+
const last = msg.content[msg.content.length - 1];
|
|
608
|
+
if (!last || last.type !== "text" || typeof last.text !== "string") return msg;
|
|
609
|
+
const text = last.text;
|
|
610
|
+
if (text.length < 80) return msg;
|
|
611
|
+
const isSummary = SUMMARY_PATTERNS.some((p) => p.test(text));
|
|
612
|
+
if (!isSummary) return msg;
|
|
613
|
+
|
|
614
|
+
trimmed += 1;
|
|
615
|
+
const newContent = msg.content.slice(0, -1);
|
|
616
|
+
if (newContent.length === 0) return msg; // keep at least one block
|
|
617
|
+
return { ...msg, content: newContent };
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
return { messages: out, changed: trimmed > 0, trimmed };
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export {
|
|
624
|
+
applyAdaptiveThinkingZero,
|
|
625
|
+
applyContextHintCompaction,
|
|
626
|
+
applyProactiveMicrocompact,
|
|
627
|
+
applySessionToolResultDedupe,
|
|
628
|
+
applyStableToolOrdering,
|
|
629
|
+
applyToolResultDedupe,
|
|
630
|
+
applyToolSchemaDeferral,
|
|
631
|
+
applyTrailingSummaryTrim,
|
|
632
|
+
applyTtlThinkingStrip,
|
|
633
|
+
estimatePromptTokensFromParsed,
|
|
634
|
+
injectTokenBudgetBlock,
|
|
635
|
+
maybeApplySessionToolResultDedupe,
|
|
636
|
+
parseNaturalLanguageBudget,
|
|
637
|
+
};
|