openzoo 0.48.72 → 0.48.75
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/models.js +131 -6
- package/lib/proxy.js +104 -237
- package/lib/spill.js +568 -14
- package/package.json +1 -1
package/lib/models.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { config } from './config.js';
|
|
2
2
|
|
|
3
|
+
/** Same threshold as BIND_MIN_CHARS in hrr.js — kept local so this
|
|
4
|
+
* module stays importable without the wallet/rpc stack. */
|
|
5
|
+
const BIND_MIN_CHARS = Number(process.env.OPENZOO_CONTEXT_MIN_CHARS || 16384);
|
|
6
|
+
|
|
3
7
|
/**
|
|
4
8
|
* Model-id rewriting — "any harness, zero model setup".
|
|
5
9
|
*
|
|
@@ -257,11 +261,124 @@ export function rewritablePath(method, url) {
|
|
|
257
261
|
return !/embed|audio|image|moderation/.test(p);
|
|
258
262
|
}
|
|
259
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Families that spend max_tokens on hidden thinking first. A 16/40/160
|
|
266
|
+
* budget on these returns an empty visible completion — measured on Grok
|
|
267
|
+
* and DeepSeek. The raise in raiseReasoningMaxTokens exists for that.
|
|
268
|
+
* It must NEVER fire on Claude Code's 16-token auto-mode classifier.
|
|
269
|
+
*/
|
|
270
|
+
export const REASONING_MODEL_RE = /(deepseek|grok|o[134](-|$)|reasoner|thinking|-pro\b|sol-pro|qwq)/i;
|
|
271
|
+
|
|
272
|
+
/** Claude Code auto-mode classify is max_tokens=16; treat anything in (0, 64] as one. */
|
|
273
|
+
export const CLASSIFY_MAX_TOKENS = 64;
|
|
274
|
+
|
|
275
|
+
const CLASSIFIER_PREFS = ['google/gemini-3.7-flash', 'anthropic/claude-haiku-4.5'];
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Tiny yes/no classify: small body (same BIND_MIN threshold the spill/brief
|
|
279
|
+
* already use) AND a short max_tokens. Detected from the ORIGINAL body,
|
|
280
|
+
* before rewrite or the reasoning floor — otherwise OPENZOO_DEFAULT_MODEL
|
|
281
|
+
* can land the classify on Grok/DeepSeek and the floor turns 16 into 4000.
|
|
282
|
+
*
|
|
283
|
+
* `body` may be the raw Buffer/string or a parsed object. An optional
|
|
284
|
+
* `bodyLen` overrides stringify length when the caller still has the wire
|
|
285
|
+
* bytes (the proxy does).
|
|
286
|
+
*/
|
|
287
|
+
export function isTinyClassify(body, bodyLen) {
|
|
288
|
+
let parsed = body;
|
|
289
|
+
let len = bodyLen;
|
|
290
|
+
if (body == null) return false;
|
|
291
|
+
if (typeof body === 'string' || Buffer.isBuffer(body)) {
|
|
292
|
+
const buf = Buffer.isBuffer(body) ? body : Buffer.from(body);
|
|
293
|
+
len = buf.length;
|
|
294
|
+
try { parsed = JSON.parse(buf.toString('utf8')); } catch { return false; }
|
|
295
|
+
} else if (len == null) {
|
|
296
|
+
try { len = Buffer.byteLength(JSON.stringify(body)); } catch { return false; }
|
|
297
|
+
}
|
|
298
|
+
const mt = Number(parsed?.max_tokens);
|
|
299
|
+
return len < BIND_MIN_CHARS && Number.isFinite(mt) && mt > 0 && mt <= CLASSIFY_MAX_TOKENS;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Fast non-reasoning id that is actually on the zoo. Prefer an explicit
|
|
304
|
+
* OPENZOO_CLASSIFIER_MODEL, then flash, then haiku, then the first catalog
|
|
305
|
+
* id that does not match the reasoning regex.
|
|
306
|
+
*/
|
|
307
|
+
export function pickClassifierModel(ids, preferred = process.env.OPENZOO_CLASSIFIER_MODEL) {
|
|
308
|
+
if (!Array.isArray(ids) || !ids.length) return null;
|
|
309
|
+
if (preferred && ids.includes(preferred)) return preferred;
|
|
310
|
+
for (const id of CLASSIFIER_PREFS) {
|
|
311
|
+
if (ids.includes(id)) return id;
|
|
312
|
+
}
|
|
313
|
+
return ids.find((id) => !REASONING_MODEL_RE.test(id)) || null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Raise max_tokens for a reasoning model. Returns { parsed, raised, from, to }.
|
|
318
|
+
* Does not itself decide whether a request is a classify — callers skip this
|
|
319
|
+
* when isTinyClassify is true. Real Grok/DeepSeek chats still need the floor:
|
|
320
|
+
* 4× a caller's 40 is 160, and those still come back blank.
|
|
321
|
+
*/
|
|
322
|
+
export function raiseReasoningMaxTokens(parsed, env = process.env) {
|
|
323
|
+
const mult = Number(env.OPENZOO_REASONING_MAX_TOKENS_X || 4);
|
|
324
|
+
const cap = Number(env.OPENZOO_REASONING_MAX_TOKENS_CAP || 32000);
|
|
325
|
+
const floor = Number(env.OPENZOO_REASONING_MIN_TOKENS || 4000);
|
|
326
|
+
const mdl = String(parsed?.model || '');
|
|
327
|
+
const mt = Number(parsed?.max_tokens);
|
|
328
|
+
if (mult > 1 && REASONING_MODEL_RE.test(mdl) && Number.isFinite(mt) && mt > 0 && mt < cap) {
|
|
329
|
+
const raised = Math.min(cap, Math.max(floor, Math.round(mt * mult)));
|
|
330
|
+
if (raised > mt) {
|
|
331
|
+
return { parsed: { ...parsed, max_tokens: raised }, raised: true, from: mt, to: raised };
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return { parsed, raised: false, from: mt, to: mt };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Model + max_tokens policy for one chat body.
|
|
339
|
+
*
|
|
340
|
+
* Tiny classify: pin to a fast non-reasoning catalog id, leave max_tokens
|
|
341
|
+
* alone, ignore OPENZOO_DEFAULT_MODEL. Everything else: resolveModel (which
|
|
342
|
+
* honours the default) then the reasoning floor.
|
|
343
|
+
*/
|
|
344
|
+
export function rewriteChatModel(parsed, ids, { bodyLen } = {}) {
|
|
345
|
+
const from = parsed?.model;
|
|
346
|
+
const len = bodyLen ?? (parsed == null ? 0 : Buffer.byteLength(JSON.stringify(parsed)));
|
|
347
|
+
if (isTinyClassify(parsed, len)) {
|
|
348
|
+
const to = (typeof from === 'string' && pickClassifierModel(ids)) || from;
|
|
349
|
+
return {
|
|
350
|
+
parsed: (to && to !== from) ? { ...parsed, model: to } : parsed,
|
|
351
|
+
tiny: true,
|
|
352
|
+
from,
|
|
353
|
+
to,
|
|
354
|
+
raised: false,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
if (typeof from !== 'string') {
|
|
358
|
+
return { parsed, tiny: false, from, to: from, raised: false };
|
|
359
|
+
}
|
|
360
|
+
const resolved = resolveModel(from, ids);
|
|
361
|
+
const next = resolved ? { ...parsed, model: resolved } : parsed;
|
|
362
|
+
const bump = raiseReasoningMaxTokens(next);
|
|
363
|
+
return {
|
|
364
|
+
parsed: bump.parsed,
|
|
365
|
+
tiny: false,
|
|
366
|
+
from,
|
|
367
|
+
to: next.model,
|
|
368
|
+
raised: bump.raised,
|
|
369
|
+
raisedFrom: bump.from,
|
|
370
|
+
raisedTo: bump.to,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
260
374
|
/**
|
|
261
375
|
* Rewrite the model field of any request body that has one.
|
|
262
|
-
* Returns null (send as-is) or { body, from, to }. Any
|
|
263
|
-
* unreachable catalog — returns null: this layer must
|
|
264
|
-
* that would have worked without it.
|
|
376
|
+
* Returns null (send as-is) or { body, from, to, tiny?, raised? }. Any
|
|
377
|
+
* failure — bad JSON, unreachable catalog — returns null: this layer must
|
|
378
|
+
* never break a call that would have worked without it.
|
|
379
|
+
*
|
|
380
|
+
* Tiny classify is pinned here too, so OPENZOO_DEFAULT_MODEL cannot capture
|
|
381
|
+
* a 16-token yes/no even if a caller only goes through this helper.
|
|
265
382
|
*/
|
|
266
383
|
export async function maybeRewriteModel(bodyBuf) {
|
|
267
384
|
let body;
|
|
@@ -269,7 +386,15 @@ export async function maybeRewriteModel(bodyBuf) {
|
|
|
269
386
|
if (typeof body?.model !== 'string') return null;
|
|
270
387
|
let ids;
|
|
271
388
|
try { ids = await zooModelIds(); } catch { return null; }
|
|
272
|
-
const
|
|
273
|
-
if (!to) return null;
|
|
274
|
-
return {
|
|
389
|
+
const policy = rewriteChatModel(body, ids, { bodyLen: bodyBuf.length });
|
|
390
|
+
if (!policy.tiny && !policy.raised && policy.to === body.model) return null;
|
|
391
|
+
return {
|
|
392
|
+
body: Buffer.from(JSON.stringify(policy.parsed)),
|
|
393
|
+
from: body.model,
|
|
394
|
+
to: policy.parsed.model,
|
|
395
|
+
tiny: policy.tiny,
|
|
396
|
+
raised: policy.raised,
|
|
397
|
+
raisedFrom: policy.raisedFrom,
|
|
398
|
+
raisedTo: policy.raisedTo,
|
|
399
|
+
};
|
|
275
400
|
}
|
package/lib/proxy.js
CHANGED
|
@@ -14,9 +14,9 @@ import { evmTokenBalance } from './evm.js';
|
|
|
14
14
|
import { bindCorpus, contextCacheDisabled, BIND_MIN_CHARS } from './hrr.js';
|
|
15
15
|
import {
|
|
16
16
|
loadBoundChars, noteCorpusLedger, filesForCorpus, readFilesForCorpus, boundAbsFromKeys,
|
|
17
|
-
|
|
17
|
+
createSpillStats, corpusCharsForSend, applySpillCut, msgText,
|
|
18
18
|
} from './spill.js';
|
|
19
|
-
import {
|
|
19
|
+
import { rewritablePath, augmentModelList, ALIAS_IDS, rewriteChatModel, zooModelIds } from './models.js';
|
|
20
20
|
import { forgetContext } from './contexts.js';
|
|
21
21
|
import { injectBrief } from './brief.js';
|
|
22
22
|
import { withNamespace } from './namespace.js';
|
|
@@ -331,26 +331,6 @@ function replayPut(key, data, settle) {
|
|
|
331
331
|
* to sending the original body untouched — caching must never break a call.
|
|
332
332
|
* Returns null (send as-is) or { body, contextId, hash, corpus, reused, savedBytes }.
|
|
333
333
|
*/
|
|
334
|
-
/** Flatten one Anthropic content block to text leCore can index. */
|
|
335
|
-
function blockText(b) {
|
|
336
|
-
if (typeof b === 'string') return b;
|
|
337
|
-
if (!b || typeof b !== 'object') return '';
|
|
338
|
-
if (b.type === 'text') return b.text || '';
|
|
339
|
-
if (b.type === 'tool_use') return `[tool_use ${b.name}] ${JSON.stringify(b.input ?? {})}`;
|
|
340
|
-
if (b.type === 'tool_result') {
|
|
341
|
-
const c = b.content;
|
|
342
|
-
return `[tool_result] ${typeof c === 'string' ? c : (Array.isArray(c) ? c.map(blockText).join('\n') : JSON.stringify(c ?? ''))}`;
|
|
343
|
-
}
|
|
344
|
-
if (b.type === 'thinking') return ''; // never bind reasoning traces
|
|
345
|
-
return '';
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
function msgText(m) {
|
|
349
|
-
const c = m?.content;
|
|
350
|
-
const body = typeof c === 'string' ? c : (Array.isArray(c) ? c.map(blockText).filter(Boolean).join('\n') : '');
|
|
351
|
-
return body ? `${(m.role || '?').toUpperCase()}: ${body}` : '';
|
|
352
|
-
}
|
|
353
|
-
|
|
354
334
|
/**
|
|
355
335
|
* Spill the OLD prefix of a long TRANSCRIPT into leCore.
|
|
356
336
|
*
|
|
@@ -441,155 +421,29 @@ async function spillTranscript(body, log, req, stats) {
|
|
|
441
421
|
return null;
|
|
442
422
|
}
|
|
443
423
|
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
// so
|
|
447
|
-
//
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
// had written user turns into it.
|
|
463
|
-
//
|
|
464
|
-
// What actually matters is that no assistant `tool_calls` is left unanswered
|
|
465
|
-
// across the cut. So any message is a legal boundary when the one BEFORE it
|
|
466
|
-
// is not an unanswered tool call — i.e. the previous message is a plain
|
|
467
|
-
// assistant/user/tool with every call already resolved.
|
|
468
|
-
const severable = (i) => {
|
|
469
|
-
if (i <= firstSpillable || i >= msgs.length) return false;
|
|
470
|
-
const prev = msgs[i - 1];
|
|
471
|
-
if (!prev) return false;
|
|
472
|
-
// an assistant that issued tool_calls must be followed by its tool results
|
|
473
|
-
if (prev.role === 'assistant' && Array.isArray(prev.tool_calls) && prev.tool_calls.length) return false;
|
|
474
|
-
// never split a tool_call/tool_result run in the middle
|
|
475
|
-
return msgs[i].role !== 'tool';
|
|
476
|
-
};
|
|
477
|
-
let cut = -1;
|
|
478
|
-
for (let i = msgs.length - keepTail; i > firstSpillable; i--) {
|
|
479
|
-
if (severable(i)) { cut = i; break; }
|
|
480
|
-
}
|
|
481
|
-
// FALL BACK TO THE LAST SEVERABLE TURN. The keepTail window is a preference,
|
|
482
|
-
// not a requirement: a transcript can be enormous and still have very few
|
|
483
|
-
// user turns (one huge document, then tool traffic), and on those the window
|
|
484
|
-
// contained no `user` message at all — so nothing spilled and the whole body
|
|
485
|
-
// went upstream while the counter honestly reported 0. Keep at least the
|
|
486
|
-
// final turn; anything earlier that is severable is better than not spilling.
|
|
487
|
-
if (cut <= firstSpillable) {
|
|
488
|
-
for (let i = msgs.length - 2; i > firstSpillable; i--) {
|
|
489
|
-
if (severable(i)) { cut = i; break; }
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
if (cut <= firstSpillable) {
|
|
424
|
+
// LIVE SELF-TUNER. Env knobs seed the first cut; after cut+stub the proxy
|
|
425
|
+
// measures corpus/sent and retunes keep/min-turns/budget (and stubs more)
|
|
426
|
+
// in process memory so the next request starts from the last good setting.
|
|
427
|
+
// No restart. OPENZOO_ADAPT=0 freezes the env defaults. The ask always
|
|
428
|
+
// stays; we never drop below 2 real user/assistant turns to delete it.
|
|
429
|
+
const knownLedger = (sessionKey && spillMemo.get(sessionKey))
|
|
430
|
+
|| (sessionKey && sessionLedger.get(sessionKey))
|
|
431
|
+
|| null;
|
|
432
|
+
const knownChars = knownLedger?.contextId
|
|
433
|
+
? (boundChars.get(knownLedger.contextId) || 0)
|
|
434
|
+
: 0;
|
|
435
|
+
const adapted = applySpillCut(msgs, {
|
|
436
|
+
corpusChars: knownChars,
|
|
437
|
+
boundAbs: previouslyBoundAbs,
|
|
438
|
+
log,
|
|
439
|
+
persist: true,
|
|
440
|
+
});
|
|
441
|
+
if (adapted.cut <= adapted.firstSpillable) {
|
|
493
442
|
bindFilesInBackground('no severable cut, files only');
|
|
494
|
-
return null;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
// TRIM THE TAIL BY BYTES, NOT MESSAGE COUNT.
|
|
498
|
-
//
|
|
499
|
-
// MEASURED on the live session: 9 kept turns of Claude Code tool output made
|
|
500
|
-
// promptTokens swamp the counterfactual and the call scored 1.00x, while the
|
|
501
|
-
// SAME bound context with a one-line ask scored 8.53x. Nine messages is a
|
|
502
|
-
// trivial number and an enormous payload — a single Read or grep result is
|
|
503
|
-
// tens of KB — so counting messages measures the wrong thing entirely.
|
|
504
|
-
//
|
|
505
|
-
// Walk backwards from the newest and stop at a byte budget. The newest turns
|
|
506
|
-
// are the ones the model actually needs verbatim; everything older is already
|
|
507
|
-
// in the bound corpus and comes back through recall.
|
|
508
|
-
let tailStart = cut;
|
|
509
|
-
{
|
|
510
|
-
// 24000 was still too fat: MEASURED on 9 live spilled calls, direct came
|
|
511
|
-
// back identical to billed to the cent, i.e. the gateway never computed a
|
|
512
|
-
// counterfactual because promptTokens >= corpusTokens. The tail has to be
|
|
513
|
-
// small enough that the BOUND corpus is the bigger number.
|
|
514
|
-
const budget = Number(process.env.OPENZOO_TAIL_MAX_CHARS || 6000);
|
|
515
|
-
let used = 0;
|
|
516
|
-
for (let i = msgs.length - 1; i >= cut; i--) {
|
|
517
|
-
used += msgText(msgs[i]).length;
|
|
518
|
-
if (used > budget && severable(i)) { tailStart = i; break; }
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
if (tailStart > cut) cut = tailStart;
|
|
522
|
-
|
|
523
|
-
// COHERENCE IS COUNTED IN TURNS, NOT BYTES.
|
|
524
|
-
//
|
|
525
|
-
// The byte budget alone produced windows of 2, 5 and 34 turns out of ~600 —
|
|
526
|
-
// and one fat tool result is enough to spend the whole 6,000 chars, so a busy
|
|
527
|
-
// agent turn collapses the window to almost nothing. OBSERVED live: an agent
|
|
528
|
-
// that had just run a command reported "I don't have the preceding turns of
|
|
529
|
-
// this conversation in view" and re-derived its own state from files, every
|
|
530
|
-
// turn. Retrieval brings back what is RELEVANT to the ask; it does not
|
|
531
|
-
// reliably bring back "what I just did", because the model does not know to
|
|
532
|
-
// query for it.
|
|
533
|
-
//
|
|
534
|
-
// So floor the window at a number of turns regardless of size. This costs
|
|
535
|
-
// saving — a bigger tail is a bigger `sent` — and that is the correct trade:
|
|
536
|
-
// measured 8.13x on the fleet leaves room to spend some of it on an agent
|
|
537
|
-
// that remembers its own last few moves.
|
|
538
|
-
// 12 was set while the model was ALSO missing its own last turn to a brief
|
|
539
|
-
// spliced in behind it (fixed in 0.48.56), so part of that floor was paying
|
|
540
|
-
// for a bug rather than for coherence. 6 turns still covers "what did I just
|
|
541
|
-
// do" — the case retrieval cannot answer, because the model does not know to
|
|
542
|
-
// query for it — and hands the rest back as saving.
|
|
543
|
-
// COUNT CONVERSATION TURNS, NOT MESSAGES.
|
|
544
|
-
//
|
|
545
|
-
// A tool round trip is TWO messages (assistant tool_call + tool result), so a
|
|
546
|
-
// floor of 6 messages is three tool calls and nothing else — the agent loses
|
|
547
|
-
// the human turn that started the run and every decision it made along the
|
|
548
|
-
// way. OBSERVED: "it's when he calls a bunch of tools, he gets lost."
|
|
549
|
-
//
|
|
550
|
-
// So the floor is measured in user/assistant turns and tool traffic rides
|
|
551
|
-
// along for free. A tool-heavy stretch therefore widens the window instead of
|
|
552
|
-
// consuming it, which is the opposite of the old behaviour and the whole
|
|
553
|
-
// point: what the agent needs verbatim is what it DID, and doing things is
|
|
554
|
-
// exactly what fills the window with tool messages.
|
|
555
|
-
const minTurns = Number(process.env.OPENZOO_TAIL_MIN_TURNS || 6);
|
|
556
|
-
const realTurns = (from) => {
|
|
557
|
-
let n = 0;
|
|
558
|
-
for (let i = from; i < msgs.length; i++) {
|
|
559
|
-
const r = msgs[i]?.role;
|
|
560
|
-
if (r === 'user' || r === 'assistant') n += 1;
|
|
561
|
-
}
|
|
562
|
-
return n;
|
|
563
|
-
};
|
|
564
|
-
if (realTurns(cut) < minTurns) {
|
|
565
|
-
for (let i = cut - 1; i > firstSpillable; i--) {
|
|
566
|
-
if (severable(i) && realTurns(i) >= minTurns) { cut = i; break; }
|
|
567
|
-
if (i === firstSpillable + 1) { if (severable(i)) cut = i; break; }
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
// NEVER SPILL THE CURRENT ASK.
|
|
572
|
-
//
|
|
573
|
-
// The tail budget walks BACKWARD accumulating bytes, and in an agent loop the
|
|
574
|
-
// last few messages are tool results — file reads, greps, build output. Those
|
|
575
|
-
// alone blow through 6,000 chars, so the cut lands AFTER the user's actual
|
|
576
|
-
// instruction and the instruction goes into the bound corpus instead of the
|
|
577
|
-
// forwarded window. It then only comes back if top-k recall happens to surface
|
|
578
|
-
// it against its own text, which is exactly the query it is least likely to
|
|
579
|
-
// match.
|
|
580
|
-
//
|
|
581
|
-
// OBSERVED: "sending 2/578 turns", and the model replying "I don't have a
|
|
582
|
-
// specific request to act on — your message came through empty", then
|
|
583
|
-
// re-reading the plan doc to work out where it was, every single turn. A loop
|
|
584
|
-
// that looks like amnesia and is actually us deleting the question.
|
|
585
|
-
//
|
|
586
|
-
// Retrieval is for CONTEXT. The ask itself is never context, and must survive
|
|
587
|
-
// any budget.
|
|
588
|
-
let lastUser = -1;
|
|
589
|
-
for (let i = msgs.length - 1; i > firstSpillable; i--) {
|
|
590
|
-
if (msgs[i].role === 'user' && msgText(msgs[i]).trim()) { lastUser = i; break; }
|
|
443
|
+
return null;
|
|
591
444
|
}
|
|
592
|
-
|
|
445
|
+
const { cut, firstSpillable } = adapted;
|
|
446
|
+
const stubbed = adapted.stubbed;
|
|
593
447
|
|
|
594
448
|
const head = msgs.slice(0, firstSpillable); // system block, always kept
|
|
595
449
|
// EVERY FILE THE AGENT TOUCHED, AT FULL SIZE.
|
|
@@ -759,18 +613,9 @@ async function spillTranscript(body, log, req, stats) {
|
|
|
759
613
|
? `transcript prefix already bound (${bind.contextId}, ${keyKind}) — sending ${sent}/${msgs.length} turns`
|
|
760
614
|
: `transcript prefix bound (${mb(bind.bytes)}MB → ${bind.contextId}, ${keyKind}) — sending ${sent}/${msgs.length} turns`);
|
|
761
615
|
|
|
762
|
-
//
|
|
763
|
-
//
|
|
764
|
-
//
|
|
765
|
-
// still forwards the last ~13 turns, which are Read/Bash tool_results of
|
|
766
|
-
// those same files. Sent ~= corpus, so counterfactualTokens > promptTokens
|
|
767
|
-
// barely fires and dollars stay ~1.2x with 5MB already bound. After a file
|
|
768
|
-
// is bound, drop its bytes from the forwarded tail (path + marker only).
|
|
769
|
-
// First-read results and non-file tool output stay verbatim. No disk I/O.
|
|
770
|
-
const stubbed = stubBoundFileResults(msgs, {
|
|
771
|
-
boundAbs: previouslyBoundAbs,
|
|
772
|
-
fromIndex: cut,
|
|
773
|
-
});
|
|
616
|
+
// Bound file bodies in the forwarded tail were stubbed inside applySpillCut
|
|
617
|
+
// (and maybe stubbed more if the tuner was below 10x). The adapt line is
|
|
618
|
+
// already logged there.
|
|
774
619
|
if (stubbed.dropped) {
|
|
775
620
|
log(`file-stub stubbed=${stubbed.stubbed} dropped=${stubbed.dropped}`);
|
|
776
621
|
}
|
|
@@ -904,6 +749,9 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
904
749
|
mkdirSync(path.dirname(sayFile), { recursive: true });
|
|
905
750
|
} catch { sayFile = null; }
|
|
906
751
|
}
|
|
752
|
+
// Spill/adapt/classifier lines must use this channel: `log` is a no-op when
|
|
753
|
+
// `openzoo claude` starts us with silent:true, and printing them on stdout
|
|
754
|
+
// corrupts the Claude Code TTY. say() writes ~/.openzoo/proxy.log then.
|
|
907
755
|
const say = (...a) => {
|
|
908
756
|
const line = a.join(' ');
|
|
909
757
|
if (sayFile) { try { appendFileSync(sayFile, line + '\n'); return; } catch { /* fall through */ } }
|
|
@@ -1321,62 +1169,77 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1321
1169
|
}
|
|
1322
1170
|
}
|
|
1323
1171
|
if (rewritablePath(req.method, req.url)) {
|
|
1324
|
-
const rw = await maybeRewriteModel(bodyBuf);
|
|
1325
|
-
if (rw) {
|
|
1326
|
-
// SAY WHETHER THE OVERRIDE IS ACTUALLY SET, and name it.
|
|
1327
|
-
//
|
|
1328
|
-
// This used to print "(OPENZOO_DEFAULT_MODEL overrides)" on EVERY
|
|
1329
|
-
// rewrite whether or not the variable existed — a hint about a knob,
|
|
1330
|
-
// phrased as a statement about this request. It cost a real incident:
|
|
1331
|
-
// the proxy was restarted from a shell carrying
|
|
1332
|
-
// OPENZOO_DEFAULT_MODEL=deepseek/deepseek-v4-pro-0813, so every
|
|
1333
|
-
// claude-sonnet-5 ask was served by deepseek, and the log line looked
|
|
1334
|
-
// exactly the same as it always had. deepseek matches the reasoning
|
|
1335
|
-
// regex, so a 16-token safety classification became a 4,000-token
|
|
1336
|
-
// reasoning generation — 11.5s, past the caller's timeout, and Claude
|
|
1337
|
-
// Code reported "claude-sonnet-5 is temporarily unavailable".
|
|
1338
|
-
const forced = process.env.OPENZOO_DEFAULT_MODEL;
|
|
1339
|
-
log(forced
|
|
1340
|
-
? `model "${rw.from}" -> FORCED to ${forced} by OPENZOO_DEFAULT_MODEL (nearest match would have been ${rw.to})`
|
|
1341
|
-
: `model "${rw.from}" is not on the zoo — nearest match ${rw.to}`);
|
|
1342
|
-
bodyBuf = rw.body;
|
|
1343
|
-
}
|
|
1344
1172
|
try {
|
|
1345
1173
|
let parsed = JSON.parse(bodyBuf.toString('utf8'));
|
|
1346
1174
|
wantsStream = parsed?.stream === true || clientWantsStream;
|
|
1347
|
-
//
|
|
1175
|
+
// TINY CLASSIFY FIRST, ON THE ORIGINAL BODY.
|
|
1348
1176
|
//
|
|
1349
|
-
//
|
|
1350
|
-
//
|
|
1351
|
-
//
|
|
1352
|
-
//
|
|
1353
|
-
//
|
|
1354
|
-
//
|
|
1355
|
-
//
|
|
1356
|
-
//
|
|
1357
|
-
//
|
|
1358
|
-
//
|
|
1359
|
-
//
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1177
|
+
// Claude Code auto-mode sends a 16-token yes/no to claude-sonnet-5
|
|
1178
|
+
// before Bash/WebSearch. Two things used to eat that call:
|
|
1179
|
+
// 1. OPENZOO_DEFAULT_MODEL rewrote it onto deepseek/grok;
|
|
1180
|
+
// 2. the reasoning floor then raised 16 -> 4000.
|
|
1181
|
+
// Measured: 11.5s, past the caller's timeout, "claude-sonnet-5 is
|
|
1182
|
+
// temporarily unavailable (timed out), so auto mode cannot determine
|
|
1183
|
+
// the safety of WebSearch". Even staying on sonnet-5 is too slow
|
|
1184
|
+
// (402 handshake behind a long Grok stream). Pin to a fast
|
|
1185
|
+
// non-reasoning catalog id and leave max_tokens at 16. Real
|
|
1186
|
+
// Grok/DeepSeek chats (max_tokens > 64 or a body over BIND_MIN)
|
|
1187
|
+
// still get the 4000 floor � those still go blank without it.
|
|
1188
|
+
let ids = [];
|
|
1189
|
+
try { ids = await zooModelIds(); } catch { /* catalog miss: still skip the floor on a tiny classify */ }
|
|
1190
|
+
const policy = rewriteChatModel(parsed, ids, { bodyLen: bodyBuf.length });
|
|
1191
|
+
parsed = policy.parsed;
|
|
1192
|
+
if (policy.tiny) {
|
|
1193
|
+
// `openzoo claude` starts us silent � `log` is a no-op then. say()
|
|
1194
|
+
// is the proxy.log channel and never the Claude Code TTY.
|
|
1195
|
+
say(`classifier tiny max_tokens=${Number(parsed?.max_tokens)} "${policy.from}" -> ${policy.to} (no reasoning floor)`);
|
|
1196
|
+
} else {
|
|
1197
|
+
// SAY WHETHER THE OVERRIDE IS ACTUALLY SET, and name it.
|
|
1198
|
+
//
|
|
1199
|
+
// This used to print "(OPENZOO_DEFAULT_MODEL overrides)" on EVERY
|
|
1200
|
+
// rewrite whether or not the variable existed � a hint about a knob,
|
|
1201
|
+
// phrased as a statement about this request. It cost a real incident:
|
|
1202
|
+
// the proxy was restarted from a shell carrying
|
|
1203
|
+
// OPENZOO_DEFAULT_MODEL=deepseek/deepseek-v4-pro-0813, so every
|
|
1204
|
+
// claude-sonnet-5 ask was served by deepseek, and the log line looked
|
|
1205
|
+
// exactly the same as it always had. deepseek matches the reasoning
|
|
1206
|
+
// regex, so a 16-token safety classification became a 4,000-token
|
|
1207
|
+
// reasoning generation � 11.5s, past the caller's timeout, and Claude
|
|
1208
|
+
// Code reported "claude-sonnet-5 is temporarily unavailable".
|
|
1209
|
+
// Tiny classify is pinned above and never reaches this path.
|
|
1210
|
+
if (policy.to && policy.to !== policy.from) {
|
|
1211
|
+
const forced = process.env.OPENZOO_DEFAULT_MODEL;
|
|
1212
|
+
log(forced
|
|
1213
|
+
? `model "${policy.from}" -> FORCED to ${forced} by OPENZOO_DEFAULT_MODEL (nearest match would have been ${policy.to})`
|
|
1214
|
+
: `model "${policy.from}" is not on the zoo � nearest match ${policy.to}`);
|
|
1379
1215
|
}
|
|
1216
|
+
// REASONING MODELS SPEND max_tokens ON THINKING FIRST.
|
|
1217
|
+
//
|
|
1218
|
+
// The budget covers hidden reasoning AND the visible answer, so a
|
|
1219
|
+
// caller that asks for 40 tokens because it wants a short answer often
|
|
1220
|
+
// gets ZERO � the whole allowance went to reasoning and the completion
|
|
1221
|
+
// truncated to an empty string. Measured across three families in one
|
|
1222
|
+
// day: deepseek returned 0 chars at 8k and was fine at 24k; grok-4.6
|
|
1223
|
+
// pinned ct at exactly its 16,000 budget with no visible output;
|
|
1224
|
+
// sonnet-5 truncated a 600-token file mid-function because Anthropic's
|
|
1225
|
+
// max_tokens covers thinking too.
|
|
1226
|
+
//
|
|
1227
|
+
// An empty completion is not an error � it bills normally and renders
|
|
1228
|
+
// as a blank reply � so this fails silently and looks like the retrieval
|
|
1229
|
+
// broke. It cost real debugging time tonight for exactly that reason.
|
|
1230
|
+
// Multiply the allowance for known reasoning families and let callers
|
|
1231
|
+
// keep asking for what they actually want back.
|
|
1232
|
+
//
|
|
1233
|
+
// A MULTIPLIER ALONE IS NOT ENOUGH. 4x on a caller's 40 is 160, which is
|
|
1234
|
+
// still nothing for a model that thinks first � measured, 2 of 3 runs
|
|
1235
|
+
// still returned empty at 160. Reasoning needs an absolute floor, not a
|
|
1236
|
+
// relative bump, so take whichever is larger.
|
|
1237
|
+
if (policy.raised) {
|
|
1238
|
+
log(`reasoning model ${parsed.model}: max_tokens ${policy.raisedFrom} -> ${policy.raisedTo} (thinking shares the budget; OPENZOO_REASONING_MAX_TOKENS_X=1 disables)`);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
if (policy.tiny || policy.raised || (policy.to && policy.to !== policy.from)) {
|
|
1242
|
+
bodyBuf = Buffer.from(JSON.stringify(parsed));
|
|
1380
1243
|
}
|
|
1381
1244
|
// Tell the agent what it is actually connected to — in band, where it
|
|
1382
1245
|
// will read it, instead of leaving it to guess (and to chunk corpora
|
|
@@ -1398,10 +1261,12 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1398
1261
|
// advice nobody will read.
|
|
1399
1262
|
//
|
|
1400
1263
|
// Threshold is the same one the spill uses: below it there is no
|
|
1401
|
-
// corpus and nothing the brief could help with.
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1264
|
+
// corpus and nothing the brief could help with. policy.tiny is
|
|
1265
|
+
// that check on the ORIGINAL body, before the reasoning floor.
|
|
1266
|
+
const tiny = policy.tiny
|
|
1267
|
+
|| (bodyBuf.length < BIND_MIN_CHARS
|
|
1268
|
+
&& Number(parsed?.max_tokens ?? 0) > 0
|
|
1269
|
+
&& Number(parsed?.max_tokens) <= 64);
|
|
1405
1270
|
const briefed = tiny ? null : injectBrief(parsed, selfUrl);
|
|
1406
1271
|
if (briefed) parsed = briefed;
|
|
1407
1272
|
// SYSTEM MESSAGES BELONG AT THE FRONT, OR GOOGLE 400s.
|
|
@@ -1512,7 +1377,9 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1512
1377
|
try {
|
|
1513
1378
|
let cached = null;
|
|
1514
1379
|
try {
|
|
1515
|
-
|
|
1380
|
+
// Spill/adapt diagnostics (adapt, file-stub, sending N/M) must hit
|
|
1381
|
+
// ~/.openzoo/proxy.log when we are silent. `log` is a no-op then.
|
|
1382
|
+
cached = await maybeCacheCorpus(req, bodyBuf, say, spill);
|
|
1516
1383
|
} catch (err) {
|
|
1517
1384
|
log(`context cache skipped for this call: ${err.message}`);
|
|
1518
1385
|
}
|
package/lib/spill.js
CHANGED
|
@@ -492,9 +492,26 @@ export function looksLikeFileView(command) {
|
|
|
492
492
|
return sawView;
|
|
493
493
|
}
|
|
494
494
|
|
|
495
|
-
|
|
495
|
+
/** Tool result larger than this is "fat" — stub it in the forwarded tail. */
|
|
496
|
+
export const FAT_TOOL_CHARS = 400;
|
|
497
|
+
|
|
498
|
+
export function fileBoundStub(paths, n) {
|
|
499
|
+
const list = [...new Set((paths || []).filter(Boolean))].join(' ');
|
|
500
|
+
const mark = Number.isFinite(n) && n > 0 ? `[bound, ${n} chars]` : '[bound]';
|
|
501
|
+
return list ? `FILE ${list} ${mark}` : `FILE ${mark}`;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Generic stub for WebSearch / Fetch / Bash / any fat tool_result. */
|
|
505
|
+
export function toolResultStub(n, paths) {
|
|
496
506
|
const list = [...new Set((paths || []).filter(Boolean))].join(' ');
|
|
497
|
-
|
|
507
|
+
const mark = `[bound, ${Number(n) || 0} chars]`;
|
|
508
|
+
return list ? `FILE ${list} ${mark}` : mark;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function isStubText(content) {
|
|
512
|
+
if (typeof content === 'string') return /\[bound(?:, \d+ chars)?\]/.test(content);
|
|
513
|
+
if (Array.isArray(content)) return content.some((b) => isStubText(typeof b === 'string' ? b : b?.text ?? b?.content));
|
|
514
|
+
return false;
|
|
498
515
|
}
|
|
499
516
|
|
|
500
517
|
function toolContentLength(content) {
|
|
@@ -524,19 +541,33 @@ function resolveBoundPath(raw, cwd, boundAbs) {
|
|
|
524
541
|
* (live: 5MB filebind, lastSend 13/107, savingX 1.22 instead of ~7x).
|
|
525
542
|
*
|
|
526
543
|
* Cheap rewrite — no disk I/O. First-read results (not yet in boundAbs) and
|
|
527
|
-
* non-file tool output
|
|
544
|
+
* non-file tool output stay verbatim UNLESS `aggressive` / `stubMore` or a
|
|
545
|
+
* tail `budget` is set. Those two are what beat a WebSearch/Fetch/Bash
|
|
546
|
+
* storm: the 800-byte floor cannot move `cut` inside a tool chain
|
|
547
|
+
* (`isSeverable` is false), so the byte budget has to win by stubbing
|
|
548
|
+
* bodies instead of deleting the tool_use / tool_result pairing.
|
|
528
549
|
*
|
|
529
550
|
* `fromIndex` limits the rewrite to the forwarded tail so the spilled prefix
|
|
530
|
-
* that becomes the conversation corpus is unchanged.
|
|
551
|
+
* that becomes the conversation corpus is unchanged. The last user ask is
|
|
552
|
+
* never rewritten.
|
|
531
553
|
*/
|
|
532
554
|
export function stubBoundFileResults(msgs, {
|
|
533
555
|
boundFiles,
|
|
534
556
|
boundAbs,
|
|
535
557
|
cwd = process.cwd(),
|
|
536
558
|
fromIndex = 0,
|
|
559
|
+
// When the live tuner is below target, stub file-view results even if
|
|
560
|
+
// this turn has not yet recorded them in boundAbs (first-read bodies).
|
|
561
|
+
// Also stubs fat non-file tools (WebSearch / Fetch / Bash).
|
|
562
|
+
aggressive = false,
|
|
563
|
+
// When the forwarded tail is over this many chars, stub older tool_result
|
|
564
|
+
// bodies (oldest first) until it fits. Pairing stays; the ask stays.
|
|
565
|
+
budget = null,
|
|
566
|
+
fatChars = FAT_TOOL_CHARS,
|
|
537
567
|
} = {}) {
|
|
538
568
|
const absSet = boundAbs || boundAbsFromKeys(boundFiles);
|
|
539
|
-
|
|
569
|
+
const wantBudget = budget != null && Number.isFinite(Number(budget));
|
|
570
|
+
if (!Array.isArray(msgs) || (!absSet.size && !aggressive && !wantBudget)) {
|
|
540
571
|
return { messages: msgs, stubbed: 0, dropped: 0 };
|
|
541
572
|
}
|
|
542
573
|
|
|
@@ -556,6 +587,7 @@ export function stubBoundFileResults(msgs, {
|
|
|
556
587
|
for (const raw of raws) {
|
|
557
588
|
const hit = resolveBoundPath(raw, currentCwd, absSet);
|
|
558
589
|
if (hit) resolved.push(hit);
|
|
590
|
+
else if (aggressive && raw) resolved.push(resolveReadablePath(raw, currentCwd) || raw);
|
|
559
591
|
}
|
|
560
592
|
if (!resolved.length || !id) return;
|
|
561
593
|
stubIds.add(id);
|
|
@@ -581,35 +613,557 @@ export function stubBoundFileResults(msgs, {
|
|
|
581
613
|
}
|
|
582
614
|
}
|
|
583
615
|
|
|
584
|
-
|
|
616
|
+
const lastUser = lastUserAskIndex(msgs, firstSpillableIndex(msgs));
|
|
617
|
+
const fatLimit = Number.isFinite(Number(fatChars)) ? Number(fatChars) : FAT_TOOL_CHARS;
|
|
618
|
+
|
|
619
|
+
const stubFor = (id, n) => {
|
|
620
|
+
const paths = idPaths.get(id);
|
|
621
|
+
return paths?.length ? fileBoundStub(paths, n) : toolResultStub(n);
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
const shouldStubBody = (id, n) => {
|
|
625
|
+
if (!n || isStubText(typeof n === 'number' ? '' : n)) return false;
|
|
626
|
+
if (stubIds.has(id)) return true;
|
|
627
|
+
if (aggressive && n >= fatLimit) return true;
|
|
628
|
+
return false;
|
|
629
|
+
};
|
|
585
630
|
|
|
586
631
|
let stubbed = 0;
|
|
587
632
|
let dropped = 0;
|
|
588
|
-
|
|
589
|
-
if (i < fromIndex || !m) return m;
|
|
590
|
-
if (m.role === 'tool'
|
|
633
|
+
let messages = msgs.map((m, i) => {
|
|
634
|
+
if (i < fromIndex || !m || i === lastUser) return m;
|
|
635
|
+
if (m.role === 'tool') {
|
|
591
636
|
const n = toolContentLength(m.content);
|
|
592
|
-
if (!n) return m;
|
|
637
|
+
if (!shouldStubBody(m.tool_call_id, n) || isStubText(m.content)) return m;
|
|
593
638
|
dropped += n;
|
|
594
639
|
stubbed += 1;
|
|
595
|
-
return { ...m, content:
|
|
640
|
+
return { ...m, content: stubFor(m.tool_call_id, n) };
|
|
596
641
|
}
|
|
597
642
|
if (!Array.isArray(m.content)) return m;
|
|
598
643
|
let changed = false;
|
|
599
644
|
const blocks = m.content.map((b) => {
|
|
600
|
-
if (b?.type !== 'tool_result'
|
|
645
|
+
if (b?.type !== 'tool_result') return b;
|
|
601
646
|
const n = toolContentLength(b.content);
|
|
602
|
-
if (!n) return b;
|
|
647
|
+
if (!shouldStubBody(b.tool_use_id, n) || isStubText(b.content)) return b;
|
|
603
648
|
dropped += n;
|
|
604
649
|
stubbed += 1;
|
|
605
650
|
changed = true;
|
|
606
|
-
return { ...b, content:
|
|
651
|
+
return { ...b, content: stubFor(b.tool_use_id, n) };
|
|
607
652
|
});
|
|
608
653
|
return changed ? { ...m, content: blocks } : m;
|
|
609
654
|
});
|
|
655
|
+
|
|
656
|
+
// Byte budget wins inside a tool chain. cutTranscript cannot move tailStart
|
|
657
|
+
// past assistant(tool_calls) / role:tool (pairing 400s the provider), so a
|
|
658
|
+
// 15-result storm used to ride in full at the 800-byte floor. Stub older
|
|
659
|
+
// bodies first; never drop the ask or the assistant tool_calls themselves.
|
|
660
|
+
if (wantBudget) {
|
|
661
|
+
const cap = Number(budget);
|
|
662
|
+
let used = sliceChars(messages, fromIndex);
|
|
663
|
+
if (used > cap) {
|
|
664
|
+
const next = messages.slice();
|
|
665
|
+
for (let i = fromIndex; i < next.length && used > cap; i++) {
|
|
666
|
+
if (i === lastUser) continue;
|
|
667
|
+
const m = next[i];
|
|
668
|
+
if (!m) continue;
|
|
669
|
+
if (m.role === 'tool') {
|
|
670
|
+
if (isStubText(m.content)) continue;
|
|
671
|
+
const n = toolContentLength(m.content);
|
|
672
|
+
if (!n) continue;
|
|
673
|
+
const stub = stubFor(m.tool_call_id, n);
|
|
674
|
+
if (stub.length >= n) continue;
|
|
675
|
+
used = used - n + stub.length;
|
|
676
|
+
next[i] = { ...m, content: stub };
|
|
677
|
+
stubbed += 1;
|
|
678
|
+
dropped += n;
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
if (!Array.isArray(m.content)) continue;
|
|
682
|
+
let changed = false;
|
|
683
|
+
const blocks = m.content.map((b) => {
|
|
684
|
+
if (b?.type !== 'tool_result' || used <= cap || isStubText(b.content)) return b;
|
|
685
|
+
const n = toolContentLength(b.content);
|
|
686
|
+
if (!n) return b;
|
|
687
|
+
const stub = stubFor(b.tool_use_id, n);
|
|
688
|
+
if (stub.length >= n) return b;
|
|
689
|
+
used = used - n + stub.length;
|
|
690
|
+
stubbed += 1;
|
|
691
|
+
dropped += n;
|
|
692
|
+
changed = true;
|
|
693
|
+
return { ...b, content: stub };
|
|
694
|
+
});
|
|
695
|
+
if (changed) next[i] = { ...m, content: blocks };
|
|
696
|
+
}
|
|
697
|
+
messages = next;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
610
701
|
return { messages, stubbed, dropped };
|
|
611
702
|
}
|
|
612
703
|
|
|
704
|
+
export const ADAPT_TARGET = 10;
|
|
705
|
+
export const ADAPT_LOOSEN_AT = 20;
|
|
706
|
+
|
|
707
|
+
export const KNOB_DEFAULTS = Object.freeze({
|
|
708
|
+
keepTail: 8,
|
|
709
|
+
minTurns: 6,
|
|
710
|
+
budget: 6000,
|
|
711
|
+
stubMore: false,
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
const KEEP_STEPS = [2, 3, 4, 6, 8, 12, 16];
|
|
715
|
+
const TURNS_STEPS = [2, 3, 4, 6, 8, 12];
|
|
716
|
+
const BUDGET_STEPS = [800, 1500, 2500, 4000, 6000, 9000, 12000, 18000, 24000];
|
|
717
|
+
|
|
718
|
+
/** Flatten one Anthropic content block to text leCore can index. */
|
|
719
|
+
export function blockText(b) {
|
|
720
|
+
if (typeof b === 'string') return b;
|
|
721
|
+
if (!b || typeof b !== 'object') return '';
|
|
722
|
+
if (b.type === 'text') return b.text || '';
|
|
723
|
+
if (b.type === 'tool_use') return `[tool_use ${b.name}] ${JSON.stringify(b.input ?? {})}`;
|
|
724
|
+
if (b.type === 'tool_result') {
|
|
725
|
+
const c = b.content;
|
|
726
|
+
return `[tool_result] ${typeof c === 'string' ? c : (Array.isArray(c) ? c.map(blockText).join('\n') : JSON.stringify(c ?? ''))}`;
|
|
727
|
+
}
|
|
728
|
+
if (b.type === 'thinking') return '';
|
|
729
|
+
return '';
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
export function msgText(m) {
|
|
733
|
+
const c = m?.content;
|
|
734
|
+
const body = typeof c === 'string' ? c : (Array.isArray(c) ? c.map(blockText).filter(Boolean).join('\n') : '');
|
|
735
|
+
return body ? `${(m.role || '?').toUpperCase()}: ${body}` : '';
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** Characters that actually ride in a forwarded message (content + tool_calls). */
|
|
739
|
+
export function messageChars(m) {
|
|
740
|
+
if (!m) return 0;
|
|
741
|
+
let n = 0;
|
|
742
|
+
const c = m.content;
|
|
743
|
+
if (typeof c === 'string') n += c.length;
|
|
744
|
+
else if (Array.isArray(c)) {
|
|
745
|
+
for (const b of c) {
|
|
746
|
+
if (typeof b === 'string') n += b.length;
|
|
747
|
+
else n += String(b?.text ?? (typeof b?.content === 'string' ? b.content : '')).length;
|
|
748
|
+
}
|
|
749
|
+
} else if (c && typeof c === 'object') n += JSON.stringify(c).length;
|
|
750
|
+
if (Array.isArray(m.tool_calls)) {
|
|
751
|
+
for (const tc of m.tool_calls) {
|
|
752
|
+
const a = tc?.function?.arguments ?? tc?.arguments;
|
|
753
|
+
n += typeof a === 'string' ? a.length : (a ? JSON.stringify(a).length : 0);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
return n;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
export function sliceChars(msgs, from = 0, to = undefined) {
|
|
760
|
+
if (!Array.isArray(msgs)) return 0;
|
|
761
|
+
const end = to == null ? msgs.length : to;
|
|
762
|
+
let n = 0;
|
|
763
|
+
for (let i = from; i < end && i < msgs.length; i++) n += messageChars(msgs[i]);
|
|
764
|
+
return n;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
export function spillRatio(corpusChars, sentChars) {
|
|
768
|
+
const c = Number(corpusChars) || 0;
|
|
769
|
+
const s = Number(sentChars) || 0;
|
|
770
|
+
if (s <= 0) return c > 0 ? Infinity : 0;
|
|
771
|
+
return c / s;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function envNumber(key, fallback) {
|
|
775
|
+
const v = Number(process.env[key]);
|
|
776
|
+
return Number.isFinite(v) && v > 0 ? v : fallback;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
export function envKnobs() {
|
|
780
|
+
return {
|
|
781
|
+
keepTail: envNumber('OPENZOO_KEEP_TAIL_MSGS', KNOB_DEFAULTS.keepTail),
|
|
782
|
+
minTurns: envNumber('OPENZOO_TAIL_MIN_TURNS', KNOB_DEFAULTS.minTurns),
|
|
783
|
+
budget: envNumber('OPENZOO_TAIL_MAX_CHARS', KNOB_DEFAULTS.budget),
|
|
784
|
+
stubMore: process.env.OPENZOO_STUB_MORE === '1',
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
export function adaptEnabled() {
|
|
789
|
+
return process.env.OPENZOO_ADAPT !== '0';
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
export function knobsFile(home = os.homedir()) {
|
|
793
|
+
return process.env.OPENZOO_KNOBS_PATH
|
|
794
|
+
|| path.join(home, '.openzoo', 'knobs.json');
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function clampInt(n, lo, hi, fallback) {
|
|
798
|
+
const v = Number(n);
|
|
799
|
+
if (!Number.isFinite(v)) return fallback;
|
|
800
|
+
return Math.min(hi, Math.max(lo, Math.round(v)));
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
export function sanitizeKnobs(raw = {}) {
|
|
804
|
+
if (!raw || typeof raw !== 'object') return { ...KNOB_DEFAULTS };
|
|
805
|
+
return {
|
|
806
|
+
keepTail: clampInt(raw.keepTail, KEEP_STEPS[0], KEEP_STEPS[KEEP_STEPS.length - 1], KNOB_DEFAULTS.keepTail),
|
|
807
|
+
minTurns: clampInt(raw.minTurns, TURNS_STEPS[0], TURNS_STEPS[TURNS_STEPS.length - 1], KNOB_DEFAULTS.minTurns),
|
|
808
|
+
budget: clampInt(raw.budget, BUDGET_STEPS[0], BUDGET_STEPS[BUDGET_STEPS.length - 1], KNOB_DEFAULTS.budget),
|
|
809
|
+
stubMore: Boolean(raw.stubMore),
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
export function loadKnobs(extra = {}) {
|
|
814
|
+
const file = extra.file || knobsFile(extra.home);
|
|
815
|
+
let raw;
|
|
816
|
+
try { raw = fs.readFileSync(file, 'utf8'); } catch { return { ok: false, reason: 'missing' }; }
|
|
817
|
+
let data;
|
|
818
|
+
try { data = JSON.parse(raw); } catch { return { ok: false, reason: 'corrupt' }; }
|
|
819
|
+
if (!data || typeof data !== 'object') return { ok: false, reason: 'corrupt' };
|
|
820
|
+
return { ok: true, knobs: sanitizeKnobs(data) };
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
export function persistKnobs(knobs, extra = {}) {
|
|
824
|
+
const file = extra.file || knobsFile(extra.home);
|
|
825
|
+
try {
|
|
826
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
827
|
+
const tmp = `${file}.tmp`;
|
|
828
|
+
fs.writeFileSync(tmp, JSON.stringify(sanitizeKnobs(knobs)));
|
|
829
|
+
fs.renameSync(tmp, file);
|
|
830
|
+
return true;
|
|
831
|
+
} catch {
|
|
832
|
+
return false;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
let memoryKnobs = null;
|
|
837
|
+
let lastAdaptAction = 'hold';
|
|
838
|
+
let knobsLoaded = false;
|
|
839
|
+
|
|
840
|
+
export function resetAdaptState(knobs = null) {
|
|
841
|
+
memoryKnobs = knobs ? sanitizeKnobs(knobs) : null;
|
|
842
|
+
lastAdaptAction = 'hold';
|
|
843
|
+
knobsLoaded = false;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
export function lastAdapt() {
|
|
847
|
+
return lastAdaptAction;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
export function getLiveKnobs(extra = {}) {
|
|
851
|
+
if (!adaptEnabled()) return envKnobs();
|
|
852
|
+
if (memoryKnobs) return { ...memoryKnobs };
|
|
853
|
+
if (!knobsLoaded) {
|
|
854
|
+
knobsLoaded = true;
|
|
855
|
+
const loaded = loadKnobs(extra);
|
|
856
|
+
if (loaded.ok) {
|
|
857
|
+
memoryKnobs = sanitizeKnobs({ ...envKnobs(), ...loaded.knobs });
|
|
858
|
+
return { ...memoryKnobs };
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
memoryKnobs = envKnobs();
|
|
862
|
+
return { ...memoryKnobs };
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
export function rememberKnobs(knobs, extra = {}) {
|
|
866
|
+
memoryKnobs = sanitizeKnobs(knobs);
|
|
867
|
+
if (extra.persist !== false) persistKnobs(memoryKnobs, extra);
|
|
868
|
+
return { ...memoryKnobs };
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function nearestIndex(steps, value) {
|
|
872
|
+
let best = 0;
|
|
873
|
+
let dist = Infinity;
|
|
874
|
+
for (let i = 0; i < steps.length; i++) {
|
|
875
|
+
const d = Math.abs(steps[i] - value);
|
|
876
|
+
if (d < dist) { dist = d; best = i; }
|
|
877
|
+
}
|
|
878
|
+
return best;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
export function tightenKnobs(knobs, { ratio, corpusChars, target = ADAPT_TARGET } = {}) {
|
|
882
|
+
const cur = sanitizeKnobs(knobs);
|
|
883
|
+
const gap = target / Math.max(Number(ratio) || 0.01, 0.01);
|
|
884
|
+
// Mild miss: one notch. Far below target (live 1–4x): jump toward the
|
|
885
|
+
// floor so a single recut can clear 10x. Always stub more on the way down.
|
|
886
|
+
const steps = gap > 1.5 ? 2 : 1;
|
|
887
|
+
const keepI = Math.max(0, nearestIndex(KEEP_STEPS, cur.keepTail) - steps);
|
|
888
|
+
const turnI = Math.max(0, nearestIndex(TURNS_STEPS, cur.minTurns) - steps);
|
|
889
|
+
let budget = cur.budget;
|
|
890
|
+
for (let i = 0; i < steps; i++) {
|
|
891
|
+
budget = BUDGET_STEPS[Math.max(0, nearestIndex(BUDGET_STEPS, budget) - 1)];
|
|
892
|
+
}
|
|
893
|
+
if (gap > 1.5 && Number.isFinite(corpusChars) && corpusChars > 0) {
|
|
894
|
+
const needSent = Math.floor(corpusChars / target);
|
|
895
|
+
if (needSent > 0) budget = Math.min(budget, Math.max(BUDGET_STEPS[0], needSent));
|
|
896
|
+
return sanitizeKnobs({
|
|
897
|
+
keepTail: KEEP_STEPS[0],
|
|
898
|
+
minTurns: TURNS_STEPS[0],
|
|
899
|
+
budget,
|
|
900
|
+
stubMore: true,
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
return sanitizeKnobs({
|
|
904
|
+
keepTail: KEEP_STEPS[keepI],
|
|
905
|
+
minTurns: TURNS_STEPS[turnI],
|
|
906
|
+
budget,
|
|
907
|
+
stubMore: true,
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
export function loosenKnobs(knobs) {
|
|
912
|
+
const cur = sanitizeKnobs(knobs);
|
|
913
|
+
const keepI = Math.min(KEEP_STEPS.length - 1, nearestIndex(KEEP_STEPS, cur.keepTail) + 1);
|
|
914
|
+
const turnI = Math.min(TURNS_STEPS.length - 1, nearestIndex(TURNS_STEPS, cur.minTurns) + 1);
|
|
915
|
+
const budI = Math.min(BUDGET_STEPS.length - 1, nearestIndex(BUDGET_STEPS, cur.budget) + 1);
|
|
916
|
+
return sanitizeKnobs({
|
|
917
|
+
keepTail: KEEP_STEPS[keepI],
|
|
918
|
+
minTurns: TURNS_STEPS[turnI],
|
|
919
|
+
budget: BUDGET_STEPS[budI],
|
|
920
|
+
stubMore: false,
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function sameKnobs(a, b) {
|
|
925
|
+
return a.keepTail === b.keepTail
|
|
926
|
+
&& a.minTurns === b.minTurns
|
|
927
|
+
&& a.budget === b.budget
|
|
928
|
+
&& Boolean(a.stubMore) === Boolean(b.stubMore);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function fmtRatio(ratio) {
|
|
932
|
+
if (!Number.isFinite(ratio)) return 'inf';
|
|
933
|
+
return String(Number(ratio.toFixed(2)));
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function adaptLine({ action, ratio, knobs, target = ADAPT_TARGET }) {
|
|
937
|
+
if (action === 'hold') return `adapt hold ratio=${fmtRatio(ratio)}`;
|
|
938
|
+
return `adapt ratio=${fmtRatio(ratio)} target=${target} tail=${knobs.keepTail} budget=${knobs.budget}`;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Decide whether to shrink, loosen, or hold. Tighten recuts this request;
|
|
943
|
+
* loosen only remembers a safer notch for the NEXT one so we do not
|
|
944
|
+
* flip-flop every call after an overshoot.
|
|
945
|
+
*/
|
|
946
|
+
export function adaptTail({
|
|
947
|
+
ratio,
|
|
948
|
+
knobs,
|
|
949
|
+
lastAction = 'hold',
|
|
950
|
+
corpusChars,
|
|
951
|
+
target = ADAPT_TARGET,
|
|
952
|
+
loosenAt = ADAPT_LOOSEN_AT,
|
|
953
|
+
} = {}) {
|
|
954
|
+
const cur = sanitizeKnobs(knobs);
|
|
955
|
+
if (!Number.isFinite(ratio)) {
|
|
956
|
+
return { action: 'hold', knobs: cur, recut: false, ratio, log: adaptLine({ action: 'hold', ratio, knobs: cur, target }) };
|
|
957
|
+
}
|
|
958
|
+
if (ratio < target) {
|
|
959
|
+
const next = tightenKnobs(cur, { ratio, corpusChars, target });
|
|
960
|
+
const changed = !sameKnobs(next, cur);
|
|
961
|
+
const action = changed ? 'tighten' : 'hold';
|
|
962
|
+
return { action, knobs: next, recut: changed, ratio, log: adaptLine({ action, ratio, knobs: next, target }) };
|
|
963
|
+
}
|
|
964
|
+
if (ratio > loosenAt && lastAction === 'hold') {
|
|
965
|
+
const next = loosenKnobs(cur);
|
|
966
|
+
const changed = !sameKnobs(next, cur);
|
|
967
|
+
const action = changed ? 'loosen' : 'hold';
|
|
968
|
+
return { action, knobs: next, recut: false, ratio, log: adaptLine({ action, ratio, knobs: next, target }) };
|
|
969
|
+
}
|
|
970
|
+
return { action: 'hold', knobs: cur, recut: false, ratio, log: adaptLine({ action: 'hold', ratio, knobs: cur, target }) };
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function firstSpillableIndex(msgs) {
|
|
974
|
+
return msgs.findIndex((m) => m?.role !== 'system');
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function lastUserAskIndex(msgs, firstSpillable) {
|
|
978
|
+
for (let i = msgs.length - 1; i > firstSpillable; i--) {
|
|
979
|
+
if (msgs[i]?.role === 'user' && msgText(msgs[i]).trim()) return i;
|
|
980
|
+
}
|
|
981
|
+
return -1;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function countRealTurns(msgs, from) {
|
|
985
|
+
let n = 0;
|
|
986
|
+
for (let i = from; i < msgs.length; i++) {
|
|
987
|
+
const r = msgs[i]?.role;
|
|
988
|
+
if (r === 'user' || r === 'assistant') n += 1;
|
|
989
|
+
}
|
|
990
|
+
return n;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function isSeverable(msgs, i, firstSpillable) {
|
|
994
|
+
if (i <= firstSpillable || i >= msgs.length) return false;
|
|
995
|
+
const prev = msgs[i - 1];
|
|
996
|
+
if (!prev) return false;
|
|
997
|
+
if (prev.role === 'assistant' && Array.isArray(prev.tool_calls) && prev.tool_calls.length) return false;
|
|
998
|
+
return msgs[i].role !== 'tool';
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Pick a severable cut: keep a recent tail, honour the byte budget, floor
|
|
1003
|
+
* at minTurns of user/assistant, and never drop the last user ask.
|
|
1004
|
+
*/
|
|
1005
|
+
export function cutTranscript(msgs, knobs = {}) {
|
|
1006
|
+
const k = sanitizeKnobs({ ...envKnobs(), ...knobs });
|
|
1007
|
+
if (!Array.isArray(msgs) || !msgs.length) {
|
|
1008
|
+
return { cut: -1, firstSpillable: -1, lastUser: -1, knobs: k };
|
|
1009
|
+
}
|
|
1010
|
+
const firstSpillable = firstSpillableIndex(msgs);
|
|
1011
|
+
if (firstSpillable < 0) return { cut: -1, firstSpillable: -1, lastUser: -1, knobs: k };
|
|
1012
|
+
|
|
1013
|
+
const keepTail = Math.min(k.keepTail, Math.max(2, Math.floor(msgs.length / 2)));
|
|
1014
|
+
const minTurns = Math.max(2, k.minTurns);
|
|
1015
|
+
const budget = k.budget;
|
|
1016
|
+
|
|
1017
|
+
let cut = -1;
|
|
1018
|
+
for (let i = msgs.length - keepTail; i > firstSpillable; i--) {
|
|
1019
|
+
if (isSeverable(msgs, i, firstSpillable)) { cut = i; break; }
|
|
1020
|
+
}
|
|
1021
|
+
if (cut <= firstSpillable) {
|
|
1022
|
+
for (let i = msgs.length - 2; i > firstSpillable; i--) {
|
|
1023
|
+
if (isSeverable(msgs, i, firstSpillable)) { cut = i; break; }
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
if (cut <= firstSpillable) {
|
|
1027
|
+
return { cut: -1, firstSpillable, lastUser: lastUserAskIndex(msgs, firstSpillable), knobs: k };
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// Only moves the cut at a severable index. A current-turn tool storm
|
|
1031
|
+
// (assistant(tool_calls) + N tool results + user ask) has no severable
|
|
1032
|
+
// index inside the chain, so this walk is a no-op — the byte budget is
|
|
1033
|
+
// applied by stubbing bodies in stubBoundFileResults, not by orphaning
|
|
1034
|
+
// a tool_result.
|
|
1035
|
+
let tailStart = cut;
|
|
1036
|
+
{
|
|
1037
|
+
let used = 0;
|
|
1038
|
+
for (let i = msgs.length - 1; i >= cut; i--) {
|
|
1039
|
+
used += msgText(msgs[i]).length;
|
|
1040
|
+
if (used > budget && isSeverable(msgs, i, firstSpillable)) { tailStart = i; break; }
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
if (tailStart > cut) cut = tailStart;
|
|
1044
|
+
|
|
1045
|
+
if (countRealTurns(msgs, cut) < minTurns) {
|
|
1046
|
+
for (let i = cut - 1; i > firstSpillable; i--) {
|
|
1047
|
+
if (isSeverable(msgs, i, firstSpillable) && countRealTurns(msgs, i) >= minTurns) { cut = i; break; }
|
|
1048
|
+
if (i === firstSpillable + 1) { if (isSeverable(msgs, i, firstSpillable)) cut = i; break; }
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const lastUser = lastUserAskIndex(msgs, firstSpillable);
|
|
1053
|
+
if (lastUser > firstSpillable && cut > lastUser) cut = lastUser;
|
|
1054
|
+
|
|
1055
|
+
// Never shrink below 2 real turns when that would drop the ask — the ask
|
|
1056
|
+
// always stays; expand earlier only if two turns exist and remain after it.
|
|
1057
|
+
if (lastUser > firstSpillable && countRealTurns(msgs, cut) < 2) {
|
|
1058
|
+
for (let i = cut - 1; i > firstSpillable; i--) {
|
|
1059
|
+
if (isSeverable(msgs, i, firstSpillable) && countRealTurns(msgs, i) >= 2) { cut = i; break; }
|
|
1060
|
+
}
|
|
1061
|
+
if (cut > lastUser) cut = lastUser;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
return { cut, firstSpillable, lastUser, knobs: k };
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function stubForCut(msgs, cut, opts) {
|
|
1068
|
+
return stubBoundFileResults(msgs, {
|
|
1069
|
+
boundFiles: opts.boundFiles,
|
|
1070
|
+
boundAbs: opts.boundAbs,
|
|
1071
|
+
cwd: opts.cwd,
|
|
1072
|
+
fromIndex: cut,
|
|
1073
|
+
aggressive: Boolean(opts.aggressive),
|
|
1074
|
+
budget: opts.budget,
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Cut + stub, then retune knobs toward a >10x corpus/sent ratio in process
|
|
1080
|
+
* memory. A miss recuts once this request. A huge overshoot loosens one
|
|
1081
|
+
* notch for the next request only (no flip-flop). Env OPENZOO_ADAPT=0
|
|
1082
|
+
* disables the tuner; env still seeds the initial knobs.
|
|
1083
|
+
*/
|
|
1084
|
+
export function applySpillCut(msgs, {
|
|
1085
|
+
knobs,
|
|
1086
|
+
corpusChars = 0,
|
|
1087
|
+
boundFiles,
|
|
1088
|
+
boundAbs,
|
|
1089
|
+
cwd = process.cwd(),
|
|
1090
|
+
log = () => {},
|
|
1091
|
+
adapt = adaptEnabled(),
|
|
1092
|
+
persist = false,
|
|
1093
|
+
file,
|
|
1094
|
+
home,
|
|
1095
|
+
} = {}) {
|
|
1096
|
+
const persistOpts = { persist, file, home };
|
|
1097
|
+
let k = sanitizeKnobs(knobs || getLiveKnobs(persistOpts));
|
|
1098
|
+
let plan = cutTranscript(msgs, k);
|
|
1099
|
+
const empty = {
|
|
1100
|
+
cut: plan.cut,
|
|
1101
|
+
firstSpillable: plan.firstSpillable,
|
|
1102
|
+
lastUser: plan.lastUser,
|
|
1103
|
+
knobs: k,
|
|
1104
|
+
stubbed: { messages: msgs, stubbed: 0, dropped: 0 },
|
|
1105
|
+
sentChars: 0,
|
|
1106
|
+
prefixChars: 0,
|
|
1107
|
+
ratio: 0,
|
|
1108
|
+
action: 'hold',
|
|
1109
|
+
};
|
|
1110
|
+
if (plan.cut <= plan.firstSpillable) {
|
|
1111
|
+
const sentChars = sliceChars(msgs, 0);
|
|
1112
|
+
const corpus = Math.max(Number(corpusChars) || 0, sentChars);
|
|
1113
|
+
return { ...empty, sentChars, ratio: spillRatio(corpus, sentChars) };
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const measure = (cut, stubbed, knobsNow) => {
|
|
1117
|
+
const prefixChars = sliceChars(msgs, plan.firstSpillable, cut);
|
|
1118
|
+
const sentChars = sliceChars(stubbed.messages, cut);
|
|
1119
|
+
const corpus = Math.max(Number(corpusChars) || 0, prefixChars);
|
|
1120
|
+
return {
|
|
1121
|
+
prefixChars,
|
|
1122
|
+
sentChars,
|
|
1123
|
+
corpusChars: corpus,
|
|
1124
|
+
ratio: spillRatio(corpus, sentChars),
|
|
1125
|
+
knobs: knobsNow,
|
|
1126
|
+
};
|
|
1127
|
+
};
|
|
1128
|
+
|
|
1129
|
+
let stubbed = stubForCut(msgs, plan.cut, { boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget });
|
|
1130
|
+
let stats = measure(plan.cut, stubbed, k);
|
|
1131
|
+
let action = 'hold';
|
|
1132
|
+
|
|
1133
|
+
if (adapt) {
|
|
1134
|
+
const decision = adaptTail({
|
|
1135
|
+
ratio: stats.ratio,
|
|
1136
|
+
knobs: k,
|
|
1137
|
+
lastAction: lastAdaptAction,
|
|
1138
|
+
corpusChars: stats.corpusChars,
|
|
1139
|
+
});
|
|
1140
|
+
k = decision.knobs;
|
|
1141
|
+
action = decision.action;
|
|
1142
|
+
if (decision.recut) {
|
|
1143
|
+
plan = cutTranscript(msgs, k);
|
|
1144
|
+
if (plan.cut > plan.firstSpillable) {
|
|
1145
|
+
stubbed = stubForCut(msgs, plan.cut, { boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget });
|
|
1146
|
+
stats = measure(plan.cut, stubbed, k);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
rememberKnobs(k, persistOpts);
|
|
1150
|
+
lastAdaptAction = action;
|
|
1151
|
+
log(adaptLine({ action, ratio: stats.ratio, knobs: k }));
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
return {
|
|
1155
|
+
cut: plan.cut,
|
|
1156
|
+
firstSpillable: plan.firstSpillable,
|
|
1157
|
+
lastUser: plan.lastUser,
|
|
1158
|
+
knobs: k,
|
|
1159
|
+
stubbed,
|
|
1160
|
+
sentChars: stats.sentChars,
|
|
1161
|
+
prefixChars: stats.prefixChars,
|
|
1162
|
+
ratio: stats.ratio,
|
|
1163
|
+
action,
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
|
|
613
1167
|
/** Session counters the HUD reads off /v1/info. */
|
|
614
1168
|
export function createSpillStats() {
|
|
615
1169
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.48.
|
|
3
|
+
"version": "0.48.75",
|
|
4
4
|
"description": "Local x402-paying proxy + MCP server for openzoo.fun \u2014 point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana and Base rails live; Robinhood experimental.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|