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 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 failure — bad JSON,
263
- * unreachable catalog — returns null: this layer must never break a call
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 to = resolveModel(body.model, ids);
273
- if (!to) return null;
274
- return { body: Buffer.from(JSON.stringify({ ...body, model: to })), from: body.model, to };
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
- stubBoundFileResults, createSpillStats, corpusCharsForSend,
17
+ createSpillStats, corpusCharsForSend, applySpillCut, msgText,
18
18
  } from './spill.js';
19
- import { maybeRewriteModel, rewritablePath, augmentModelList, ALIAS_IDS } from './models.js';
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
- // Keep the recent tail, but never more than half the transcript: a fixed 8 on
445
- // a 10-message body left only index 2 to search, which is rarely a user turn,
446
- // so a SHORT-but-huge transcript (one giant tool_result) silently never
447
- // spilled the exact case an agent hits first.
448
- const keepTail = Math.min(
449
- Number(process.env.OPENZOO_KEEP_TAIL_MSGS || 8),
450
- Math.max(2, Math.floor(msgs.length / 2)),
451
- );
452
- const firstSpillable = msgs.findIndex((m) => m?.role !== 'system');
453
- if (firstSpillable < 0) return null;
454
-
455
- // A SEVERABLE BOUNDARY IS NOT ONLY A `user` TURN.
456
- //
457
- // That was the whole bug: in a Claude Code agent loop the human speaks ONCE
458
- // and everything after is assistant->tool pairs, so a rule that only cuts at
459
- // role:"user" finds nothing and the spill returns null on EVERY turn.
460
- // MEASURED against the live proxy log: 104 real claude-cli requests, zero
461
- // spills, while every synthetic transcript I tested spilled fine because I
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; // nothing safely severable
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
- if (lastUser > firstSpillable && cut > lastUser) cut = lastUser;
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
- // STUB BOUND FILE BODIES IN THE TAIL.
763
- //
764
- // Tests bind a 250k pile and send a one-line ask: 7x. Live Claude Code
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
- // REASONING MODELS SPEND max_tokens ON THINKING FIRST.
1175
+ // TINY CLASSIFY FIRST, ON THE ORIGINAL BODY.
1348
1176
  //
1349
- // The budget covers hidden reasoning AND the visible answer, so a
1350
- // caller that asks for 40 tokens because it wants a short answer often
1351
- // gets ZERO the whole allowance went to reasoning and the completion
1352
- // truncated to an empty string. Measured across three families in one
1353
- // day: deepseek returned 0 chars at 8k and was fine at 24k; grok-4.6
1354
- // pinned ct at exactly its 16,000 budget with no visible output;
1355
- // sonnet-5 truncated a 600-token file mid-function because Anthropic's
1356
- // max_tokens covers thinking too.
1357
- //
1358
- // An empty completion is not an error it bills normally and renders
1359
- // as a blank reply so this fails silently and looks like the retrieval
1360
- // broke. It cost real debugging time tonight for exactly that reason.
1361
- // Multiply the allowance for known reasoning families and let callers
1362
- // keep asking for what they actually want back.
1363
- const REASONING = /(deepseek|grok|o[134](-|$)|reasoner|thinking|-pro\b|sol-pro|qwq)/i;
1364
- const mult = Number(process.env.OPENZOO_REASONING_MAX_TOKENS_X || 4);
1365
- const cap = Number(process.env.OPENZOO_REASONING_MAX_TOKENS_CAP || 32000);
1366
- const mdl = String(parsed?.model || '');
1367
- const mt = Number(parsed?.max_tokens);
1368
- // A MULTIPLIER ALONE IS NOT ENOUGH. 4x on a caller's 40 is 160, which is
1369
- // still nothing for a model that thinks first — measured, 2 of 3 runs
1370
- // still returned empty at 160. Reasoning needs an absolute floor, not a
1371
- // relative bump, so take whichever is larger.
1372
- const floor = Number(process.env.OPENZOO_REASONING_MIN_TOKENS || 4000);
1373
- if (mult > 1 && REASONING.test(mdl) && Number.isFinite(mt) && mt > 0 && mt < cap) {
1374
- const raised = Math.min(cap, Math.max(floor, Math.round(mt * mult)));
1375
- if (raised > mt) {
1376
- parsed.max_tokens = raised;
1377
- bodyBuf = Buffer.from(JSON.stringify(parsed));
1378
- log(`reasoning model ${mdl}: max_tokens ${mt} -> ${raised} (thinking shares the budget; OPENZOO_REASONING_MAX_TOKENS_X=1 disables)`);
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
- const tiny = bodyBuf.length < BIND_MIN_CHARS
1403
- && Number(parsed?.max_tokens ?? 0) > 0
1404
- && Number(parsed?.max_tokens) <= 64;
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
- cached = await maybeCacheCorpus(req, bodyBuf, log, spill);
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
- export function fileBoundStub(paths) {
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
- return list ? `FILE ${list} [bound]` : 'FILE [bound]';
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 (npm test, grep, …) stay verbatim. The ask stays.
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
- if (!Array.isArray(msgs) || !absSet.size) {
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
- if (!stubIds.size) return { messages: msgs, stubbed: 0, dropped: 0 };
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
- const messages = msgs.map((m, i) => {
589
- if (i < fromIndex || !m) return m;
590
- if (m.role === 'tool' && stubIds.has(m.tool_call_id)) {
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: fileBoundStub(idPaths.get(m.tool_call_id)) };
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' || !stubIds.has(b.tool_use_id)) return b;
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: fileBoundStub(idPaths.get(b.tool_use_id)) };
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.72",
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",