openzoo 0.49.18 → 0.50.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/lib/spill.js DELETED
@@ -1,2031 +0,0 @@
1
- /**
2
- * Spill-side bookkeeping that used to live (and die) inside proxy.js.
3
- *
4
- * Three things the status line needs, and that a live session was not getting:
5
- * 1. A corpus ledger that accumulates bind + append + file bytes, persisted
6
- * so a sidecar restart still knows how big the bound context is.
7
- * 2. File-path extraction that matches Claude Code / Anthropic tool_use
8
- * (and the OpenAI shape those requests are translated into).
9
- * 3. Counters for spilled calls, file-bind events, and offloaded chars —
10
- * incremented on the spill path, never on a pass-through.
11
- *
12
- * Kept as pure-enough helpers so test/spill.test.js can cover them without
13
- * standing up the proxy or the gateway.
14
- */
15
- import fs from 'node:fs';
16
- import os from 'node:os';
17
- import path from 'node:path';
18
-
19
- const PATH_KEYS = new Set([
20
- 'file_path', 'path', 'target_file', 'filePath', 'filename', 'file',
21
- 'targetFile', 'filepath',
22
- ]);
23
- const PATH_ARRAY_KEYS = new Set(['files', 'file_paths', 'paths', 'filePaths']);
24
-
25
- export function boundCharsFile(home = os.homedir()) {
26
- return process.env.OPENZOO_BOUND_CHARS_PATH
27
- || path.join(home, '.openzoo', 'bound-chars.json');
28
- }
29
-
30
- /**
31
- * Load ~/.openzoo/bound-chars.json into the maps the proxy holds.
32
- * Missing / corrupt file is a cold start, not an error.
33
- */
34
- export function loadBoundChars(boundChars, extra = {}) {
35
- const file = extra.file || boundCharsFile(extra.home);
36
- let raw;
37
- try { raw = fs.readFileSync(file, 'utf8'); } catch { return { ok: false, reason: 'missing' }; }
38
- let data;
39
- try { data = JSON.parse(raw); } catch { return { ok: false, reason: 'corrupt' }; }
40
- const chars = data?.chars && typeof data.chars === 'object' ? data.chars
41
- : (data && typeof data === 'object' && !data.sessions && !data.files ? data : {});
42
- for (const [id, n] of Object.entries(chars)) {
43
- const v = Number(n);
44
- if (id && Number.isFinite(v) && v > 0) boundChars.set(id, v);
45
- }
46
- if (extra.sessions && data?.sessions && typeof data.sessions === 'object') {
47
- for (const [k, v] of Object.entries(data.sessions)) {
48
- if (k && v && typeof v.contextId === 'string') extra.sessions.set(k, v);
49
- }
50
- }
51
- if (extra.boundFiles && Array.isArray(data?.files)) {
52
- for (const key of data.files) {
53
- if (typeof key === 'string') extra.boundFiles.add(key);
54
- }
55
- }
56
- return { ok: true, contexts: boundChars.size };
57
- }
58
-
59
- export function persistBoundChars(boundChars, extra = {}) {
60
- const file = extra.file || boundCharsFile(extra.home);
61
- try {
62
- fs.mkdirSync(path.dirname(file), { recursive: true });
63
- const payload = {
64
- chars: Object.fromEntries(boundChars),
65
- };
66
- if (extra.sessions) payload.sessions = Object.fromEntries(extra.sessions);
67
- if (extra.boundFiles) payload.files = [...extra.boundFiles];
68
- const tmp = `${file}.tmp`;
69
- fs.writeFileSync(tmp, JSON.stringify(payload));
70
- fs.renameSync(tmp, file);
71
- return true;
72
- } catch {
73
- return false;
74
- }
75
- }
76
-
77
- /**
78
- * Accumulate bound bytes for a context.
79
- *
80
- * init — first bind: ledger becomes `chars` (the bound corpus size)
81
- * add — append / file: ledger += chars
82
- *
83
- * Persist after every successful update so a crash mid-session keeps the count.
84
- */
85
- export function accumulateBoundChars(boundChars, contextId, chars, opts = {}) {
86
- if (!contextId || !Number.isFinite(chars) || chars < 0) return boundChars.get(contextId) || 0;
87
- const next = opts.init ? chars : (boundChars.get(contextId) || 0) + chars;
88
- boundChars.set(contextId, next);
89
- if (opts.sessionKey && opts.sessions) {
90
- opts.sessions.set(opts.sessionKey, { contextId, chars: next });
91
- }
92
- if (opts.persist !== false) persistBoundChars(boundChars, opts);
93
- return next;
94
- }
95
-
96
- /**
97
- * Conversation chars use Math.max so a stale smaller ledger (the 34056
98
- * files-only row) cannot cap a later, larger prefix. New file bytes add on top.
99
- *
100
- * next = max(prev, corpusChars) + fileChars
101
- */
102
- export function noteCorpusLedger(boundChars, {
103
- contextId, corpusChars = 0, fileChars = 0, ...opts
104
- } = {}) {
105
- if (!contextId) return 0;
106
- const prev = boundChars.get(contextId) || 0;
107
- const next = Math.max(prev, Number(corpusChars) || 0) + (Number(fileChars) || 0);
108
- boundChars.set(contextId, next);
109
- if (opts.sessionKey && opts.sessions) {
110
- opts.sessions.set(opts.sessionKey, { contextId, chars: next });
111
- }
112
- persistBoundChars(boundChars, opts);
113
- return next;
114
- }
115
-
116
- /** send() must not let `stale || thisTurn` pick the smaller number. */
117
- export function corpusCharsForSend(boundChars, contextId, thisTurn) {
118
- return Math.max(boundChars.get(contextId) || 0, thisTurn || 0);
119
- }
120
-
121
- /**
122
- * Pricing / unspilled basis: the unspilled size, never the shrunken sent
123
- * (tokensAfter) size. Same unit on both args — tokens or chars, not mixed.
124
- */
125
- export function unspilledBasis({ tokensBefore, corpus } = {}) {
126
- return Math.max(Number(tokensBefore) || 0, Number(corpus) || 0);
127
- }
128
-
129
- /**
130
- * Gateway counterfactual the tell-line must print. Missing / non-finite /
131
- * non-positive → null (`basis ?`). Never reads lecore.corpusTokens.
132
- */
133
- export function spillPricedTellBasis(x) {
134
- const n = Number(x?.counterfactualTokensUsed);
135
- return Number.isFinite(n) && n > 0 ? n : null;
136
- }
137
-
138
- /** Exact `spill priced:` / `spill priced (streamed):` line the proxy logs. */
139
- export function spillPricedLine(x, { streamed = false } = {}) {
140
- const lc = x?.lecore || {};
141
- const basis = spillPricedTellBasis(x);
142
- const prefix = streamed ? 'spill priced (streamed):' : 'spill priced:';
143
- return `${prefix} ${x?.pricing} · basis ${basis ?? '?'} tok vs sent ${lc.tokensBefore ?? '?'} -> ${lc.tokensAfter ?? '?'} · billed ${(x?.billedUsd ?? 0).toFixed(5)} direct ${(x?.directUsd ?? 0).toFixed(5)}`;
144
- }
145
-
146
- /** Expand ~ and resolve relative paths against cwd. Returns null if unusable. */
147
- export function resolveReadablePath(p, cwd = process.cwd()) {
148
- if (typeof p !== 'string') return null;
149
- let s = p.trim();
150
- if (!s || s.length > 1024 || /[\n\r]/.test(s)) return null;
151
- if (/^https?:\/\//i.test(s)) return null;
152
- if (s.startsWith('~/') || s === '~') s = path.join(os.homedir(), s.slice(1).replace(/^\//, '') || '');
153
- if (!path.isAbsolute(s)) s = path.resolve(cwd, s);
154
- return s;
155
- }
156
-
157
- function looksLikePath(s) {
158
- if (typeof s !== 'string') return false;
159
- const t = s.trim();
160
- if (t.length < 2 || t.length > 1024 || /[\n\r]/.test(t)) return false;
161
- if (/^https?:\/\//i.test(t)) return false;
162
- return /[\\/]/.test(t) || /\.\w{1,10}$/.test(t) || t.startsWith('~') || t.startsWith('.');
163
- }
164
-
165
- function parseArgs(args) {
166
- if (args == null) return {};
167
- if (typeof args === 'object' && !Array.isArray(args)) return args;
168
- if (typeof args === 'string') {
169
- const t = args.trim();
170
- if (!t) return {};
171
- try { return JSON.parse(t); } catch { return {}; }
172
- }
173
- return {};
174
- }
175
-
176
- function collectStructuredPaths(args, out) {
177
- if (!args || typeof args !== 'object') return;
178
- for (const k of PATH_KEYS) {
179
- if (typeof args[k] === 'string' && args[k]) out.push(args[k]);
180
- }
181
- for (const k of PATH_ARRAY_KEYS) {
182
- if (!Array.isArray(args[k])) continue;
183
- for (const x of args[k]) {
184
- if (typeof x === 'string') out.push(x);
185
- else if (x && typeof x === 'object') collectStructuredPaths(x, out);
186
- }
187
- }
188
- }
189
-
190
- const CWD_HINT = /(?:current working directory is[:\s]+|<cwd>\s*|cwd:\s+)([^\s<]+)/i;
191
-
192
- export function parseCwdHint(text) {
193
- if (typeof text !== 'string' || !text) return null;
194
- const m = text.match(CWD_HINT);
195
- if (!m) return null;
196
- const p = m[1].trim();
197
- return path.isAbsolute(p) ? p : null;
198
- }
199
-
200
- /**
201
- * Pull path-like tokens out of a Bash `command` string.
202
- * `head -80 programs/README.md` and `cat /abs/file` both count; bare `ls` does not.
203
- */
204
- export function extractBashPaths(command, cwd = process.cwd()) {
205
- const found = [];
206
- if (typeof command !== 'string' || !command) return { paths: found, cwd };
207
- let localCwd = cwd;
208
- for (const part of command.split(/(?:&&|\|\||;|\n)/)) {
209
- const cd = part.match(/^\s*cd\s+(?:\/[dD]\s+)?(['"]?)(.+?)\1\s*$/);
210
- if (cd) {
211
- const dest = resolveReadablePath(cd[2].trim(), localCwd);
212
- if (dest) localCwd = dest;
213
- continue;
214
- }
215
- for (const m of part.matchAll(/(['"])([^'"]+)\1/g)) {
216
- const t = m[2].trim();
217
- if (looksLikePath(t) || path.isAbsolute(t)) found.push({ raw: t, cwd: localCwd });
218
- }
219
- for (const tok of part.split(/\s+/)) {
220
- const t = tok.replace(/^[`'"]|[`'"]$/g, '');
221
- if (!t || t.startsWith('-') || t.startsWith('$') || t === '.' || t === '..') continue;
222
- if (looksLikePath(t) || path.isAbsolute(t)) found.push({ raw: t, cwd: localCwd });
223
- }
224
- }
225
- return { paths: found, cwd: localCwd };
226
- }
227
-
228
- /**
229
- * Live Claude Code msgs are OpenAI-shaped (spill runs AFTER anthropicToOpenAI).
230
- * Read/Edit/Write land on tool_calls[].function.arguments as a JSON string
231
- * {file_path:"/abs/..."}. Bash is {command:"head -80 programs/README.md"}.
232
- * Do not expect Read tool_result to carry the path. Do not harvest import
233
- * paths out of tool_result bodies.
234
- */
235
- export function extractFileCandidates(msgs, { cwd = process.cwd() } = {}) {
236
- const structured = [];
237
- const bash = [];
238
- let currentCwd = cwd;
239
- if (!Array.isArray(msgs)) return { structured, bash, cwd: currentCwd };
240
- for (const m of msgs) {
241
- if (!m || typeof m !== 'object') continue;
242
- if (m.role === 'tool' && typeof m.content === 'string') {
243
- const hint = parseCwdHint(m.content);
244
- if (hint) currentCwd = hint;
245
- }
246
- const calls = [
247
- ...(Array.isArray(m.tool_calls) ? m.tool_calls : []),
248
- ...(m.function_call ? [m.function_call] : []),
249
- ];
250
- for (const c of calls) {
251
- const args = parseArgs(c?.function?.arguments ?? c?.arguments);
252
- const fromArgs = [];
253
- collectStructuredPaths(args, fromArgs);
254
- for (const raw of fromArgs) structured.push({ raw, cwd: currentCwd });
255
- if (typeof args.command === 'string') {
256
- const got = extractBashPaths(args.command, currentCwd);
257
- for (const p of got.paths) bash.push({ ...p, bash: true });
258
- currentCwd = got.cwd;
259
- }
260
- }
261
- // Harmless leftover: pre-conversion Anthropic tool_use. Live Claude Code
262
- // never has this by the time spillTranscript runs.
263
- const blocks = Array.isArray(m.content) ? m.content : [];
264
- for (const b of blocks) {
265
- if (b?.input) {
266
- const fromInput = [];
267
- collectStructuredPaths(b.input, fromInput);
268
- for (const raw of fromInput) structured.push({ raw, cwd: currentCwd });
269
- if (typeof b.input.command === 'string') {
270
- const got = extractBashPaths(b.input.command, currentCwd);
271
- for (const p of got.paths) bash.push({ ...p, bash: true });
272
- currentCwd = got.cwd;
273
- }
274
- }
275
- }
276
- }
277
- return { structured, bash, cwd: currentCwd };
278
- }
279
-
280
- export function extractFilePaths(msgs, opts) {
281
- const { structured, bash } = extractFileCandidates(msgs, opts);
282
- const seen = new Set();
283
- const out = [];
284
- for (const item of [...structured, ...bash]) {
285
- const t = typeof item === 'string' ? item : item?.raw;
286
- if (typeof t !== 'string') continue;
287
- const s = t.trim();
288
- if (!s || seen.has(s)) continue;
289
- seen.add(s);
290
- out.push(s);
291
- }
292
- return out;
293
- }
294
-
295
- const SKIP_DIR_NAMES = new Set(['node_modules', '.git', 'dist', 'build', '__pycache__', '.venv', 'target']);
296
-
297
- function fileBindLog({ kept, bytes, enoent, cap, dir, rel, bash }) {
298
- return `file-bind kept=${kept} bytes=${bytes} skip enoent=${enoent} cap=${cap} dir=${dir} rel=${rel} bash=${bash}`;
299
- }
300
-
301
- function emptyFileBind({ reason = 'none-kept', ...extra } = {}) {
302
- return {
303
- text: '',
304
- files: 0,
305
- bytes: 0,
306
- pending: [],
307
- kept: 0,
308
- enoent: 0,
309
- cap: 0,
310
- dir: 0,
311
- rel: 0,
312
- bash: 0,
313
- reason,
314
- ...extra,
315
- };
316
- }
317
-
318
- /**
319
- * Request-path filebind: collect paths + cheap stat/mtime only.
320
- *
321
- * Live path is OpenAI tool_calls (Read/Edit/Write + Bash command). Relative
322
- * paths resolve against cwd / last "current working directory is …" hint.
323
- *
324
- * MUST NOT read file contents and MUST NOT readdir children. A 2MB Read
325
- * (or a directory the agent listed) used to stall the chat turn here.
326
- * Bytes, directory expansion, and bindCorpus belong in readFilesForCorpus,
327
- * which the proxy runs after the turn is already on the wire.
328
- *
329
- * Dedupes on path+mtime into `boundFiles`, applies the size cap, and
330
- * reserves directory keys so the same tree is not re-queued every turn.
331
- * The `file-bind kept=N bytes=B …` line is logged here only when there is
332
- * nothing to read (disabled / none-kept) — otherwise readFilesForCorpus
333
- * fills bytes after the background read, never on the hot path.
334
- */
335
- export function filesForCorpus(msgs, {
336
- boundFiles,
337
- cwd = process.cwd(),
338
- cap = Number(process.env.OPENZOO_BIND_FILE_MAX || 400_000),
339
- disabled = process.env.OPENZOO_BIND_FILES === '0',
340
- log = () => {},
341
- statSync = (p) => fs.statSync(p),
342
- } = {}) {
343
- if (disabled) {
344
- const empty = emptyFileBind({ reason: 'disabled' });
345
- log(fileBindLog(empty));
346
- return empty;
347
- }
348
- const { structured, bash } = extractFileCandidates(msgs, { cwd });
349
- const candidates = [
350
- ...structured.map((p) => ({ ...p, bash: false })),
351
- ...bash.map((p) => ({ ...p, bash: true })),
352
- ];
353
- const skip = { enoent: 0, cap: 0, dir: 0, rel: 0 };
354
- const seen = new Set();
355
- const pending = [];
356
-
357
- const queue = (abs) => {
358
- if (!abs || seen.has(abs)) return;
359
- seen.add(abs);
360
- let st;
361
- try { st = statSync(abs); } catch { skip.enoent += 1; return; }
362
- if (st.isDirectory()) {
363
- skip.dir += 1;
364
- const key = `${abs}:${st.mtimeMs}`;
365
- if (boundFiles?.has(key)) return;
366
- boundFiles?.add(key);
367
- pending.push({ abs, kind: 'dir' });
368
- return;
369
- }
370
- if (!st.isFile()) { skip.enoent += 1; return; }
371
- if (st.size > cap) { skip.cap += 1; return; }
372
- const key = `${abs}:${st.mtimeMs}`;
373
- if (boundFiles?.has(key)) return;
374
- boundFiles?.add(key);
375
- pending.push({ abs, kind: 'file' });
376
- };
377
-
378
- for (const item of candidates) {
379
- const raw = item.raw;
380
- if (!path.isAbsolute(raw) && !(raw.startsWith('~/') || raw === '~')) skip.rel += 1;
381
- const abs = resolveReadablePath(raw, item.cwd || cwd);
382
- if (!abs) { skip.enoent += 1; continue; }
383
- queue(abs);
384
- }
385
-
386
- const stats = {
387
- kept: 0,
388
- bytes: 0,
389
- enoent: skip.enoent,
390
- cap: skip.cap,
391
- dir: skip.dir,
392
- rel: skip.rel,
393
- bash: bash.length,
394
- };
395
- if (!pending.length) log(fileBindLog(stats));
396
- return {
397
- text: '',
398
- files: 0,
399
- bytes: 0,
400
- pending,
401
- reason: pending.length ? null : 'none-kept',
402
- ...stats,
403
- };
404
- }
405
-
406
- /**
407
- * Background filebind: expand directories, read bytes, log the real totals.
408
- *
409
- * Takes the `pending` list (or the whole collect result) from filesForCorpus.
410
- * Children of a directory skip node_modules/.git/dist/build/__pycache__/.venv/target
411
- * and hidden names. Same size cap as the request-path collector.
412
- *
413
- * Always logs `file-bind kept=N bytes=B skip enoent=X cap=Y dir=Z rel=W bash=K`
414
- * so grep-for-FILES is no longer the only signal. Bytes, not 0.0MB.
415
- */
416
- export function readFilesForCorpus(collected, {
417
- boundFiles,
418
- cap = Number(process.env.OPENZOO_BIND_FILE_MAX || 400_000),
419
- log = () => {},
420
- statSync = (p) => fs.statSync(p),
421
- readFileSync = (p) => fs.readFileSync(p, 'utf8'),
422
- readdirSync = (p) => fs.readdirSync(p),
423
- } = {}) {
424
- const pending = Array.isArray(collected) ? collected : (collected?.pending || []);
425
- const skip = {
426
- enoent: Number(collected?.enoent) || 0,
427
- cap: Number(collected?.cap) || 0,
428
- dir: Number(collected?.dir) || 0,
429
- rel: Number(collected?.rel) || 0,
430
- };
431
- const bash = Number(collected?.bash) || 0;
432
- const chunks = [];
433
- const readSeen = new Set();
434
-
435
- const readOne = (abs) => {
436
- if (!abs || readSeen.has(abs)) return;
437
- readSeen.add(abs);
438
- try {
439
- chunks.push(`FILE ${abs}\n${readFileSync(abs)}`);
440
- } catch {
441
- skip.enoent += 1;
442
- }
443
- };
444
-
445
- const bindChild = (abs) => {
446
- let st;
447
- try { st = statSync(abs); } catch { skip.enoent += 1; return; }
448
- if (!st.isFile()) return;
449
- if (st.size > cap) { skip.cap += 1; return; }
450
- const key = `${abs}:${st.mtimeMs}`;
451
- if (boundFiles?.has(key)) return;
452
- boundFiles?.add(key);
453
- readOne(abs);
454
- };
455
-
456
- for (const item of pending) {
457
- const abs = typeof item === 'string' ? item : item?.abs;
458
- if (!abs) continue;
459
- if (item?.kind === 'dir') {
460
- let kids = [];
461
- try { kids = readdirSync(abs); } catch { skip.enoent += 1; continue; }
462
- for (const name of kids) {
463
- if (!name || name.startsWith('.') || SKIP_DIR_NAMES.has(name)) continue;
464
- bindChild(path.join(abs, name));
465
- }
466
- continue;
467
- }
468
- readOne(abs);
469
- }
470
-
471
- const text = chunks.join('\n\n');
472
- const stats = {
473
- kept: chunks.length,
474
- bytes: text.length,
475
- enoent: skip.enoent,
476
- cap: skip.cap,
477
- dir: skip.dir,
478
- rel: skip.rel,
479
- bash,
480
- };
481
- log(fileBindLog(stats));
482
- return { text, files: chunks.length, bytes: text.length, reason: chunks.length ? null : 'none-kept', ...stats };
483
- }
484
-
485
- /**
486
- * path:mtime keys -> absolute paths. mtime is always the last `:Number` segment
487
- * so `C:\foo:1734.2` still splits correctly.
488
- */
489
- export function boundAbsFromKeys(boundFiles) {
490
- const out = new Set();
491
- if (!boundFiles) return out;
492
- for (const key of boundFiles) {
493
- if (typeof key !== 'string' || !key) continue;
494
- const i = key.lastIndexOf(':');
495
- if (i <= 0) { out.add(key); continue; }
496
- const rest = key.slice(i + 1);
497
- if (rest !== '' && Number.isFinite(Number(rest))) out.add(key.slice(0, i));
498
- else out.add(key);
499
- }
500
- return out;
501
- }
502
-
503
- const FILE_VIEW = /^(head|tail|cat|less|more|type|Get-Content|gc)\b/i;
504
-
505
- /** `head -80 notes.md` and `cd dir && cat x` count; `npm test` and `cat x | rg y` do not. */
506
- export function looksLikeFileView(command) {
507
- if (typeof command !== 'string' || !command.trim()) return false;
508
- let sawView = false;
509
- for (const part of command.split(/(?:&&|\|\||;|\n)/)) {
510
- const t = part.trim();
511
- if (!t) continue;
512
- if (/^cd\s+/.test(t)) continue;
513
- if (/\|/.test(t)) return false;
514
- if (FILE_VIEW.test(t)) { sawView = true; continue; }
515
- return false;
516
- }
517
- return sawView;
518
- }
519
-
520
- function toolFnName(c) {
521
- return c?.function?.name || c?.name || '';
522
- }
523
-
524
- /**
525
- * Read / Edit / Write (and file-view Bash). Bodies for these that remain
526
- * after the last user ask must stay real — a `[bound, N chars]` placeholder
527
- * in the live tail makes the model think Read is broken and cat files.
528
- */
529
- export function isLiveFileTool(name, args = {}) {
530
- const n = String(name || '');
531
- if (/^(read|edit|write)/i.test(n)) return true;
532
- if (/bash/i.test(n) && looksLikeFileView(args?.command)) return true;
533
- return false;
534
- }
535
-
536
- /**
537
- * Same number the HUD green `x` uses: this-call / running spill
538
- * direct÷billed, else session-wide. Null when no dollar figure exists.
539
- */
540
- export function hudDollarX({
541
- spillDirect, spillSpend, sessionDirect, sessionSpent,
542
- } = {}) {
543
- const billed = Number(spillSpend);
544
- const direct = Number(spillDirect);
545
- if (billed > 0 && Number.isFinite(direct)) return direct / billed;
546
- const sessBilled = Number(sessionSpent);
547
- const sessDirect = Number(sessionDirect);
548
- if (sessBilled > 0 && Number.isFinite(sessDirect)) return sessDirect / sessBilled;
549
- return null;
550
- }
551
-
552
- /** Tool result larger than this is "fat" — stub it in the forwarded tail. */
553
- export const FAT_TOOL_CHARS = 400;
554
-
555
- /**
556
- * Over-budget forwarded-tail shrink (SHRINK_OVER). When the tail exceeds
557
- * this many chars (or the caller `budget`), oldest tool_results are
558
- * considered for `[bound]` stubs. A stub is emitted only if HRR recall
559
- * actually returns overlapping bytes for that item — `boundFiles` /
560
- * `boundAbs` is not proof.
561
- */
562
- export const SHRINK_OVER = 6000;
563
-
564
- /**
565
- * Last-round bodies under this floor stay even after a follow-up ask
566
- * (0.48.77: a 461-byte bound Read must remain visible). Older rounds use
567
- * the 0.48.76 rules (bound / fat / over budget) whether or not they sit
568
- * after the last user ask.
569
- */
570
- export const KEEP_TOOL_CHARS = 4096;
571
-
572
- function toolCallIds(m) {
573
- if (!m || typeof m !== 'object') return [];
574
- const ids = [];
575
- if (Array.isArray(m.tool_calls)) {
576
- for (const c of m.tool_calls) {
577
- const id = c?.id || c?.tool_call_id;
578
- if (id) ids.push(id);
579
- }
580
- }
581
- if (m.function_call) {
582
- const id = m.function_call.id || m.function_call.tool_call_id;
583
- if (id) ids.push(id);
584
- }
585
- if (Array.isArray(m.content)) {
586
- for (const b of m.content) {
587
- if (b?.type === 'tool_use' && b.id) ids.push(b.id);
588
- }
589
- }
590
- return ids;
591
- }
592
-
593
- /**
594
- * Most recent assistant tool_calls / Anthropic tool_use in the forwarded
595
- * tail, plus those ids. Not "everything after the last user ask".
596
- *
597
- * A long continue / ultracode turn parks the last user ask early and then
598
- * stacks Read/Bash/WebSearch rounds after it. Only the latest batch is
599
- * this turn's eyes — even a 461-byte Read, even a fat WebSearch. Older
600
- * batches in that same tail stub/trim under the 0.48.76 rules. When the
601
- * ask follows the last batch, KEEP_TOOL_CHARS still keeps that last
602
- * round's small bodies (0.48.77) while fat completed rounds may stub.
603
- */
604
- export function currentToolRound(msgs, { lastUser = -1, fromIndex = 0 } = {}) {
605
- let index = -1;
606
- const ids = new Set();
607
- if (!Array.isArray(msgs)) return { index, ids, inFlight: false };
608
- const start = Math.max(0, Number(fromIndex) || 0);
609
- for (let i = start; i < msgs.length; i++) {
610
- const here = toolCallIds(msgs[i]);
611
- if (!here.length) continue;
612
- index = i;
613
- ids.clear();
614
- for (const id of here) ids.add(id);
615
- }
616
- return { index, ids, inFlight: index >= 0 && index > lastUser };
617
- }
618
-
619
- export function fileBoundStub(paths, n) {
620
- const list = [...new Set((paths || []).filter(Boolean))].join(' ');
621
- const mark = Number.isFinite(n) && n > 0 ? `[bound, ${n} chars]` : '[bound]';
622
- return list ? `FILE ${list} ${mark}` : `FILE ${mark}`;
623
- }
624
-
625
- /** Generic stub for WebSearch / Fetch / Bash / any fat tool_result. */
626
- export function toolResultStub(n, paths) {
627
- const list = [...new Set((paths || []).filter(Boolean))].join(' ');
628
- const mark = `[bound, ${Number(n) || 0} chars]`;
629
- return list ? `FILE ${list} ${mark}` : mark;
630
- }
631
-
632
- function toolCallArgLength(tc) {
633
- const a = tc?.function?.arguments ?? tc?.arguments;
634
- if (typeof a === 'string') return a.length;
635
- if (a && typeof a === 'object') return JSON.stringify(a).length;
636
- return 0;
637
- }
638
-
639
- /**
640
- * Keep path/query/url/command so pairing still names the call; drop the
641
- * fat payload (Write contents, Edit hunks, pasted Bash, tool JSON).
642
- */
643
- export function slimToolCallArgs(tc, n) {
644
- const args = parseArgs(tc?.function?.arguments ?? tc?.arguments);
645
- const slim = {};
646
- for (const k of PATH_KEYS) {
647
- if (typeof args[k] === 'string' && args[k]) slim[k] = args[k];
648
- }
649
- if (typeof args.query === 'string') {
650
- slim.query = args.query.length > 240 ? `${args.query.slice(0, 240)}…` : args.query;
651
- }
652
- if (typeof args.url === 'string') {
653
- slim.url = args.url.length > 300 ? `${args.url.slice(0, 300)}…` : args.url;
654
- }
655
- if (typeof args.command === 'string') {
656
- slim.command = args.command.length > 240 ? `${args.command.slice(0, 240)}…` : args.command;
657
- }
658
- slim._stub = `[bound, ${Number(n) || 0} chars]`;
659
- const encoded = JSON.stringify(slim);
660
- if (tc?.function) return { ...tc, function: { ...tc.function, arguments: encoded } };
661
- return { ...tc, arguments: encoded };
662
- }
663
-
664
- function isStubText(content) {
665
- if (typeof content === 'string') return /\[bound(?:, \d+ chars)?\]/.test(content);
666
- if (Array.isArray(content)) return content.some((b) => isStubText(typeof b === 'string' ? b : b?.text ?? b?.content));
667
- return false;
668
- }
669
-
670
- function toolContentLength(content) {
671
- if (typeof content === 'string') return content.length;
672
- if (Array.isArray(content)) {
673
- return content.reduce((n, b) => n + (typeof b === 'string' ? b.length : String(b?.text ?? b?.content ?? '').length), 0);
674
- }
675
- if (content && typeof content === 'object') return JSON.stringify(content).length;
676
- return 0;
677
- }
678
-
679
- function toolContentText(content) {
680
- if (typeof content === 'string') return content;
681
- if (Array.isArray(content)) {
682
- return content.map((b) => (typeof b === 'string' ? b : String(b?.text ?? b?.content ?? ''))).join('');
683
- }
684
- if (content && typeof content === 'object') return JSON.stringify(content);
685
- return content == null ? '' : String(content);
686
- }
687
-
688
- /** Normalize a recall hook result to text. Empty means a miss. */
689
- export function recallText(got) {
690
- if (got == null) return '';
691
- if (typeof got === 'string') return got;
692
- if (typeof Buffer !== 'undefined' && Buffer.isBuffer(got)) return got.toString('utf8');
693
- if (typeof got === 'object') {
694
- if (typeof got.text === 'string') return got.text;
695
- if (typeof got.content === 'string') return got.content;
696
- if (typeof got.bytes === 'string') return got.bytes;
697
- if (typeof Buffer !== 'undefined' && Buffer.isBuffer(got.bytes)) return got.bytes.toString('utf8');
698
- }
699
- return '';
700
- }
701
-
702
- /** True when recalled text is non-empty and overlaps the original body. */
703
- export function recallOverlapsBody(recalled, body) {
704
- const a = recallText(recalled);
705
- const b = toolContentText(body);
706
- if (!a || !b) return false;
707
- if (b.includes(a) || a.includes(b)) return true;
708
- const n = Math.min(32, a.length, b.length);
709
- if (n < 1) return false;
710
- for (let i = 0; i + n <= a.length; i += Math.max(1, Math.floor(n / 2))) {
711
- if (b.includes(a.slice(i, i + n))) return true;
712
- }
713
- return false;
714
- }
715
-
716
- /**
717
- * Probe `recall` for this tool_result / file. True only when the hook
718
- * actually produced overlapping bytes — not when we merely think the
719
- * path is bound.
720
- */
721
- export function recallReturnedBytes(recall, item = {}) {
722
- if (typeof recall !== 'function') return false;
723
- let got;
724
- try {
725
- const content = toolContentText(item.content);
726
- got = recall({
727
- id: item.id,
728
- paths: item.paths || [],
729
- content,
730
- query: item.query || (Array.isArray(item.paths) && item.paths[0]) || content.slice(0, 80),
731
- });
732
- } catch {
733
- return false;
734
- }
735
- return recallOverlapsBody(got, item.content);
736
- }
737
-
738
- /**
739
- * Local recall over already-bound corpus text. Hard-proof stand-in when a
740
- * live HRR probe is not available: the exact bytes must be in the corpus
741
- * AND the probe must return a non-empty overlapping slice.
742
- */
743
- export function corpusRecall(corpus) {
744
- const text = typeof corpus === 'string' ? corpus : '';
745
- return (item = {}) => {
746
- if (!text) return '';
747
- const body = toolContentText(item.content);
748
- const snippet = body.length >= 16 ? body.slice(0, 64) : body;
749
- if (snippet && text.includes(snippet)) {
750
- const i = text.indexOf(snippet);
751
- return text.slice(i, i + Math.min(Math.max(body.length, snippet.length), 8192));
752
- }
753
- return '';
754
- };
755
- }
756
-
757
- function resolveBoundPath(raw, cwd, boundAbs) {
758
- if (!boundAbs?.size) return null;
759
- const t = typeof raw === 'string' ? raw.trim() : '';
760
- if (!t) return null;
761
- const abs = resolveReadablePath(t, cwd);
762
- if (abs && boundAbs.has(abs)) return abs;
763
- if (boundAbs.has(t)) return t;
764
- return null;
765
- }
766
-
767
- /**
768
- * After a file is bound, drop its tool_result / file body from the forwarded
769
- * tail. Keep the path and a short marker. Shipping those bytes again makes
770
- * sent ≈ corpus and the gateway's `counterfactualTokens > promptTokens`
771
- * gate barely fires (live: 5MB filebind, lastSend 13/107, savingX 1.22
772
- * instead of ~7x).
773
- *
774
- * A `[bound]` marker is a promise that HRR recall can return those bytes.
775
- * `boundAbs` / `boundFiles` only means we think we bound the path — that
776
- * is not proof. On the SHRINK_OVER path (tail over `budget`), and whenever
777
- * a `recall` hook is installed on a budgeted tail, stub only if recall
778
- * actually returns non-empty overlapping content. Prefer the original body
779
- * over a lying stub (context missing, file not in corpus, recall miss).
780
- *
781
- * Cheap rewrite — no disk I/O. First-read results (not yet in boundAbs) and
782
- * non-file tool output stay verbatim UNLESS `aggressive` / `stubMore` or a
783
- * tail `budget` is set. Those two are what beat a WebSearch/Fetch/Bash
784
- * storm: the 800-byte floor cannot move `cut` inside a tool chain
785
- * (`isSeverable` is false), so the byte budget has to win by stubbing
786
- * bodies (and fat tool_call JSON) instead of orphaning a pairing.
787
- *
788
- * `keepTail` is the vote cutTranscript cannot cast on an un-severable
789
- * chain: drop older complete tool_call + tool_result pairs (rewrite the
790
- * assistant's tool_calls, do not leave orphans). Live 0.48.75: keep 16/8/6/2
791
- * all cut at the same index and left ~728k after file-only stubs.
792
- *
793
- * `fromIndex` limits the rewrite to the forwarded tail so the spilled prefix
794
- * that becomes the conversation corpus is unchanged. The last user ask is
795
- * never rewritten. The current tool round is only the most recent assistant
796
- * tool_calls / tool_use in that tail, plus tool results for those ids — not
797
- * every Read/Bash/Search after the last user ask. In-flight latest bodies
798
- * stay even when large. KEEP_TOOL_CHARS applies only to that last round so
799
- * a 461-byte Read survives a follow-up ask.
800
- *
801
- * After the last user ask, remaining Read/Edit/Write / Bash-file bodies
802
- * stay verbatim. Drop older post-ask rounds (trimRoundsAfterAsk) instead
803
- * of rewriting them to `[bound, N chars]` — a placeholder in the live tail
804
- * is 76-style blindness (model cats files). Fat WebSearch/Fetch in older
805
- * kept rounds may still stub. stubMore tightens SEARCH, not live files.
806
- */
807
- export function stubBoundFileResults(msgs, {
808
- boundFiles,
809
- boundAbs,
810
- cwd = process.cwd(),
811
- fromIndex = 0,
812
- // When the live tuner is below target, stub file-view results even if
813
- // this turn has not yet recorded them in boundAbs (first-read bodies).
814
- // Also stubs fat non-file tools (WebSearch / Fetch / Bash) and fat
815
- // tool_call argument JSON.
816
- aggressive = false,
817
- // When the forwarded tail is over this many chars, stub older tool_result
818
- // bodies and fat tool_call JSON (oldest first) until it fits. Pairing
819
- // stays; the ask stays. If still over, drop older complete pairs.
820
- budget = null,
821
- // How many complete tool pairs to keep at the end of an un-severable
822
- // assistant(tool_calls)+results chain. Ignored when the tail is severable.
823
- keepTail = null,
824
- fatChars = FAT_TOOL_CHARS,
825
- // (query) => bytes. SHRINK_OVER [bound] stubs require a non-empty
826
- // overlapping hit. boundFiles alone is not enough.
827
- recall = null,
828
- } = {}) {
829
- const absSet = boundAbs || boundAbsFromKeys(boundFiles);
830
- const wantBudget = budget != null && Number.isFinite(Number(budget));
831
- const wantKeep = keepTail != null && Number.isFinite(Number(keepTail));
832
- if (!Array.isArray(msgs) || (!absSet.size && !aggressive && !wantBudget && !wantKeep)) {
833
- return { messages: msgs, stubbed: 0, dropped: 0 };
834
- }
835
-
836
- const stubIds = new Set();
837
- const fileIds = new Set();
838
- const afterAskIds = new Set();
839
- const idPaths = new Map();
840
- let currentCwd = cwd;
841
-
842
- const noteCall = (c) => {
843
- const id = c?.id || c?.tool_call_id;
844
- const args = parseArgs(c?.function?.arguments ?? c?.arguments);
845
- if (id && isLiveFileTool(toolFnName(c), args)) fileIds.add(id);
846
- const raws = [];
847
- collectStructuredPaths(args, raws);
848
- if (typeof args.command === 'string' && looksLikeFileView(args.command)) {
849
- for (const p of extractBashPaths(args.command, currentCwd).paths) raws.push(p.raw);
850
- }
851
- const resolved = [];
852
- for (const raw of raws) {
853
- const hit = resolveBoundPath(raw, currentCwd, absSet);
854
- if (hit) resolved.push(hit);
855
- else if (aggressive && raw) resolved.push(resolveReadablePath(raw, currentCwd) || raw);
856
- }
857
- if (!resolved.length || !id) return;
858
- stubIds.add(id);
859
- idPaths.set(id, [...(idPaths.get(id) || []), ...resolved]);
860
- };
861
-
862
- for (const m of msgs) {
863
- if (!m || typeof m !== 'object') continue;
864
- if (m.role === 'tool' && typeof m.content === 'string') {
865
- const hint = parseCwdHint(m.content);
866
- if (hint) currentCwd = hint;
867
- }
868
- const calls = [
869
- ...(Array.isArray(m.tool_calls) ? m.tool_calls : []),
870
- ...(m.function_call ? [m.function_call] : []),
871
- ];
872
- for (const c of calls) noteCall(c);
873
- const blocks = Array.isArray(m.content) ? m.content : [];
874
- for (const b of blocks) {
875
- if (b?.type === 'tool_use') {
876
- noteCall({ id: b.id, arguments: b.input, function: { name: b.name, arguments: b.input } });
877
- }
878
- }
879
- }
880
-
881
- const firstSpillable = firstSpillableIndex(msgs);
882
- let lastUser = lastUserAskIndex(msgs, firstSpillable);
883
- const fatLimit = Number.isFinite(Number(fatChars)) ? Number(fatChars) : FAT_TOOL_CHARS;
884
- const keepFloor = KEEP_TOOL_CHARS;
885
- const slimArgs = aggressive || wantBudget;
886
- let round = currentToolRound(msgs, { lastUser, fromIndex });
887
-
888
- const markAfterAsk = (list) => {
889
- afterAskIds.clear();
890
- if (lastUser < 0 || !Array.isArray(list)) return;
891
- for (let i = lastUser + 1; i < list.length; i++) {
892
- for (const id of toolCallIds(list[i])) afterAskIds.add(id);
893
- }
894
- };
895
- markAfterAsk(msgs);
896
-
897
- const stubFor = (id, n) => {
898
- const paths = idPaths.get(id);
899
- return paths?.length ? fileBoundStub(paths, n) : toolResultStub(n);
900
- };
901
-
902
- const shouldStubBody = (id, n, { overBudget = false } = {}) => {
903
- if (!n || isStubText(typeof n === 'number' ? '' : n)) return false;
904
- // Latest in-flight batch (after the ask): never stub, even if fat.
905
- if (id && round.inFlight && round.ids.has(id)) return false;
906
- // Last completed batch (ask follows): keep small bodies only.
907
- if (id && round.ids.has(id) && n < keepFloor) return false;
908
- // Remaining post-ask file bodies stay real. Drop the round instead.
909
- if (id && afterAskIds.has(id) && fileIds.has(id)) return false;
910
- if (stubIds.has(id)) return true;
911
- if (slimArgs && n >= fatLimit) return true;
912
- if (overBudget && n > 0) return true;
913
- return false;
914
- };
915
-
916
- const recallCache = new Map();
917
- const probeRecall = (id, content) => {
918
- const key = id || toolContentText(content);
919
- if (recallCache.has(key)) return recallCache.get(key);
920
- const ok = recallReturnedBytes(recall, {
921
- id,
922
- content,
923
- paths: idPaths.get(id) || [],
924
- });
925
- recallCache.set(key, ok);
926
- return ok;
927
- };
928
-
929
- const allowBoundStub = (id, content, { overBudget = false } = {}) => {
930
- // SHRINK_OVER leftovers always need real recall bytes (fail closed
931
- // when no hook is installed). When a recall hook is installed on a
932
- // budgeted tail, every [bound] claim uses the same gate.
933
- if (overBudget || (wantBudget && typeof recall === 'function')) {
934
- return probeRecall(id, content);
935
- }
936
- return true;
937
- };
938
-
939
- let stubbed = 0;
940
- let dropped = 0;
941
- let messages = msgs;
942
-
943
- // keepTail votes here because cutTranscript cannot move inside the chain
944
- // *or* past the last user ask. A continue turn stacks severable
945
- // assistant+result rounds after that ask; trim those older rounds first
946
- // so lastSend cannot grow to hundreds of fully-billed messages.
947
- if (wantKeep) {
948
- messages = trimRoundsAfterAsk(messages, {
949
- fromIndex,
950
- keepTail: Number(keepTail),
951
- lastUser,
952
- });
953
- lastUser = lastUserAskIndex(messages, firstSpillableIndex(messages));
954
- messages = trimUnseverablePairs(messages, {
955
- fromIndex,
956
- keepTail: Number(keepTail),
957
- lastUser,
958
- firstSpillable: firstSpillableIndex(messages),
959
- });
960
- lastUser = lastUserAskIndex(messages, firstSpillableIndex(messages));
961
- markAfterAsk(messages);
962
- round = currentToolRound(messages, { lastUser, fromIndex });
963
- }
964
-
965
- const skipSlimCalls = (i) => round.inFlight && i === round.index;
966
-
967
- messages = messages.map((m, i) => {
968
- if (i < fromIndex || !m || i === lastUser) return m;
969
- if (m.role === 'tool') {
970
- const n = toolContentLength(m.content);
971
- if (!shouldStubBody(m.tool_call_id, n) || isStubText(m.content)) return m;
972
- if (!allowBoundStub(m.tool_call_id, m.content)) return m;
973
- dropped += n;
974
- stubbed += 1;
975
- return { ...m, content: stubFor(m.tool_call_id, n) };
976
- }
977
- let next = m;
978
- if (Array.isArray(m.tool_calls) && slimArgs && !skipSlimCalls(i)) {
979
- let changed = false;
980
- const calls = m.tool_calls.map((tc) => {
981
- const n = toolCallArgLength(tc);
982
- const raw = tc?.function?.arguments ?? tc?.arguments;
983
- if (n < fatLimit || isStubText(typeof raw === 'string' ? raw : '')) return tc;
984
- dropped += n;
985
- stubbed += 1;
986
- changed = true;
987
- return slimToolCallArgs(tc, n);
988
- });
989
- if (changed) next = { ...next, tool_calls: calls };
990
- }
991
- if (next.function_call && slimArgs && !skipSlimCalls(i)) {
992
- const n = toolCallArgLength(next.function_call);
993
- const raw = next.function_call.arguments;
994
- if (n >= fatLimit && !isStubText(typeof raw === 'string' ? raw : '')) {
995
- dropped += n;
996
- stubbed += 1;
997
- next = { ...next, function_call: slimToolCallArgs(next.function_call, n) };
998
- }
999
- }
1000
- if (!Array.isArray(next.content)) return next;
1001
- let changed = false;
1002
- const blocks = next.content.map((b) => {
1003
- if (b?.type !== 'tool_result') return b;
1004
- const n = toolContentLength(b.content);
1005
- if (!shouldStubBody(b.tool_use_id, n) || isStubText(b.content)) return b;
1006
- if (!allowBoundStub(b.tool_use_id, b.content)) return b;
1007
- dropped += n;
1008
- stubbed += 1;
1009
- changed = true;
1010
- return { ...b, content: stubFor(b.tool_use_id, n) };
1011
- });
1012
- return changed ? { ...next, content: blocks } : next;
1013
- });
1014
-
1015
- // SHRINK_OVER: byte budget wins inside a tool chain. cutTranscript cannot
1016
- // move tailStart past assistant(tool_calls) / role:tool (pairing 400s the
1017
- // provider), so a 300-result storm used to ride in at ~728k after file-only
1018
- // stubs. Stub bodies first, then fat tool_call JSON; if still over, drop
1019
- // older pairs. Never drop the ask. Never orphan a remaining tool_result.
1020
- // Never write `[bound]` unless recall returned those bytes.
1021
- if (wantBudget) {
1022
- const cap = Number(budget);
1023
- let used = sliceChars(messages, fromIndex);
1024
- if (used > cap) {
1025
- const next = messages.slice();
1026
- for (let i = fromIndex; i < next.length && used > cap; i++) {
1027
- if (i === lastUser) continue;
1028
- const m = next[i];
1029
- if (!m) continue;
1030
- if (m.role === 'tool') {
1031
- if (isStubText(m.content)) continue;
1032
- const n = toolContentLength(m.content);
1033
- if (!shouldStubBody(m.tool_call_id, n, { overBudget: true })) continue;
1034
- if (!allowBoundStub(m.tool_call_id, m.content, { overBudget: true })) continue;
1035
- const stub = stubFor(m.tool_call_id, n);
1036
- if (stub.length >= n) continue;
1037
- used = used - n + stub.length;
1038
- next[i] = { ...m, content: stub };
1039
- stubbed += 1;
1040
- dropped += n;
1041
- continue;
1042
- }
1043
- if (Array.isArray(m.tool_calls) && !skipSlimCalls(i)) {
1044
- let changed = false;
1045
- const calls = m.tool_calls.map((tc) => {
1046
- if (used <= cap) return tc;
1047
- const n = toolCallArgLength(tc);
1048
- const raw = tc?.function?.arguments ?? tc?.arguments;
1049
- if (n < fatLimit || isStubText(typeof raw === 'string' ? raw : '')) return tc;
1050
- const slim = slimToolCallArgs(tc, n);
1051
- const after = toolCallArgLength(slim);
1052
- if (after >= n) return tc;
1053
- used = used - n + after;
1054
- stubbed += 1;
1055
- dropped += n;
1056
- changed = true;
1057
- return slim;
1058
- });
1059
- if (changed) next[i] = { ...m, tool_calls: calls };
1060
- }
1061
- if (!Array.isArray((next[i] || m).content)) continue;
1062
- const cur = next[i];
1063
- let changed = false;
1064
- const blocks = cur.content.map((b) => {
1065
- if (b?.type !== 'tool_result' || used <= cap || isStubText(b.content)) return b;
1066
- const n = toolContentLength(b.content);
1067
- if (!shouldStubBody(b.tool_use_id, n, { overBudget: true })) return b;
1068
- if (!allowBoundStub(b.tool_use_id, b.content, { overBudget: true })) return b;
1069
- const stub = stubFor(b.tool_use_id, n);
1070
- if (stub.length >= n) return b;
1071
- used = used - n + stub.length;
1072
- stubbed += 1;
1073
- dropped += n;
1074
- changed = true;
1075
- return { ...b, content: stub };
1076
- });
1077
- if (changed) next[i] = { ...cur, content: blocks };
1078
- }
1079
- messages = next;
1080
- used = sliceChars(messages, fromIndex);
1081
- if (used > cap) {
1082
- // Still over: drop older post-ask rounds rather than rewriting
1083
- // remaining file bodies to `[bound]`. Then trim an un-severable
1084
- // last chain. Latest batch stays.
1085
- messages = trimRoundsAfterAsk(messages, {
1086
- fromIndex,
1087
- keepTail: 1,
1088
- lastUser,
1089
- });
1090
- lastUser = lastUserAskIndex(messages, firstSpillableIndex(messages));
1091
- messages = trimUnseverablePairs(messages, {
1092
- fromIndex,
1093
- keepTail: 1,
1094
- lastUser,
1095
- firstSpillable: firstSpillableIndex(messages),
1096
- });
1097
- }
1098
- }
1099
- }
1100
-
1101
- return { messages, stubbed, dropped };
1102
- }
1103
-
1104
- export const ADAPT_TARGET = 10;
1105
- export const ADAPT_LOOSEN_AT = 20;
1106
- /** lastSend growing past this while dollar x < target is a tighten signal. */
1107
- export const LAST_SEND_TIGHTEN = 24;
1108
-
1109
- export const KNOB_DEFAULTS = Object.freeze({
1110
- keepTail: 8,
1111
- minTurns: 6,
1112
- budget: SHRINK_OVER,
1113
- stubMore: false,
1114
- });
1115
-
1116
- const KEEP_STEPS = [2, 3, 4, 6, 8, 12, 16];
1117
- const TURNS_STEPS = [2, 3, 4, 6, 8, 12];
1118
- const BUDGET_STEPS = [800, 1500, 2500, 4000, 6000, 9000, 12000, 18000, 24000];
1119
-
1120
- /** Flatten one Anthropic content block to text leCore can index. */
1121
- export function blockText(b) {
1122
- if (typeof b === 'string') return b;
1123
- if (!b || typeof b !== 'object') return '';
1124
- if (b.type === 'text') return b.text || '';
1125
- if (b.type === 'tool_use') return `[tool_use ${b.name}] ${JSON.stringify(b.input ?? {})}`;
1126
- if (b.type === 'tool_result') {
1127
- const c = b.content;
1128
- return `[tool_result] ${typeof c === 'string' ? c : (Array.isArray(c) ? c.map(blockText).join('\n') : JSON.stringify(c ?? ''))}`;
1129
- }
1130
- if (b.type === 'thinking') return '';
1131
- return '';
1132
- }
1133
-
1134
- export function msgText(m) {
1135
- const c = m?.content;
1136
- const body = typeof c === 'string' ? c : (Array.isArray(c) ? c.map(blockText).filter(Boolean).join('\n') : '');
1137
- return body ? `${(m.role || '?').toUpperCase()}: ${body}` : '';
1138
- }
1139
-
1140
- /** Characters that actually ride in a forwarded message (content + tool_calls). */
1141
- export function messageChars(m) {
1142
- if (!m) return 0;
1143
- let n = 0;
1144
- const c = m.content;
1145
- if (typeof c === 'string') n += c.length;
1146
- else if (Array.isArray(c)) {
1147
- for (const b of c) {
1148
- if (typeof b === 'string') n += b.length;
1149
- else if (b && typeof b === 'object') {
1150
- if (typeof b.text === 'string') n += b.text.length;
1151
- else if (typeof b.content === 'string') n += b.content.length;
1152
- else if (b.content != null) n += toolContentLength(b.content);
1153
- else n += JSON.stringify(b).length;
1154
- }
1155
- }
1156
- } else if (c && typeof c === 'object') n += JSON.stringify(c).length;
1157
- if (Array.isArray(m.tool_calls)) {
1158
- for (const tc of m.tool_calls) n += toolCallArgLength(tc);
1159
- }
1160
- const legacy = m.function_call;
1161
- if (legacy) n += toolCallArgLength(legacy);
1162
- return n;
1163
- }
1164
-
1165
- export function sliceChars(msgs, from = 0, to = undefined) {
1166
- if (!Array.isArray(msgs)) return 0;
1167
- const end = to == null ? msgs.length : to;
1168
- let n = 0;
1169
- for (let i = from; i < end && i < msgs.length; i++) n += messageChars(msgs[i]);
1170
- return n;
1171
- }
1172
-
1173
- export function spillRatio(corpusChars, sentChars) {
1174
- const c = Number(corpusChars) || 0;
1175
- const s = Number(sentChars) || 0;
1176
- if (s <= 0) return c > 0 ? Infinity : 0;
1177
- return c / s;
1178
- }
1179
-
1180
- function envNumber(key, fallback) {
1181
- const v = Number(process.env[key]);
1182
- return Number.isFinite(v) && v > 0 ? v : fallback;
1183
- }
1184
-
1185
- export function envKnobs() {
1186
- return {
1187
- keepTail: envNumber('OPENZOO_KEEP_TAIL_MSGS', KNOB_DEFAULTS.keepTail),
1188
- minTurns: envNumber('OPENZOO_TAIL_MIN_TURNS', KNOB_DEFAULTS.minTurns),
1189
- budget: envNumber('OPENZOO_TAIL_MAX_CHARS', KNOB_DEFAULTS.budget),
1190
- stubMore: process.env.OPENZOO_STUB_MORE === '1',
1191
- };
1192
- }
1193
-
1194
- export function adaptEnabled() {
1195
- return process.env.OPENZOO_ADAPT !== '0';
1196
- }
1197
-
1198
- export function knobsFile(home = os.homedir()) {
1199
- return process.env.OPENZOO_KNOBS_PATH
1200
- || path.join(home, '.openzoo', 'knobs.json');
1201
- }
1202
-
1203
- function clampInt(n, lo, hi, fallback) {
1204
- const v = Number(n);
1205
- if (!Number.isFinite(v)) return fallback;
1206
- return Math.min(hi, Math.max(lo, Math.round(v)));
1207
- }
1208
-
1209
- export function sanitizeKnobs(raw = {}) {
1210
- if (!raw || typeof raw !== 'object') return { ...KNOB_DEFAULTS };
1211
- return {
1212
- keepTail: clampInt(raw.keepTail, KEEP_STEPS[0], KEEP_STEPS[KEEP_STEPS.length - 1], KNOB_DEFAULTS.keepTail),
1213
- minTurns: clampInt(raw.minTurns, TURNS_STEPS[0], TURNS_STEPS[TURNS_STEPS.length - 1], KNOB_DEFAULTS.minTurns),
1214
- budget: clampInt(raw.budget, BUDGET_STEPS[0], BUDGET_STEPS[BUDGET_STEPS.length - 1], KNOB_DEFAULTS.budget),
1215
- stubMore: Boolean(raw.stubMore),
1216
- };
1217
- }
1218
-
1219
- export function loadKnobs(extra = {}) {
1220
- const file = extra.file || knobsFile(extra.home);
1221
- let raw;
1222
- try { raw = fs.readFileSync(file, 'utf8'); } catch { return { ok: false, reason: 'missing' }; }
1223
- let data;
1224
- try { data = JSON.parse(raw); } catch { return { ok: false, reason: 'corrupt' }; }
1225
- if (!data || typeof data !== 'object') return { ok: false, reason: 'corrupt' };
1226
- return { ok: true, knobs: sanitizeKnobs(data) };
1227
- }
1228
-
1229
- export function persistKnobs(knobs, extra = {}) {
1230
- const file = extra.file || knobsFile(extra.home);
1231
- try {
1232
- fs.mkdirSync(path.dirname(file), { recursive: true });
1233
- const tmp = `${file}.tmp`;
1234
- fs.writeFileSync(tmp, JSON.stringify(sanitizeKnobs(knobs)));
1235
- fs.renameSync(tmp, file);
1236
- return true;
1237
- } catch {
1238
- return false;
1239
- }
1240
- }
1241
-
1242
- let memoryKnobs = null;
1243
- let lastAdaptAction = 'hold';
1244
- let knobsLoaded = false;
1245
-
1246
- export function resetAdaptState(knobs = null) {
1247
- memoryKnobs = knobs ? sanitizeKnobs(knobs) : null;
1248
- lastAdaptAction = 'hold';
1249
- knobsLoaded = false;
1250
- }
1251
-
1252
- export function lastAdapt() {
1253
- return lastAdaptAction;
1254
- }
1255
-
1256
- export function getLiveKnobs(extra = {}) {
1257
- if (!adaptEnabled()) return envKnobs();
1258
- if (memoryKnobs) return { ...memoryKnobs };
1259
- if (!knobsLoaded) {
1260
- knobsLoaded = true;
1261
- const loaded = loadKnobs(extra);
1262
- if (loaded.ok) {
1263
- memoryKnobs = sanitizeKnobs({ ...envKnobs(), ...loaded.knobs });
1264
- return { ...memoryKnobs };
1265
- }
1266
- }
1267
- memoryKnobs = envKnobs();
1268
- return { ...memoryKnobs };
1269
- }
1270
-
1271
- export function rememberKnobs(knobs, extra = {}) {
1272
- memoryKnobs = sanitizeKnobs(knobs);
1273
- if (extra.persist !== false) persistKnobs(memoryKnobs, extra);
1274
- return { ...memoryKnobs };
1275
- }
1276
-
1277
- function nearestIndex(steps, value) {
1278
- let best = 0;
1279
- let dist = Infinity;
1280
- for (let i = 0; i < steps.length; i++) {
1281
- const d = Math.abs(steps[i] - value);
1282
- if (d < dist) { dist = d; best = i; }
1283
- }
1284
- return best;
1285
- }
1286
-
1287
- export function tightenKnobs(knobs, { ratio, corpusChars, target = ADAPT_TARGET } = {}) {
1288
- const cur = sanitizeKnobs(knobs);
1289
- const gap = target / Math.max(Number(ratio) || 0.01, 0.01);
1290
- // Mild miss: one notch. Far below target (live 1–4x): jump toward the
1291
- // floor so a single recut can clear 10x. Always stub more on the way down.
1292
- const steps = gap > 1.5 ? 2 : 1;
1293
- const keepI = Math.max(0, nearestIndex(KEEP_STEPS, cur.keepTail) - steps);
1294
- const turnI = Math.max(0, nearestIndex(TURNS_STEPS, cur.minTurns) - steps);
1295
- let budget = cur.budget;
1296
- for (let i = 0; i < steps; i++) {
1297
- budget = BUDGET_STEPS[Math.max(0, nearestIndex(BUDGET_STEPS, budget) - 1)];
1298
- }
1299
- if (gap > 1.5 && Number.isFinite(corpusChars) && corpusChars > 0) {
1300
- const needSent = Math.floor(corpusChars / target);
1301
- if (needSent > 0) budget = Math.min(budget, Math.max(BUDGET_STEPS[0], needSent));
1302
- return sanitizeKnobs({
1303
- keepTail: KEEP_STEPS[0],
1304
- minTurns: TURNS_STEPS[0],
1305
- budget,
1306
- stubMore: true,
1307
- });
1308
- }
1309
- return sanitizeKnobs({
1310
- keepTail: KEEP_STEPS[keepI],
1311
- minTurns: TURNS_STEPS[turnI],
1312
- budget,
1313
- stubMore: true,
1314
- });
1315
- }
1316
-
1317
- export function loosenKnobs(knobs) {
1318
- const cur = sanitizeKnobs(knobs);
1319
- const keepI = Math.min(KEEP_STEPS.length - 1, nearestIndex(KEEP_STEPS, cur.keepTail) + 1);
1320
- const turnI = Math.min(TURNS_STEPS.length - 1, nearestIndex(TURNS_STEPS, cur.minTurns) + 1);
1321
- const budI = Math.min(BUDGET_STEPS.length - 1, nearestIndex(BUDGET_STEPS, cur.budget) + 1);
1322
- return sanitizeKnobs({
1323
- keepTail: KEEP_STEPS[keepI],
1324
- minTurns: TURNS_STEPS[turnI],
1325
- budget: BUDGET_STEPS[budI],
1326
- stubMore: false,
1327
- });
1328
- }
1329
-
1330
- function sameKnobs(a, b) {
1331
- return a.keepTail === b.keepTail
1332
- && a.minTurns === b.minTurns
1333
- && a.budget === b.budget
1334
- && Boolean(a.stubMore) === Boolean(b.stubMore);
1335
- }
1336
-
1337
- function fmtRatio(ratio) {
1338
- if (!Number.isFinite(ratio)) return 'inf';
1339
- return String(Number(ratio.toFixed(2)));
1340
- }
1341
-
1342
- function adaptLine({ action, ratio, knobs, target = ADAPT_TARGET, dollarX } = {}) {
1343
- const d = Number(dollarX);
1344
- const hasD = Number.isFinite(d) && d > 0;
1345
- const via = hasD
1346
- ? `dollar=${fmtRatio(d)} chars=${fmtRatio(ratio)}`
1347
- : `ratio=${fmtRatio(ratio)}`;
1348
- if (action === 'hold') return `adapt hold ${via}`;
1349
- return `adapt ${via} target=${target} tail=${knobs.keepTail} budget=${knobs.budget}`;
1350
- }
1351
-
1352
- /**
1353
- * Decide whether to shrink, loosen, or hold.
1354
- *
1355
- * Score the HUD dollar multiple (direct/billed) when present. Char ratio
1356
- * is the fallback only. Tighten recuts this request. Loosen only when the
1357
- * scored metric is above loosenAt AND last action was hold — never loosen
1358
- * off a char-only overshoot while dollar x is in hand and below loosenAt.
1359
- */
1360
- export function adaptTail({
1361
- ratio,
1362
- dollarX,
1363
- lastSend,
1364
- knobs,
1365
- lastAction = 'hold',
1366
- corpusChars,
1367
- target = ADAPT_TARGET,
1368
- loosenAt = ADAPT_LOOSEN_AT,
1369
- } = {}) {
1370
- const cur = sanitizeKnobs(knobs);
1371
- const dollar = Number(dollarX);
1372
- const hasDollar = Number.isFinite(dollar) && dollar > 0;
1373
- const score = hasDollar ? dollar : Number(ratio);
1374
- const sendN = Number(lastSend);
1375
- const sendGrowing = Number.isFinite(sendN) && sendN > LAST_SEND_TIGHTEN;
1376
- const line = (action, knobsNow) => adaptLine({
1377
- action, ratio, knobs: knobsNow, target, dollarX: hasDollar ? dollar : undefined,
1378
- });
1379
- if (!Number.isFinite(score)) {
1380
- return { action: 'hold', knobs: cur, recut: false, ratio, dollarX: hasDollar ? dollar : null, score, log: line('hold', cur) };
1381
- }
1382
- // Dollar miss, or lastSend growing while dollar x is under target.
1383
- if (score < target || (hasDollar && dollar < target && sendGrowing)) {
1384
- const next = tightenKnobs(cur, { ratio: score, corpusChars, target });
1385
- const changed = !sameKnobs(next, cur);
1386
- const action = changed ? 'tighten' : 'hold';
1387
- return { action, knobs: next, recut: changed, ratio, dollarX: hasDollar ? dollar : null, score, log: line(action, next) };
1388
- }
1389
- if (score > loosenAt && lastAction === 'hold') {
1390
- const next = loosenKnobs(cur);
1391
- const changed = !sameKnobs(next, cur);
1392
- const action = changed ? 'loosen' : 'hold';
1393
- return { action, knobs: next, recut: false, ratio, dollarX: hasDollar ? dollar : null, score, log: line(action, next) };
1394
- }
1395
- return { action: 'hold', knobs: cur, recut: false, ratio, dollarX: hasDollar ? dollar : null, score, log: line('hold', cur) };
1396
- }
1397
-
1398
- function firstSpillableIndex(msgs) {
1399
- return msgs.findIndex((m) => m?.role !== 'system');
1400
- }
1401
-
1402
- function lastUserAskIndex(msgs, firstSpillable) {
1403
- for (let i = msgs.length - 1; i > firstSpillable; i--) {
1404
- if (msgs[i]?.role === 'user' && msgText(msgs[i]).trim()) return i;
1405
- }
1406
- return -1;
1407
- }
1408
-
1409
- function countRealTurns(msgs, from, to = msgs.length) {
1410
- let n = 0;
1411
- for (let i = from; i < to; i++) {
1412
- const r = msgs[i]?.role;
1413
- if (r === 'user' || r === 'assistant') n += 1;
1414
- }
1415
- return n;
1416
- }
1417
-
1418
- function isSeverable(msgs, i, firstSpillable) {
1419
- if (i <= firstSpillable || i >= msgs.length) return false;
1420
- const prev = msgs[i - 1];
1421
- if (!prev) return false;
1422
- if (prev.role === 'assistant' && Array.isArray(prev.tool_calls) && prev.tool_calls.length) return false;
1423
- return msgs[i].role !== 'tool';
1424
- }
1425
-
1426
- /**
1427
- * cutTranscript pins `cut` at the last user ask, so every later tool round
1428
- * on a continue / ultracode turn used to ride in the forwarded tail. Keep
1429
- * the newest `keepTail` complete assistant(tool_calls)+results rounds after
1430
- * that ask and drop the older ones. Pairing stays valid; the ask stays;
1431
- * the latest round is never dropped.
1432
- */
1433
- export function trimRoundsAfterAsk(msgs, {
1434
- fromIndex = 0,
1435
- keepTail = 1,
1436
- lastUser = -1,
1437
- } = {}) {
1438
- if (!Array.isArray(msgs) || lastUser < 0) return msgs;
1439
- const keep = Math.max(1, Math.round(Number(keepTail) || 1));
1440
- const start = Math.max(fromIndex, lastUser + 1);
1441
- const starts = [];
1442
- for (let i = start; i < msgs.length; i++) {
1443
- if (toolCallIds(msgs[i]).length) starts.push(i);
1444
- }
1445
- if (starts.length <= keep) return msgs;
1446
- const keepFrom = starts[starts.length - keep];
1447
- const out = [];
1448
- for (let i = 0; i < msgs.length; i++) {
1449
- if (i > lastUser && i < keepFrom) continue;
1450
- out.push(msgs[i]);
1451
- }
1452
- return out;
1453
- }
1454
-
1455
- /**
1456
- * On an un-severable assistant(tool_calls)+results chain, keepTail cannot
1457
- * move `cut`. Drop older complete pairs (rewrite tool_calls, drop their
1458
- * results) so keep 16 vs 2 actually differ and the byte budget can land.
1459
- */
1460
- export function trimUnseverablePairs(msgs, {
1461
- fromIndex = 0,
1462
- keepTail = 2,
1463
- lastUser = -1,
1464
- firstSpillable = 0,
1465
- } = {}) {
1466
- if (!Array.isArray(msgs) || !msgs.length) return msgs;
1467
- const keep = Math.max(1, Math.round(Number(keepTail) || 2));
1468
- const end = lastUser > fromIndex ? lastUser : msgs.length;
1469
- let asstIdx = -1;
1470
- for (let i = fromIndex; i < end; i++) {
1471
- if (Array.isArray(msgs[i]?.tool_calls) && msgs[i].tool_calls.length) {
1472
- asstIdx = i;
1473
- break;
1474
- }
1475
- }
1476
- if (asstIdx < 0) return msgs;
1477
- for (let i = asstIdx + 1; i < end; i++) {
1478
- if (isSeverable(msgs, i, firstSpillable)) return msgs;
1479
- }
1480
- const calls = msgs[asstIdx].tool_calls;
1481
- if (calls.length <= keep) return msgs;
1482
- const keptCalls = calls.slice(-keep);
1483
- const keptIds = new Set(keptCalls.map((c) => c.id).filter(Boolean));
1484
- const out = [];
1485
- for (let i = 0; i < msgs.length; i++) {
1486
- const m = msgs[i];
1487
- if (i === asstIdx) {
1488
- out.push({ ...m, tool_calls: keptCalls });
1489
- continue;
1490
- }
1491
- if (i > asstIdx && i !== lastUser && m?.role === 'tool' && m.tool_call_id && !keptIds.has(m.tool_call_id)) {
1492
- continue;
1493
- }
1494
- if (i > asstIdx && i !== lastUser && Array.isArray(m?.content)) {
1495
- const blocks = m.content.filter((b) => b?.type !== 'tool_result' || keptIds.has(b.tool_use_id));
1496
- if (blocks.length !== m.content.length) {
1497
- if (!blocks.length && m.role !== 'assistant') continue;
1498
- out.push({ ...m, content: blocks });
1499
- continue;
1500
- }
1501
- }
1502
- out.push(m);
1503
- }
1504
- return out;
1505
- }
1506
-
1507
- /**
1508
- * Pick a severable cut: keep a recent tail, honour the byte budget, floor
1509
- * at minTurns of user/assistant on a LONG thread, and never drop the last
1510
- * user ask. minTurns must not empty the bind prefix — a short AUTO thread
1511
- * binds early turns and may forward a tail with fewer than minTurns.
1512
- */
1513
- export function cutTranscript(msgs, knobs = {}) {
1514
- const k = sanitizeKnobs({ ...envKnobs(), ...knobs });
1515
- if (!Array.isArray(msgs) || !msgs.length) {
1516
- return { cut: -1, firstSpillable: -1, lastUser: -1, knobs: k };
1517
- }
1518
- const firstSpillable = firstSpillableIndex(msgs);
1519
- if (firstSpillable < 0) return { cut: -1, firstSpillable: -1, lastUser: -1, knobs: k };
1520
-
1521
- const keepTail = Math.min(k.keepTail, Math.max(2, Math.floor(msgs.length / 2)));
1522
- const minTurns = Math.max(2, k.minTurns);
1523
- const budget = k.budget;
1524
-
1525
- const lastUser = lastUserAskIndex(msgs, firstSpillable);
1526
-
1527
- let cut = -1;
1528
- for (let i = msgs.length - keepTail; i > firstSpillable; i--) {
1529
- if (isSeverable(msgs, i, firstSpillable)) { cut = i; break; }
1530
- }
1531
- if (cut <= firstSpillable) {
1532
- for (let i = msgs.length - 2; i > firstSpillable; i--) {
1533
- if (isSeverable(msgs, i, firstSpillable)) { cut = i; break; }
1534
- }
1535
- }
1536
- if (cut <= firstSpillable) {
1537
- return { cut: -1, firstSpillable, lastUser, knobs: k };
1538
- }
1539
-
1540
- // Only moves the cut at a severable index. A current-turn tool storm
1541
- // (assistant(tool_calls) + N tool results + user ask) has no severable
1542
- // index inside the chain, so this walk is a no-op — the byte budget is
1543
- // applied by stubbing bodies in stubBoundFileResults, not by orphaning
1544
- // a tool_result.
1545
- //
1546
- // When the overflow IS severable (grokui text hops: a fat previous
1547
- // command output sitting just before the current ask), start the tail
1548
- // AFTER that message so the live window is actually bounded. Starting
1549
- // AT the overflowing index left the fat hop in the forwarded body —
1550
- // spent≈direct on 1-model AUTO even after a cut existed.
1551
- let tailStart = cut;
1552
- {
1553
- let used = 0;
1554
- for (let i = msgs.length - 1; i >= cut; i--) {
1555
- used += msgText(msgs[i]).length;
1556
- if (used <= budget) continue;
1557
- const after = i + 1;
1558
- // Plain user/assistant hops only. Jumping after a tool_result would
1559
- // drop the current tool chain from the tail (orphans / lost pairing);
1560
- // those storms stay in-window and get stubbed.
1561
- const overflow = msgs[i];
1562
- const plainHop = overflow?.role === 'user' || (overflow?.role === 'assistant' && !toolCallIds(overflow).length);
1563
- if (plainHop && lastUser >= 0 && after <= lastUser && after > firstSpillable
1564
- && isSeverable(msgs, after, firstSpillable)) {
1565
- tailStart = after;
1566
- break;
1567
- }
1568
- if (isSeverable(msgs, i, firstSpillable)) { tailStart = i; break; }
1569
- }
1570
- }
1571
- if (tailStart > cut) cut = tailStart;
1572
-
1573
- // minTurns may still size the tail on a long thread, but never past the
1574
- // byte budget (that pulled fat 1-model hops back so sent≈unspilled).
1575
- // If walking earlier would empty the bind prefix, keep a non-empty prefix
1576
- // and allow fewer than minTurns. On a short thread that cannot satisfy
1577
- // minTurns at all, pin the last ask so early turns bind.
1578
- if (countRealTurns(msgs, cut) < minTurns) {
1579
- let moved = false;
1580
- for (let i = cut - 1; i > firstSpillable; i--) {
1581
- if (isSeverable(msgs, i, firstSpillable) && countRealTurns(msgs, i) >= minTurns) {
1582
- if (sliceChars(msgs, i) > budget) break;
1583
- cut = i;
1584
- moved = true;
1585
- break;
1586
- }
1587
- }
1588
- if (!moved && lastUser > firstSpillable && isSeverable(msgs, lastUser, firstSpillable)) {
1589
- cut = lastUser;
1590
- }
1591
- }
1592
-
1593
- if (lastUser > firstSpillable && cut > lastUser) cut = lastUser;
1594
-
1595
- // 2-real-turn floor protects the last user ask: never drop it, and expand
1596
- // the tail to two turns on a LONG thread. Do not steal the first
1597
- // user+assistant pair from the prefix just to pad a short tail, and do
1598
- // not expand past the byte budget (that re-imported fat 1-model hops).
1599
- if (lastUser > firstSpillable && countRealTurns(msgs, cut) < 2) {
1600
- for (let i = cut - 1; i > firstSpillable; i--) {
1601
- if (!isSeverable(msgs, i, firstSpillable) || countRealTurns(msgs, i) < 2) continue;
1602
- if (countRealTurns(msgs, firstSpillable, i) < 2) continue;
1603
- if (sliceChars(msgs, i) > budget) break;
1604
- cut = i;
1605
- break;
1606
- }
1607
- if (cut > lastUser) cut = lastUser;
1608
- }
1609
-
1610
- return { cut, firstSpillable, lastUser, knobs: k };
1611
- }
1612
-
1613
- /** Opening slice used as a content-anchor when no session header is sent. */
1614
- export const SPILL_CONTENT_ANCHOR_CHARS = 2048;
1615
-
1616
- /**
1617
- * Find a memoized / ledger bind for this request. Prefers an explicit
1618
- * session key; otherwise matches a stored prefix so a growing content-anchor
1619
- * (grokui AUTO sends no session header) still recalls the same contextId.
1620
- */
1621
- export function lookupSpillMemo(spillMemo, sessionLedger, { sessionKey, corpus } = {}) {
1622
- if (sessionKey && spillMemo?.has(sessionKey)) {
1623
- return { key: sessionKey, source: 'memo', ...spillMemo.get(sessionKey) };
1624
- }
1625
- if (sessionKey && sessionLedger?.has(sessionKey)) {
1626
- const led = sessionLedger.get(sessionKey);
1627
- if (led?.contextId) return { key: sessionKey, source: 'ledger', restored: !led.corpus, ...led };
1628
- }
1629
- if (corpus && spillMemo) {
1630
- for (const [k, v] of spillMemo) {
1631
- if (typeof v?.corpus === 'string' && v.corpus.length && corpus.startsWith(v.corpus)) {
1632
- return { key: k, source: 'memo-prefix', ...v };
1633
- }
1634
- }
1635
- const opening = corpus.slice(0, SPILL_CONTENT_ANCHOR_CHARS);
1636
- for (const [k, v] of spillMemo) {
1637
- if (typeof k !== 'string' || k.startsWith('sid:')) continue;
1638
- if (opening.startsWith(k) || (k && k.startsWith(opening))) {
1639
- return { key: k, source: 'memo-anchor', ...v };
1640
- }
1641
- }
1642
- }
1643
- return null;
1644
- }
1645
-
1646
- export function rememberSpillMemo(spillMemo, key, entry, { max = 32 } = {}) {
1647
- if (!spillMemo || !key) return;
1648
- spillMemo.set(key, entry);
1649
- while (spillMemo.size > max) spillMemo.delete(spillMemo.keys().next().value);
1650
- }
1651
-
1652
- /**
1653
- * Decide first-bind vs later-turn recall. No I/O — bindCorpus is injected
1654
- * by the caller (or mocked in tests).
1655
- *
1656
- * cold-bind — fire-and-forget first bind; this turn may go unspilled
1657
- * await-pending — later turn while that bind is in flight; await, then tail
1658
- * recall — contextId known; send tail (+ optional delta append)
1659
- */
1660
- export function planConversationBind({ sessionKey, corpus, spillMemo, sessionLedger } = {}) {
1661
- const key = sessionKey || (corpus ? corpus.slice(0, SPILL_CONTENT_ANCHOR_CHARS) : null);
1662
- const prior = lookupSpillMemo(spillMemo, sessionLedger, { sessionKey: key, corpus });
1663
- if (!prior) {
1664
- return { action: 'cold-bind', send: 'full', key, corpus };
1665
- }
1666
- if (prior.pending && !prior.contextId) {
1667
- return {
1668
- action: 'await-pending',
1669
- send: 'tail',
1670
- key: prior.key || key,
1671
- corpus,
1672
- ready: prior.ready,
1673
- };
1674
- }
1675
- if (prior.contextId) {
1676
- let delta = '';
1677
- let append = false;
1678
- if (prior.restored) {
1679
- append = true;
1680
- } else if (typeof prior.corpus === 'string' && corpus.startsWith(prior.corpus) && corpus.length > prior.corpus.length) {
1681
- delta = corpus.slice(prior.corpus.length);
1682
- append = true;
1683
- }
1684
- return {
1685
- action: 'recall',
1686
- send: 'tail',
1687
- key: prior.key || key,
1688
- corpus,
1689
- contextId: prior.contextId,
1690
- hash: prior.hash,
1691
- reused: true,
1692
- append,
1693
- delta,
1694
- restored: Boolean(prior.restored),
1695
- };
1696
- }
1697
- return { action: 'cold-bind', send: 'full', key, corpus };
1698
- }
1699
-
1700
- /**
1701
- * Cut the transcript and plan the conversation bind. Tests use this instead
1702
- * of standing up the proxy or a live gateway.
1703
- */
1704
- export function planTranscriptSpill(msgs, {
1705
- knobs,
1706
- sessionKey,
1707
- spillMemo,
1708
- sessionLedger,
1709
- corpusChars = 0,
1710
- adapt = false,
1711
- persist = false,
1712
- ...cutOpts
1713
- } = {}) {
1714
- const adapted = applySpillCut(msgs, {
1715
- knobs,
1716
- corpusChars,
1717
- adapt,
1718
- persist,
1719
- ...cutOpts,
1720
- });
1721
- const empty = { ...adapted, corpus: '', bindPlan: null };
1722
- if (adapted.cut <= adapted.firstSpillable) return empty;
1723
- const corpus = msgs.slice(adapted.firstSpillable, adapted.cut).map(msgText).filter(Boolean).join('\n\n');
1724
- const bindPlan = planConversationBind({ sessionKey, corpus, spillMemo, sessionLedger });
1725
- return { ...adapted, corpus, bindPlan };
1726
- }
1727
-
1728
- function stubForCut(msgs, cut, opts) {
1729
- return stubBoundFileResults(msgs, {
1730
- boundFiles: opts.boundFiles,
1731
- boundAbs: opts.boundAbs,
1732
- cwd: opts.cwd,
1733
- fromIndex: Math.max(0, cut),
1734
- aggressive: Boolean(opts.aggressive),
1735
- budget: opts.budget,
1736
- keepTail: opts.keepTail,
1737
- recall: opts.recall,
1738
- });
1739
- }
1740
-
1741
- /**
1742
- * Cut + stub, then retune knobs toward the HUD dollar multiple (direct /
1743
- * billed) when present, else corpus/sent. A miss recuts once this request
1744
- * (smaller keepTail; stubMore for SEARCH, not live file bodies). A dollar
1745
- * overshoot loosens one notch for the next request only (no flip-flop).
1746
- * Env OPENZOO_ADAPT=0 disables the tuner; env still seeds the initial knobs.
1747
- */
1748
- export function applySpillCut(msgs, {
1749
- knobs,
1750
- corpusChars = 0,
1751
- boundFiles,
1752
- boundAbs,
1753
- cwd = process.cwd(),
1754
- log = () => {},
1755
- adapt = adaptEnabled(),
1756
- persist = false,
1757
- file,
1758
- home,
1759
- dollarX,
1760
- lastSend,
1761
- recall,
1762
- } = {}) {
1763
- const persistOpts = { persist, file, home };
1764
- let k = sanitizeKnobs(knobs || getLiveKnobs(persistOpts));
1765
- let plan = cutTranscript(msgs, k);
1766
- const empty = {
1767
- cut: plan.cut,
1768
- firstSpillable: plan.firstSpillable,
1769
- lastUser: plan.lastUser,
1770
- knobs: k,
1771
- stubbed: { messages: msgs, stubbed: 0, dropped: 0 },
1772
- sentChars: 0,
1773
- prefixChars: 0,
1774
- ratio: 0,
1775
- action: 'hold',
1776
- };
1777
- if (plan.cut <= plan.firstSpillable) {
1778
- // No severable index — still stub/trim the un-severable tail so a
1779
- // 300-result storm is not forwarded at full size.
1780
- const stubFrom = plan.firstSpillable >= 0 ? plan.firstSpillable : 0;
1781
- let stubbed = stubForCut(msgs, stubFrom, {
1782
- boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail, recall,
1783
- });
1784
- let sentChars = sliceChars(stubbed.messages, stubFrom);
1785
- const corpus = Math.max(Number(corpusChars) || 0, sentChars);
1786
- let ratio = spillRatio(corpus, sentChars);
1787
- let action = 'hold';
1788
- if (adapt) {
1789
- const thisSend = Math.max(0, stubbed.messages.length - stubFrom);
1790
- const decision = adaptTail({
1791
- ratio,
1792
- dollarX,
1793
- lastSend: Math.max(Number(lastSend) || 0, thisSend),
1794
- knobs: k,
1795
- lastAction: lastAdaptAction,
1796
- corpusChars: corpus,
1797
- });
1798
- k = decision.knobs;
1799
- action = decision.action;
1800
- if (decision.recut) {
1801
- stubbed = stubForCut(msgs, stubFrom, {
1802
- boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail, recall,
1803
- });
1804
- sentChars = sliceChars(stubbed.messages, stubFrom);
1805
- ratio = spillRatio(corpus, sentChars);
1806
- }
1807
- rememberKnobs(k, persistOpts);
1808
- lastAdaptAction = action;
1809
- log(adaptLine({ action, ratio, knobs: k, dollarX }));
1810
- }
1811
- return { ...empty, knobs: k, stubbed, sentChars, ratio, action };
1812
- }
1813
-
1814
- const measure = (cut, stubbed, knobsNow) => {
1815
- const prefixChars = sliceChars(msgs, plan.firstSpillable, cut);
1816
- const sentChars = sliceChars(stubbed.messages, cut);
1817
- const corpus = Math.max(Number(corpusChars) || 0, prefixChars);
1818
- return {
1819
- prefixChars,
1820
- sentChars,
1821
- corpusChars: corpus,
1822
- ratio: spillRatio(corpus, sentChars),
1823
- knobs: knobsNow,
1824
- };
1825
- };
1826
-
1827
- let stubbed = stubForCut(msgs, plan.cut, {
1828
- boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail, recall,
1829
- });
1830
- let stats = measure(plan.cut, stubbed, k);
1831
- let action = 'hold';
1832
-
1833
- if (adapt) {
1834
- const thisSend = Math.max(0, stubbed.messages.length - plan.cut);
1835
- const decision = adaptTail({
1836
- ratio: stats.ratio,
1837
- dollarX,
1838
- lastSend: Math.max(Number(lastSend) || 0, thisSend),
1839
- knobs: k,
1840
- lastAction: lastAdaptAction,
1841
- corpusChars: stats.corpusChars,
1842
- });
1843
- k = decision.knobs;
1844
- action = decision.action;
1845
- if (decision.recut) {
1846
- plan = cutTranscript(msgs, k);
1847
- if (plan.cut > plan.firstSpillable) {
1848
- stubbed = stubForCut(msgs, plan.cut, {
1849
- boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail, recall,
1850
- });
1851
- stats = measure(plan.cut, stubbed, k);
1852
- }
1853
- }
1854
- rememberKnobs(k, persistOpts);
1855
- lastAdaptAction = action;
1856
- log(adaptLine({ action, ratio: stats.ratio, knobs: k, dollarX }));
1857
- }
1858
-
1859
- return {
1860
- cut: plan.cut,
1861
- firstSpillable: plan.firstSpillable,
1862
- lastUser: plan.lastUser,
1863
- knobs: k,
1864
- stubbed,
1865
- sentChars: stats.sentChars,
1866
- prefixChars: stats.prefixChars,
1867
- ratio: stats.ratio,
1868
- action,
1869
- };
1870
- }
1871
-
1872
- /**
1873
- * Same threshold hrr.js BIND_MIN_CHARS uses. Kept here so the 1-model /
1874
- * raced AUTO decision can be tested without standing up the bind client.
1875
- */
1876
- export const SPILL_MIN_PREFIX_CHARS = Number(process.env.OPENZOO_CONTEXT_MIN_CHARS || 16384);
1877
-
1878
- /**
1879
- * zoo_ask / chat-surface shape: one huge final string ending in `\n\n<ask>`.
1880
- * Agent transcripts (Claude Code, grokui) never look like this.
1881
- */
1882
- export function isOneShotCorpusAsk(msgs, minChars = SPILL_MIN_PREFIX_CHARS) {
1883
- if (!Array.isArray(msgs) || !msgs.length) return false;
1884
- const last = msgs[msgs.length - 1];
1885
- if (typeof last?.content !== 'string') return false;
1886
- if (last.content.length <= minChars) return false;
1887
- if (last.content.lastIndexOf('\n\n') < minChars) return false;
1888
- const ask = last.content.slice(last.content.lastIndexOf('\n\n') + 2).trim();
1889
- return Boolean(ask && ask.length <= 8000);
1890
- }
1891
-
1892
- function emptySpillDecision(msgs, reason) {
1893
- return {
1894
- mode: 'passthrough',
1895
- reason,
1896
- setHrrContext: false,
1897
- forwarded: msgs,
1898
- prefix: '',
1899
- head: [],
1900
- tail: msgs,
1901
- sentChars: sliceChars(msgs),
1902
- unspilledChars: sliceChars(msgs),
1903
- };
1904
- }
1905
-
1906
- /**
1907
- * THE spill/bind/forward gate. 1-model grokui and raced grokui AUTO both
1908
- * POST /chat/completions through maybeCacheCorpus → this decision. `race`,
1909
- * `race_need`, and `tier` on the body are ignored — the cut is a property
1910
- * of messages[], not of how many models will read them.
1911
- *
1912
- * Oversized: bind the old prefix, forward system + bounded tail, and the
1913
- * sidecar must set x-hrr-context (setHrrContext). Small: passthrough,
1914
- * no bind, no header. Does not talk to the network.
1915
- */
1916
- export function decideChatSpill(body, opts = {}) {
1917
- const msgs = Array.isArray(body?.messages) ? body.messages : null;
1918
- if (!msgs?.length) return emptySpillDecision(msgs, 'no-messages');
1919
- let minChars = opts.minPrefixChars ?? SPILL_MIN_PREFIX_CHARS;
1920
- const dx = Number(opts.dollarX);
1921
- const model = String(body?.model || '').trim().toLowerCase();
1922
- const auto = model === 'openzoo/auto' || model === 'openzoo-auto' || model === 'auto';
1923
- // Thin HUD / Auto: bind way earlier than 16k. 2.1x session is cheap-model
1924
- // vs cheap-model because the prefix never cleared the gate.
1925
- if (auto || (Number.isFinite(dx) && dx > 0 && dx < 5)) {
1926
- minChars = Math.min(minChars, 2048);
1927
- }
1928
-
1929
- if (isOneShotCorpusAsk(msgs, minChars)) {
1930
- const last = msgs[msgs.length - 1];
1931
- const at = last.content.lastIndexOf('\n\n');
1932
- const prefix = last.content.slice(0, at);
1933
- const ask = last.content.slice(at + 2).trim();
1934
- const forwarded = [...msgs.slice(0, -1), { ...last, content: ask }];
1935
- return {
1936
- mode: 'oneshot',
1937
- reason: 'corpus-question',
1938
- setHrrContext: true,
1939
- prefix,
1940
- ask,
1941
- forwarded,
1942
- head: msgs.slice(0, -1),
1943
- tail: [{ ...last, content: ask }],
1944
- sentChars: sliceChars(forwarded),
1945
- unspilledChars: sliceChars(msgs),
1946
- };
1947
- }
1948
-
1949
- const adapted = applySpillCut(msgs, opts);
1950
- if (adapted.cut <= adapted.firstSpillable) {
1951
- if (adapted.stubbed?.dropped || adapted.stubbed?.stubbed) {
1952
- return {
1953
- mode: 'stub-only',
1954
- reason: 'no-cut-stubbed',
1955
- setHrrContext: false,
1956
- adapted,
1957
- forwarded: adapted.stubbed.messages,
1958
- prefix: '',
1959
- head: [],
1960
- tail: adapted.stubbed.messages,
1961
- sentChars: sliceChars(adapted.stubbed.messages),
1962
- unspilledChars: sliceChars(msgs),
1963
- };
1964
- }
1965
- return { ...emptySpillDecision(msgs, 'no-cut'), adapted };
1966
- }
1967
-
1968
- const prefix = msgs.slice(adapted.firstSpillable, adapted.cut)
1969
- .map(msgText).filter(Boolean).join('\n\n');
1970
- if (prefix.length <= minChars) {
1971
- return { ...emptySpillDecision(msgs, 'prefix-under-threshold'), adapted, prefix };
1972
- }
1973
-
1974
- const head = msgs.slice(0, adapted.firstSpillable);
1975
- const tail = adapted.stubbed.messages.slice(adapted.cut);
1976
- const forwarded = [...head, ...tail];
1977
- return {
1978
- mode: 'spill',
1979
- reason: 'oversized-prefix',
1980
- setHrrContext: true,
1981
- adapted,
1982
- prefix,
1983
- head,
1984
- tail,
1985
- forwarded,
1986
- cut: adapted.cut,
1987
- firstSpillable: adapted.firstSpillable,
1988
- sentChars: sliceChars(forwarded),
1989
- unspilledChars: sliceChars(msgs),
1990
- };
1991
- }
1992
-
1993
- /** Session counters the HUD reads off /v1/info. */
1994
- export function createSpillStats() {
1995
- return {
1996
- spillCalls: 0,
1997
- spilledChars: 0,
1998
- spillReuses: 0,
1999
- fileBinds: 0,
2000
- fileBindBytes: 0,
2001
- spillSpend: 0,
2002
- spillDirect: 0,
2003
- noteSpill({ corpusChars = 0, reused = false } = {}) {
2004
- this.spillCalls += 1;
2005
- this.spilledChars += corpusChars;
2006
- if (reused) this.spillReuses += 1;
2007
- },
2008
- noteFileBind(n, bytes = 0) {
2009
- if (!n) return;
2010
- this.fileBinds += n;
2011
- this.fileBindBytes += bytes;
2012
- this.spilledChars += bytes;
2013
- },
2014
- snapshot({ boundChars = null } = {}) {
2015
- const unique = boundChars == null ? this.spilledChars : boundChars;
2016
- return {
2017
- calls: this.spillCalls,
2018
- chars: this.spilledChars,
2019
- // Unique bound size — do not re-add the same prefix on every reuse.
2020
- tokensApprox: Math.round(unique / 4),
2021
- reusedBinds: this.spillReuses,
2022
- fileBinds: this.fileBinds,
2023
- fileBindBytes: this.fileBindBytes,
2024
- spend: this.spillSpend,
2025
- direct: this.spillDirect,
2026
- savedUsd: Math.max(0, this.spillDirect - this.spillSpend),
2027
- savingX: this.spillSpend > 0 ? Number((this.spillDirect / this.spillSpend).toFixed(4)) : null,
2028
- };
2029
- },
2030
- };
2031
- }