openzoo 0.48.72 → 0.48.74

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
  }
@@ -1321,62 +1166,75 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1321
1166
  }
1322
1167
  }
1323
1168
  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
1169
  try {
1345
1170
  let parsed = JSON.parse(bodyBuf.toString('utf8'));
1346
1171
  wantsStream = parsed?.stream === true || clientWantsStream;
1347
- // REASONING MODELS SPEND max_tokens ON THINKING FIRST.
1172
+ // TINY CLASSIFY FIRST, ON THE ORIGINAL BODY.
1348
1173
  //
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)`);
1174
+ // Claude Code auto-mode sends a 16-token yes/no to claude-sonnet-5
1175
+ // before Bash/WebSearch. Two things used to eat that call:
1176
+ // 1. OPENZOO_DEFAULT_MODEL rewrote it onto deepseek/grok;
1177
+ // 2. the reasoning floor then raised 16 -> 4000.
1178
+ // Measured: 11.5s, past the caller's timeout, "claude-sonnet-5 is
1179
+ // temporarily unavailable (timed out), so auto mode cannot determine
1180
+ // the safety of WebSearch". Even staying on sonnet-5 is too slow
1181
+ // (402 handshake behind a long Grok stream). Pin to a fast
1182
+ // non-reasoning catalog id and leave max_tokens at 16. Real
1183
+ // Grok/DeepSeek chats (max_tokens > 64 or a body over BIND_MIN)
1184
+ // still get the 4000 floor those still go blank without it.
1185
+ let ids = [];
1186
+ try { ids = await zooModelIds(); } catch { /* catalog miss: still skip the floor on a tiny classify */ }
1187
+ const policy = rewriteChatModel(parsed, ids, { bodyLen: bodyBuf.length });
1188
+ parsed = policy.parsed;
1189
+ if (policy.tiny) {
1190
+ log(`classifier tiny max_tokens=${Number(parsed?.max_tokens)} "${policy.from}" -> ${policy.to} (no reasoning floor)`);
1191
+ } else {
1192
+ // SAY WHETHER THE OVERRIDE IS ACTUALLY SET, and name it.
1193
+ //
1194
+ // This used to print "(OPENZOO_DEFAULT_MODEL overrides)" on EVERY
1195
+ // rewrite whether or not the variable existed a hint about a knob,
1196
+ // phrased as a statement about this request. It cost a real incident:
1197
+ // the proxy was restarted from a shell carrying
1198
+ // OPENZOO_DEFAULT_MODEL=deepseek/deepseek-v4-pro-0813, so every
1199
+ // claude-sonnet-5 ask was served by deepseek, and the log line looked
1200
+ // exactly the same as it always had. deepseek matches the reasoning
1201
+ // regex, so a 16-token safety classification became a 4,000-token
1202
+ // reasoning generation � 11.5s, past the caller's timeout, and Claude
1203
+ // Code reported "claude-sonnet-5 is temporarily unavailable".
1204
+ // Tiny classify is pinned above and never reaches this path.
1205
+ if (policy.to && policy.to !== policy.from) {
1206
+ const forced = process.env.OPENZOO_DEFAULT_MODEL;
1207
+ log(forced
1208
+ ? `model "${policy.from}" -> FORCED to ${forced} by OPENZOO_DEFAULT_MODEL (nearest match would have been ${policy.to})`
1209
+ : `model "${policy.from}" is not on the zoo � nearest match ${policy.to}`);
1379
1210
  }
1211
+ // REASONING MODELS SPEND max_tokens ON THINKING FIRST.
1212
+ //
1213
+ // The budget covers hidden reasoning AND the visible answer, so a
1214
+ // caller that asks for 40 tokens because it wants a short answer often
1215
+ // gets ZERO � the whole allowance went to reasoning and the completion
1216
+ // truncated to an empty string. Measured across three families in one
1217
+ // day: deepseek returned 0 chars at 8k and was fine at 24k; grok-4.6
1218
+ // pinned ct at exactly its 16,000 budget with no visible output;
1219
+ // sonnet-5 truncated a 600-token file mid-function because Anthropic's
1220
+ // max_tokens covers thinking too.
1221
+ //
1222
+ // An empty completion is not an error � it bills normally and renders
1223
+ // as a blank reply � so this fails silently and looks like the retrieval
1224
+ // broke. It cost real debugging time tonight for exactly that reason.
1225
+ // Multiply the allowance for known reasoning families and let callers
1226
+ // keep asking for what they actually want back.
1227
+ //
1228
+ // A MULTIPLIER ALONE IS NOT ENOUGH. 4x on a caller's 40 is 160, which is
1229
+ // still nothing for a model that thinks first � measured, 2 of 3 runs
1230
+ // still returned empty at 160. Reasoning needs an absolute floor, not a
1231
+ // relative bump, so take whichever is larger.
1232
+ if (policy.raised) {
1233
+ log(`reasoning model ${parsed.model}: max_tokens ${policy.raisedFrom} -> ${policy.raisedTo} (thinking shares the budget; OPENZOO_REASONING_MAX_TOKENS_X=1 disables)`);
1234
+ }
1235
+ }
1236
+ if (policy.tiny || policy.raised || (policy.to && policy.to !== policy.from)) {
1237
+ bodyBuf = Buffer.from(JSON.stringify(parsed));
1380
1238
  }
1381
1239
  // Tell the agent what it is actually connected to — in band, where it
1382
1240
  // will read it, instead of leaving it to guess (and to chunk corpora
@@ -1398,10 +1256,12 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1398
1256
  // advice nobody will read.
1399
1257
  //
1400
1258
  // 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;
1259
+ // corpus and nothing the brief could help with. policy.tiny is
1260
+ // that check on the ORIGINAL body, before the reasoning floor.
1261
+ const tiny = policy.tiny
1262
+ || (bodyBuf.length < BIND_MIN_CHARS
1263
+ && Number(parsed?.max_tokens ?? 0) > 0
1264
+ && Number(parsed?.max_tokens) <= 64);
1405
1265
  const briefed = tiny ? null : injectBrief(parsed, selfUrl);
1406
1266
  if (briefed) parsed = briefed;
1407
1267
  // SYSTEM MESSAGES BELONG AT THE FRONT, OR GOOGLE 400s.
package/lib/spill.js CHANGED
@@ -534,9 +534,12 @@ export function stubBoundFileResults(msgs, {
534
534
  boundAbs,
535
535
  cwd = process.cwd(),
536
536
  fromIndex = 0,
537
+ // When the live tuner is below target, stub file-view results even if
538
+ // this turn has not yet recorded them in boundAbs (first-read bodies).
539
+ aggressive = false,
537
540
  } = {}) {
538
541
  const absSet = boundAbs || boundAbsFromKeys(boundFiles);
539
- if (!Array.isArray(msgs) || !absSet.size) {
542
+ if (!Array.isArray(msgs) || (!absSet.size && !aggressive)) {
540
543
  return { messages: msgs, stubbed: 0, dropped: 0 };
541
544
  }
542
545
 
@@ -556,6 +559,7 @@ export function stubBoundFileResults(msgs, {
556
559
  for (const raw of raws) {
557
560
  const hit = resolveBoundPath(raw, currentCwd, absSet);
558
561
  if (hit) resolved.push(hit);
562
+ else if (aggressive && raw) resolved.push(resolveReadablePath(raw, currentCwd) || raw);
559
563
  }
560
564
  if (!resolved.length || !id) return;
561
565
  stubIds.add(id);
@@ -610,6 +614,463 @@ export function stubBoundFileResults(msgs, {
610
614
  return { messages, stubbed, dropped };
611
615
  }
612
616
 
617
+ export const ADAPT_TARGET = 10;
618
+ export const ADAPT_LOOSEN_AT = 20;
619
+
620
+ export const KNOB_DEFAULTS = Object.freeze({
621
+ keepTail: 8,
622
+ minTurns: 6,
623
+ budget: 6000,
624
+ stubMore: false,
625
+ });
626
+
627
+ const KEEP_STEPS = [2, 3, 4, 6, 8, 12, 16];
628
+ const TURNS_STEPS = [2, 3, 4, 6, 8, 12];
629
+ const BUDGET_STEPS = [800, 1500, 2500, 4000, 6000, 9000, 12000, 18000, 24000];
630
+
631
+ /** Flatten one Anthropic content block to text leCore can index. */
632
+ export function blockText(b) {
633
+ if (typeof b === 'string') return b;
634
+ if (!b || typeof b !== 'object') return '';
635
+ if (b.type === 'text') return b.text || '';
636
+ if (b.type === 'tool_use') return `[tool_use ${b.name}] ${JSON.stringify(b.input ?? {})}`;
637
+ if (b.type === 'tool_result') {
638
+ const c = b.content;
639
+ return `[tool_result] ${typeof c === 'string' ? c : (Array.isArray(c) ? c.map(blockText).join('\n') : JSON.stringify(c ?? ''))}`;
640
+ }
641
+ if (b.type === 'thinking') return '';
642
+ return '';
643
+ }
644
+
645
+ export function msgText(m) {
646
+ const c = m?.content;
647
+ const body = typeof c === 'string' ? c : (Array.isArray(c) ? c.map(blockText).filter(Boolean).join('\n') : '');
648
+ return body ? `${(m.role || '?').toUpperCase()}: ${body}` : '';
649
+ }
650
+
651
+ /** Characters that actually ride in a forwarded message (content + tool_calls). */
652
+ export function messageChars(m) {
653
+ if (!m) return 0;
654
+ let n = 0;
655
+ const c = m.content;
656
+ if (typeof c === 'string') n += c.length;
657
+ else if (Array.isArray(c)) {
658
+ for (const b of c) {
659
+ if (typeof b === 'string') n += b.length;
660
+ else n += String(b?.text ?? (typeof b?.content === 'string' ? b.content : '')).length;
661
+ }
662
+ } else if (c && typeof c === 'object') n += JSON.stringify(c).length;
663
+ if (Array.isArray(m.tool_calls)) {
664
+ for (const tc of m.tool_calls) {
665
+ const a = tc?.function?.arguments ?? tc?.arguments;
666
+ n += typeof a === 'string' ? a.length : (a ? JSON.stringify(a).length : 0);
667
+ }
668
+ }
669
+ return n;
670
+ }
671
+
672
+ export function sliceChars(msgs, from = 0, to = undefined) {
673
+ if (!Array.isArray(msgs)) return 0;
674
+ const end = to == null ? msgs.length : to;
675
+ let n = 0;
676
+ for (let i = from; i < end && i < msgs.length; i++) n += messageChars(msgs[i]);
677
+ return n;
678
+ }
679
+
680
+ export function spillRatio(corpusChars, sentChars) {
681
+ const c = Number(corpusChars) || 0;
682
+ const s = Number(sentChars) || 0;
683
+ if (s <= 0) return c > 0 ? Infinity : 0;
684
+ return c / s;
685
+ }
686
+
687
+ function envNumber(key, fallback) {
688
+ const v = Number(process.env[key]);
689
+ return Number.isFinite(v) && v > 0 ? v : fallback;
690
+ }
691
+
692
+ export function envKnobs() {
693
+ return {
694
+ keepTail: envNumber('OPENZOO_KEEP_TAIL_MSGS', KNOB_DEFAULTS.keepTail),
695
+ minTurns: envNumber('OPENZOO_TAIL_MIN_TURNS', KNOB_DEFAULTS.minTurns),
696
+ budget: envNumber('OPENZOO_TAIL_MAX_CHARS', KNOB_DEFAULTS.budget),
697
+ stubMore: process.env.OPENZOO_STUB_MORE === '1',
698
+ };
699
+ }
700
+
701
+ export function adaptEnabled() {
702
+ return process.env.OPENZOO_ADAPT !== '0';
703
+ }
704
+
705
+ export function knobsFile(home = os.homedir()) {
706
+ return process.env.OPENZOO_KNOBS_PATH
707
+ || path.join(home, '.openzoo', 'knobs.json');
708
+ }
709
+
710
+ function clampInt(n, lo, hi, fallback) {
711
+ const v = Number(n);
712
+ if (!Number.isFinite(v)) return fallback;
713
+ return Math.min(hi, Math.max(lo, Math.round(v)));
714
+ }
715
+
716
+ export function sanitizeKnobs(raw = {}) {
717
+ if (!raw || typeof raw !== 'object') return { ...KNOB_DEFAULTS };
718
+ return {
719
+ keepTail: clampInt(raw.keepTail, KEEP_STEPS[0], KEEP_STEPS[KEEP_STEPS.length - 1], KNOB_DEFAULTS.keepTail),
720
+ minTurns: clampInt(raw.minTurns, TURNS_STEPS[0], TURNS_STEPS[TURNS_STEPS.length - 1], KNOB_DEFAULTS.minTurns),
721
+ budget: clampInt(raw.budget, BUDGET_STEPS[0], BUDGET_STEPS[BUDGET_STEPS.length - 1], KNOB_DEFAULTS.budget),
722
+ stubMore: Boolean(raw.stubMore),
723
+ };
724
+ }
725
+
726
+ export function loadKnobs(extra = {}) {
727
+ const file = extra.file || knobsFile(extra.home);
728
+ let raw;
729
+ try { raw = fs.readFileSync(file, 'utf8'); } catch { return { ok: false, reason: 'missing' }; }
730
+ let data;
731
+ try { data = JSON.parse(raw); } catch { return { ok: false, reason: 'corrupt' }; }
732
+ if (!data || typeof data !== 'object') return { ok: false, reason: 'corrupt' };
733
+ return { ok: true, knobs: sanitizeKnobs(data) };
734
+ }
735
+
736
+ export function persistKnobs(knobs, extra = {}) {
737
+ const file = extra.file || knobsFile(extra.home);
738
+ try {
739
+ fs.mkdirSync(path.dirname(file), { recursive: true });
740
+ const tmp = `${file}.tmp`;
741
+ fs.writeFileSync(tmp, JSON.stringify(sanitizeKnobs(knobs)));
742
+ fs.renameSync(tmp, file);
743
+ return true;
744
+ } catch {
745
+ return false;
746
+ }
747
+ }
748
+
749
+ let memoryKnobs = null;
750
+ let lastAdaptAction = 'hold';
751
+ let knobsLoaded = false;
752
+
753
+ export function resetAdaptState(knobs = null) {
754
+ memoryKnobs = knobs ? sanitizeKnobs(knobs) : null;
755
+ lastAdaptAction = 'hold';
756
+ knobsLoaded = false;
757
+ }
758
+
759
+ export function lastAdapt() {
760
+ return lastAdaptAction;
761
+ }
762
+
763
+ export function getLiveKnobs(extra = {}) {
764
+ if (!adaptEnabled()) return envKnobs();
765
+ if (memoryKnobs) return { ...memoryKnobs };
766
+ if (!knobsLoaded) {
767
+ knobsLoaded = true;
768
+ const loaded = loadKnobs(extra);
769
+ if (loaded.ok) {
770
+ memoryKnobs = sanitizeKnobs({ ...envKnobs(), ...loaded.knobs });
771
+ return { ...memoryKnobs };
772
+ }
773
+ }
774
+ memoryKnobs = envKnobs();
775
+ return { ...memoryKnobs };
776
+ }
777
+
778
+ export function rememberKnobs(knobs, extra = {}) {
779
+ memoryKnobs = sanitizeKnobs(knobs);
780
+ if (extra.persist !== false) persistKnobs(memoryKnobs, extra);
781
+ return { ...memoryKnobs };
782
+ }
783
+
784
+ function nearestIndex(steps, value) {
785
+ let best = 0;
786
+ let dist = Infinity;
787
+ for (let i = 0; i < steps.length; i++) {
788
+ const d = Math.abs(steps[i] - value);
789
+ if (d < dist) { dist = d; best = i; }
790
+ }
791
+ return best;
792
+ }
793
+
794
+ export function tightenKnobs(knobs, { ratio, corpusChars, target = ADAPT_TARGET } = {}) {
795
+ const cur = sanitizeKnobs(knobs);
796
+ const gap = target / Math.max(Number(ratio) || 0.01, 0.01);
797
+ // Mild miss: one notch. Far below target (live 1–4x): jump toward the
798
+ // floor so a single recut can clear 10x. Always stub more on the way down.
799
+ const steps = gap > 1.5 ? 2 : 1;
800
+ const keepI = Math.max(0, nearestIndex(KEEP_STEPS, cur.keepTail) - steps);
801
+ const turnI = Math.max(0, nearestIndex(TURNS_STEPS, cur.minTurns) - steps);
802
+ let budget = cur.budget;
803
+ for (let i = 0; i < steps; i++) {
804
+ budget = BUDGET_STEPS[Math.max(0, nearestIndex(BUDGET_STEPS, budget) - 1)];
805
+ }
806
+ if (gap > 1.5 && Number.isFinite(corpusChars) && corpusChars > 0) {
807
+ const needSent = Math.floor(corpusChars / target);
808
+ if (needSent > 0) budget = Math.min(budget, Math.max(BUDGET_STEPS[0], needSent));
809
+ return sanitizeKnobs({
810
+ keepTail: KEEP_STEPS[0],
811
+ minTurns: TURNS_STEPS[0],
812
+ budget,
813
+ stubMore: true,
814
+ });
815
+ }
816
+ return sanitizeKnobs({
817
+ keepTail: KEEP_STEPS[keepI],
818
+ minTurns: TURNS_STEPS[turnI],
819
+ budget,
820
+ stubMore: true,
821
+ });
822
+ }
823
+
824
+ export function loosenKnobs(knobs) {
825
+ const cur = sanitizeKnobs(knobs);
826
+ const keepI = Math.min(KEEP_STEPS.length - 1, nearestIndex(KEEP_STEPS, cur.keepTail) + 1);
827
+ const turnI = Math.min(TURNS_STEPS.length - 1, nearestIndex(TURNS_STEPS, cur.minTurns) + 1);
828
+ const budI = Math.min(BUDGET_STEPS.length - 1, nearestIndex(BUDGET_STEPS, cur.budget) + 1);
829
+ return sanitizeKnobs({
830
+ keepTail: KEEP_STEPS[keepI],
831
+ minTurns: TURNS_STEPS[turnI],
832
+ budget: BUDGET_STEPS[budI],
833
+ stubMore: false,
834
+ });
835
+ }
836
+
837
+ function sameKnobs(a, b) {
838
+ return a.keepTail === b.keepTail
839
+ && a.minTurns === b.minTurns
840
+ && a.budget === b.budget
841
+ && Boolean(a.stubMore) === Boolean(b.stubMore);
842
+ }
843
+
844
+ function fmtRatio(ratio) {
845
+ if (!Number.isFinite(ratio)) return 'inf';
846
+ return String(Number(ratio.toFixed(2)));
847
+ }
848
+
849
+ function adaptLine({ action, ratio, knobs, target = ADAPT_TARGET }) {
850
+ if (action === 'hold') return `adapt hold ratio=${fmtRatio(ratio)}`;
851
+ return `adapt ratio=${fmtRatio(ratio)} target=${target} tail=${knobs.keepTail} budget=${knobs.budget}`;
852
+ }
853
+
854
+ /**
855
+ * Decide whether to shrink, loosen, or hold. Tighten recuts this request;
856
+ * loosen only remembers a safer notch for the NEXT one so we do not
857
+ * flip-flop every call after an overshoot.
858
+ */
859
+ export function adaptTail({
860
+ ratio,
861
+ knobs,
862
+ lastAction = 'hold',
863
+ corpusChars,
864
+ target = ADAPT_TARGET,
865
+ loosenAt = ADAPT_LOOSEN_AT,
866
+ } = {}) {
867
+ const cur = sanitizeKnobs(knobs);
868
+ if (!Number.isFinite(ratio)) {
869
+ return { action: 'hold', knobs: cur, recut: false, ratio, log: adaptLine({ action: 'hold', ratio, knobs: cur, target }) };
870
+ }
871
+ if (ratio < target) {
872
+ const next = tightenKnobs(cur, { ratio, corpusChars, target });
873
+ const changed = !sameKnobs(next, cur);
874
+ const action = changed ? 'tighten' : 'hold';
875
+ return { action, knobs: next, recut: changed, ratio, log: adaptLine({ action, ratio, knobs: next, target }) };
876
+ }
877
+ if (ratio > loosenAt && lastAction === 'hold') {
878
+ const next = loosenKnobs(cur);
879
+ const changed = !sameKnobs(next, cur);
880
+ const action = changed ? 'loosen' : 'hold';
881
+ return { action, knobs: next, recut: false, ratio, log: adaptLine({ action, ratio, knobs: next, target }) };
882
+ }
883
+ return { action: 'hold', knobs: cur, recut: false, ratio, log: adaptLine({ action: 'hold', ratio, knobs: cur, target }) };
884
+ }
885
+
886
+ function firstSpillableIndex(msgs) {
887
+ return msgs.findIndex((m) => m?.role !== 'system');
888
+ }
889
+
890
+ function lastUserAskIndex(msgs, firstSpillable) {
891
+ for (let i = msgs.length - 1; i > firstSpillable; i--) {
892
+ if (msgs[i]?.role === 'user' && msgText(msgs[i]).trim()) return i;
893
+ }
894
+ return -1;
895
+ }
896
+
897
+ function countRealTurns(msgs, from) {
898
+ let n = 0;
899
+ for (let i = from; i < msgs.length; i++) {
900
+ const r = msgs[i]?.role;
901
+ if (r === 'user' || r === 'assistant') n += 1;
902
+ }
903
+ return n;
904
+ }
905
+
906
+ function isSeverable(msgs, i, firstSpillable) {
907
+ if (i <= firstSpillable || i >= msgs.length) return false;
908
+ const prev = msgs[i - 1];
909
+ if (!prev) return false;
910
+ if (prev.role === 'assistant' && Array.isArray(prev.tool_calls) && prev.tool_calls.length) return false;
911
+ return msgs[i].role !== 'tool';
912
+ }
913
+
914
+ /**
915
+ * Pick a severable cut: keep a recent tail, honour the byte budget, floor
916
+ * at minTurns of user/assistant, and never drop the last user ask.
917
+ */
918
+ export function cutTranscript(msgs, knobs = {}) {
919
+ const k = sanitizeKnobs({ ...envKnobs(), ...knobs });
920
+ if (!Array.isArray(msgs) || !msgs.length) {
921
+ return { cut: -1, firstSpillable: -1, lastUser: -1, knobs: k };
922
+ }
923
+ const firstSpillable = firstSpillableIndex(msgs);
924
+ if (firstSpillable < 0) return { cut: -1, firstSpillable: -1, lastUser: -1, knobs: k };
925
+
926
+ const keepTail = Math.min(k.keepTail, Math.max(2, Math.floor(msgs.length / 2)));
927
+ const minTurns = Math.max(2, k.minTurns);
928
+ const budget = k.budget;
929
+
930
+ let cut = -1;
931
+ for (let i = msgs.length - keepTail; i > firstSpillable; i--) {
932
+ if (isSeverable(msgs, i, firstSpillable)) { cut = i; break; }
933
+ }
934
+ if (cut <= firstSpillable) {
935
+ for (let i = msgs.length - 2; i > firstSpillable; i--) {
936
+ if (isSeverable(msgs, i, firstSpillable)) { cut = i; break; }
937
+ }
938
+ }
939
+ if (cut <= firstSpillable) {
940
+ return { cut: -1, firstSpillable, lastUser: lastUserAskIndex(msgs, firstSpillable), knobs: k };
941
+ }
942
+
943
+ let tailStart = cut;
944
+ {
945
+ let used = 0;
946
+ for (let i = msgs.length - 1; i >= cut; i--) {
947
+ used += msgText(msgs[i]).length;
948
+ if (used > budget && isSeverable(msgs, i, firstSpillable)) { tailStart = i; break; }
949
+ }
950
+ }
951
+ if (tailStart > cut) cut = tailStart;
952
+
953
+ if (countRealTurns(msgs, cut) < minTurns) {
954
+ for (let i = cut - 1; i > firstSpillable; i--) {
955
+ if (isSeverable(msgs, i, firstSpillable) && countRealTurns(msgs, i) >= minTurns) { cut = i; break; }
956
+ if (i === firstSpillable + 1) { if (isSeverable(msgs, i, firstSpillable)) cut = i; break; }
957
+ }
958
+ }
959
+
960
+ const lastUser = lastUserAskIndex(msgs, firstSpillable);
961
+ if (lastUser > firstSpillable && cut > lastUser) cut = lastUser;
962
+
963
+ // Never shrink below 2 real turns when that would drop the ask — the ask
964
+ // always stays; expand earlier only if two turns exist and remain after it.
965
+ if (lastUser > firstSpillable && countRealTurns(msgs, cut) < 2) {
966
+ for (let i = cut - 1; i > firstSpillable; i--) {
967
+ if (isSeverable(msgs, i, firstSpillable) && countRealTurns(msgs, i) >= 2) { cut = i; break; }
968
+ }
969
+ if (cut > lastUser) cut = lastUser;
970
+ }
971
+
972
+ return { cut, firstSpillable, lastUser, knobs: k };
973
+ }
974
+
975
+ function stubForCut(msgs, cut, opts) {
976
+ return stubBoundFileResults(msgs, {
977
+ boundFiles: opts.boundFiles,
978
+ boundAbs: opts.boundAbs,
979
+ cwd: opts.cwd,
980
+ fromIndex: cut,
981
+ aggressive: Boolean(opts.aggressive),
982
+ });
983
+ }
984
+
985
+ /**
986
+ * Cut + stub, then retune knobs toward a >10x corpus/sent ratio in process
987
+ * memory. A miss recuts once this request. A huge overshoot loosens one
988
+ * notch for the next request only (no flip-flop). Env OPENZOO_ADAPT=0
989
+ * disables the tuner; env still seeds the initial knobs.
990
+ */
991
+ export function applySpillCut(msgs, {
992
+ knobs,
993
+ corpusChars = 0,
994
+ boundFiles,
995
+ boundAbs,
996
+ cwd = process.cwd(),
997
+ log = () => {},
998
+ adapt = adaptEnabled(),
999
+ persist = false,
1000
+ file,
1001
+ home,
1002
+ } = {}) {
1003
+ const persistOpts = { persist, file, home };
1004
+ let k = sanitizeKnobs(knobs || getLiveKnobs(persistOpts));
1005
+ let plan = cutTranscript(msgs, k);
1006
+ const empty = {
1007
+ cut: plan.cut,
1008
+ firstSpillable: plan.firstSpillable,
1009
+ lastUser: plan.lastUser,
1010
+ knobs: k,
1011
+ stubbed: { messages: msgs, stubbed: 0, dropped: 0 },
1012
+ sentChars: 0,
1013
+ prefixChars: 0,
1014
+ ratio: 0,
1015
+ action: 'hold',
1016
+ };
1017
+ if (plan.cut <= plan.firstSpillable) {
1018
+ const sentChars = sliceChars(msgs, 0);
1019
+ const corpus = Math.max(Number(corpusChars) || 0, sentChars);
1020
+ return { ...empty, sentChars, ratio: spillRatio(corpus, sentChars) };
1021
+ }
1022
+
1023
+ const measure = (cut, stubbed, knobsNow) => {
1024
+ const prefixChars = sliceChars(msgs, plan.firstSpillable, cut);
1025
+ const sentChars = sliceChars(stubbed.messages, cut);
1026
+ const corpus = Math.max(Number(corpusChars) || 0, prefixChars);
1027
+ return {
1028
+ prefixChars,
1029
+ sentChars,
1030
+ corpusChars: corpus,
1031
+ ratio: spillRatio(corpus, sentChars),
1032
+ knobs: knobsNow,
1033
+ };
1034
+ };
1035
+
1036
+ let stubbed = stubForCut(msgs, plan.cut, { boundFiles, boundAbs, cwd, aggressive: k.stubMore });
1037
+ let stats = measure(plan.cut, stubbed, k);
1038
+ let action = 'hold';
1039
+
1040
+ if (adapt) {
1041
+ const decision = adaptTail({
1042
+ ratio: stats.ratio,
1043
+ knobs: k,
1044
+ lastAction: lastAdaptAction,
1045
+ corpusChars: stats.corpusChars,
1046
+ });
1047
+ k = decision.knobs;
1048
+ action = decision.action;
1049
+ if (decision.recut) {
1050
+ plan = cutTranscript(msgs, k);
1051
+ if (plan.cut > plan.firstSpillable) {
1052
+ stubbed = stubForCut(msgs, plan.cut, { boundFiles, boundAbs, cwd, aggressive: k.stubMore });
1053
+ stats = measure(plan.cut, stubbed, k);
1054
+ }
1055
+ }
1056
+ rememberKnobs(k, persistOpts);
1057
+ lastAdaptAction = action;
1058
+ log(adaptLine({ action, ratio: stats.ratio, knobs: k }));
1059
+ }
1060
+
1061
+ return {
1062
+ cut: plan.cut,
1063
+ firstSpillable: plan.firstSpillable,
1064
+ lastUser: plan.lastUser,
1065
+ knobs: k,
1066
+ stubbed,
1067
+ sentChars: stats.sentChars,
1068
+ prefixChars: stats.prefixChars,
1069
+ ratio: stats.ratio,
1070
+ action,
1071
+ };
1072
+ }
1073
+
613
1074
  /** Session counters the HUD reads off /v1/info. */
614
1075
  export function createSpillStats() {
615
1076
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.48.72",
3
+ "version": "0.48.74",
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",