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/bin/openzoo.js +0 -9
- package/lib/models.js +27 -211
- package/lib/proxy.js +113 -1371
- package/lib/xbot.js +1569 -0
- package/lib/xburner.js +80 -0
- package/package.json +4 -5
- package/lib/anthropic.js +0 -369
- package/lib/boxes.js +0 -299
- package/lib/brief.js +0 -76
- package/lib/grokbot.js +0 -120
- package/lib/grokui.mjs +0 -6295
- package/lib/modelroute/README.md +0 -1
- package/lib/modelroute/catalog.json +0 -1
- package/lib/modelroute/outcomes.json +0 -1566
- package/lib/modelroute/router.json +0 -1
- package/lib/modelroute.js +0 -737
- package/lib/podagent.mjs +0 -1414
- package/lib/responses-stream.js +0 -176
- package/lib/responses.js +0 -425
- package/lib/runguard.js +0 -31
- package/lib/spill.js +0 -2031
- package/lib/worktree.mjs +0 -424
- package/vendor/modelroute/CURRENT_STATE.md +0 -132
- package/vendor/modelroute/FOR_MOOSE.md +0 -110
- package/vendor/modelroute/HANDOFF.md +0 -159
- package/vendor/modelroute/catalog.json +0 -1
- package/vendor/modelroute/holographic_modelroute.py +0 -809
- package/vendor/modelroute/outcomes.json +0 -1566
- package/vendor/modelroute/router.json +0 -1
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
|
-
}
|