opencode-anthropic-fix 0.1.31 → 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.
Files changed (40) hide show
  1. package/README.md +2 -0
  2. package/cli.mjs +42 -35
  3. package/index.mjs +399 -3591
  4. package/lib/config.mjs +9 -10
  5. package/lib/config.test.mjs +298 -1
  6. package/lib/diagnose.mjs +184 -0
  7. package/lib/diagnose.test.mjs +122 -0
  8. package/lib/env.mjs +19 -0
  9. package/lib/env.test.mjs +25 -0
  10. package/lib/mimicry/cache.mjs +62 -0
  11. package/lib/mimicry/cache.test.mjs +112 -0
  12. package/lib/mimicry/headers.mjs +530 -0
  13. package/lib/mimicry/headers.test.mjs +50 -0
  14. package/lib/mimicry/models.mjs +146 -0
  15. package/lib/mimicry/models.test.mjs +132 -0
  16. package/lib/mimicry/request-body.mjs +684 -0
  17. package/lib/mimicry/request-body.test.mjs +34 -0
  18. package/lib/mimicry/request-helpers.mjs +243 -0
  19. package/lib/mimicry/request-helpers.test.mjs +155 -0
  20. package/lib/mimicry/response-stream.mjs +429 -0
  21. package/lib/mimicry/response-stream.test.mjs +127 -0
  22. package/lib/mimicry/system-prompt.mjs +638 -0
  23. package/lib/mimicry/system-prompt.test.mjs +73 -0
  24. package/lib/oauth.mjs +1 -1
  25. package/lib/oauth.test.mjs +8 -4
  26. package/lib/redact.mjs +147 -0
  27. package/lib/redact.test.mjs +154 -0
  28. package/lib/refresh-lock.test.mjs +52 -0
  29. package/lib/retry/overload-loop.mjs +83 -0
  30. package/lib/retry/overload-loop.test.mjs +107 -0
  31. package/lib/session-metrics.mjs +75 -0
  32. package/lib/session-metrics.test.mjs +67 -0
  33. package/lib/storage.test.mjs +82 -0
  34. package/lib/token-economy/microcompact.mjs +35 -0
  35. package/lib/token-economy/microcompact.test.mjs +28 -0
  36. package/lib/token-economy/transforms.mjs +637 -0
  37. package/lib/token-economy/transforms.test.mjs +192 -0
  38. package/lib/tuning.mjs +70 -0
  39. package/lib/tuning.test.mjs +89 -0
  40. 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
+ };