openzoo 0.49.6 → 0.49.7

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/boxes.js CHANGED
@@ -77,7 +77,7 @@ const ENTRYPOINT = [
77
77
  // its HTTP port mappings). The app is read-only at runtime; mutable state
78
78
  // lives in /root/.openzoo.
79
79
  + 'until OPENZOO_BIND=0.0.0.0 node /opt/openzoo/bin/openzoo.js >> /var/log/openzoo/proxy.log 2>&1; do echo "proxy exited, restarting" >> /var/log/openzoo/proxy.log; sleep 2; done & '
80
- + 'until OZ_GROKUI_BIND=0.0.0.0 OZ_GROKUI_PORT=4173 node /opt/grokui/grokui.mjs >> /var/log/openzoo/grokui.log 2>&1; do echo "grokui exited, restarting" >> /var/log/openzoo/grokui.log; sleep 2; done & '
80
+ + 'until OZ_GROKUI_BIND=0.0.0.0 OZ_GROKUI_PORT=4173 node /opt/openzoo/lib/grokui.mjs >> /var/log/openzoo/grokui.log 2>&1; do echo "grokui exited, restarting" >> /var/log/openzoo/grokui.log; sleep 2; done & '
81
81
  // the capture agent answers the ports Grok Bot expects a Cursor sandbox on,
82
82
  // logging the protocol we do not yet speak (see lib/podagent.mjs)
83
83
  + 'if [ -n "$OZ_PODAGENT_B64" ]; then printf %s "$OZ_PODAGENT_B64" | base64 -d > /opt/podagent.mjs; node /opt/podagent.mjs > /var/log/openzoo/agent.log 2>&1 & fi; '
package/lib/models.js CHANGED
@@ -323,10 +323,24 @@ export function isTinyClassify(body, bodyLen) {
323
323
  return messages.length <= CLASSIFY_MAX_MSGS && len < CLASSIFY_MAX_BODY;
324
324
  }
325
325
 
326
+ /**
327
+ * Ids that must never serve Claude Code / grokui AUTO's tiny yes/no classify.
328
+ * REASONING_MODEL_RE is the thinking floor; HEAVY_RE is the flagship set
329
+ * (opus/pro/max/…). opus-5 is openzoo's default session model and does NOT
330
+ * match the reasoning regex, so a catalog that lists opus before flash — or
331
+ * lists only opus + grok — used to pick opus as "first non-reasoner". That
332
+ * classify is a 402 handshake on a big model; AUTO's timeout then hard-blocks
333
+ * Bash instead of prompting.
334
+ */
335
+ function isSlowClassifier(id) {
336
+ const s = String(id || '');
337
+ return REASONING_MODEL_RE.test(s) || HEAVY_RE.test(s);
338
+ }
339
+
326
340
  /**
327
341
  * Fast non-reasoning id that is actually on the zoo. Prefer an explicit
328
342
  * OPENZOO_CLASSIFIER_MODEL, then flash, then haiku, then the first catalog
329
- * id that does not match the reasoning regex.
343
+ * id that is neither a reasoner nor a heavy/flagship (opus/pro/max/…).
330
344
  */
331
345
  export function pickClassifierModel(ids, preferred = process.env.OPENZOO_CLASSIFIER_MODEL) {
332
346
  if (!Array.isArray(ids) || !ids.length) return null;
@@ -334,7 +348,7 @@ export function pickClassifierModel(ids, preferred = process.env.OPENZOO_CLASSIF
334
348
  for (const id of CLASSIFIER_PREFS) {
335
349
  if (ids.includes(id)) return id;
336
350
  }
337
- return ids.find((id) => !REASONING_MODEL_RE.test(id)) || null;
351
+ return ids.find((id) => !isSlowClassifier(id)) || null;
338
352
  }
339
353
 
340
354
  /**
@@ -362,14 +376,22 @@ export function raiseReasoningMaxTokens(parsed, env = process.env) {
362
376
  * Model + max_tokens policy for one chat body.
363
377
  *
364
378
  * Tiny classify: pin to a fast non-reasoning catalog id, leave max_tokens
365
- * alone, ignore OPENZOO_DEFAULT_MODEL. Everything else: resolveModel (which
366
- * honours the default) then the reasoning floor.
379
+ * alone, ignore OPENZOO_DEFAULT_MODEL. Never fall back to `from` when that
380
+ * id is a reasoner or a heavy/flagship (the zoo default is opus-5). A
381
+ * catalog miss or an opus-only list used to keep the classify on opus-5
382
+ * and AUTO hard-blocked Bash. Everything else: resolveModel (which honours
383
+ * the default) then the reasoning floor.
367
384
  */
368
385
  export function rewriteChatModel(parsed, ids, { bodyLen } = {}) {
369
386
  const from = parsed?.model;
370
387
  const len = bodyLen ?? (parsed == null ? 0 : Buffer.byteLength(JSON.stringify(parsed)));
371
388
  if (isTinyClassify(parsed, len)) {
372
- const to = (typeof from === 'string' && pickClassifierModel(ids)) || from;
389
+ const picked = pickClassifierModel(ids);
390
+ // pickClassifierModel returns null on an empty catalog or a zoo that
391
+ // only lists reasoners/heavies. `(picked) || from` left those on
392
+ // anthropic/claude-opus-5 (openzoo's default). Pin to flash instead —
393
+ // never ship a classify body AUTO would time out and hard-block on.
394
+ const to = picked || (typeof from === 'string' && !isSlowClassifier(from) ? from : CLASSIFIER_PREFS[0]);
373
395
  return {
374
396
  parsed: (to && to !== from) ? { ...parsed, model: to } : parsed,
375
397
  tiny: true,
package/lib/proxy.js CHANGED
@@ -14,9 +14,11 @@ 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
- createSpillStats, corpusCharsForSend, applySpillCut, msgText, hudDollarX,
17
+ createSpillStats, corpusCharsForSend, msgText, hudDollarX,
18
18
  spillPricedLine,
19
19
  planConversationBind, rememberSpillMemo, SPILL_CONTENT_ANCHOR_CHARS,
20
+ corpusRecall,
21
+ decideChatSpill, isOneShotCorpusAsk,
20
22
  } from './spill.js';
21
23
  import { rewritablePath, augmentModelList, ALIAS_IDS, rewriteChatModel, zooModelIds, CLASSIFY_MAX_TOKENS } from './models.js';
22
24
  import { forgetContext } from './contexts.js';
@@ -431,40 +433,61 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
431
433
  // in process memory so this request recuts when the green x is under 10.
432
434
  // No restart. OPENZOO_ADAPT=0 freezes the env defaults. The ask always
433
435
  // stays; we never drop below 2 real user/assistant turns to delete it.
436
+ //
437
+ // 1-model AND raced grokui AUTO both land here (same POST /chat/completions
438
+ // door). The old `msgs.length < 6` bail skipped fat 1-model hops � a 40k
439
+ // command-output turn with 4�5 messages never bound, so spent?direct.
440
+ // decideChatSpill is the shared gate: oversized ? bind prefix + system/tail
441
+ // + x-hrr-context; small ? passthrough. Race fields do not change it.
434
442
  const knownLedger = (sessionKey && spillMemo.get(sessionKey))
435
443
  || (sessionKey && sessionLedger.get(sessionKey))
436
444
  || null;
437
445
  const knownChars = knownLedger?.contextId
438
446
  ? (boundChars.get(knownLedger.contextId) || 0)
439
447
  : 0;
440
- const adapted = applySpillCut(msgs, {
448
+ const decision = decideChatSpill(body, {
441
449
  corpusChars: knownChars,
442
450
  boundAbs: previouslyBoundAbs,
451
+ recall: corpusRecall(typeof knownLedger?.corpus === 'string' ? knownLedger.corpus : ''),
443
452
  log,
444
453
  persist: true,
445
454
  dollarX: extra.dollarX ?? hudDollarX(stats || {}),
446
455
  lastSend: extra.lastSend,
456
+ minPrefixChars: BIND_MIN_CHARS,
447
457
  });
448
- if (adapted.cut <= adapted.firstSpillable) {
458
+ if (decision.mode === 'passthrough') {
459
+ bindFilesInBackground(decision.reason === 'no-cut'
460
+ ? 'no severable cut, files only'
461
+ : 'conversation under spill threshold, background');
462
+ return null;
463
+ }
464
+ if (decision.mode === 'stub-only') {
449
465
  bindFilesInBackground('no severable cut, files only');
450
466
  // Still forward stubbed/trimmed bodies so an un-severable storm is not
451
467
  // shipped at full size just because cutTranscript could not move.
452
- if (adapted.stubbed?.dropped || adapted.stubbed?.stubbed) {
453
- return {
454
- body: Buffer.from(JSON.stringify({ ...body, messages: adapted.stubbed.messages })),
455
- corpus: '',
456
- reused: false,
457
- savedBytes: 0,
458
- sent: adapted.stubbed.messages.length,
459
- msgs: msgs.length,
460
- };
461
- }
462
- return null;
468
+ return {
469
+ body: Buffer.from(JSON.stringify({ ...body, messages: decision.forwarded })),
470
+ corpus: '',
471
+ reused: false,
472
+ savedBytes: 0,
473
+ sent: decision.forwarded.length,
474
+ msgs: msgs.length,
475
+ };
476
+ }
477
+ // oneshot is handled in maybeCacheCorpus; if we reach it here, bind the
478
+ // extracted corpus the same way as a transcript prefix.
479
+ if (decision.mode === 'oneshot' && !decision.adapted) {
480
+ decision.adapted = {
481
+ cut: msgs.length - 1,
482
+ firstSpillable: Math.max(0, msgs.findIndex((m) => m?.role !== 'system')),
483
+ stubbed: { messages: decision.forwarded, stubbed: 0, dropped: 0 },
484
+ };
463
485
  }
486
+ const adapted = decision.adapted;
464
487
  const { cut, firstSpillable } = adapted;
465
488
  const stubbed = adapted.stubbed;
466
489
 
467
- const head = msgs.slice(0, firstSpillable); // system block, always kept
490
+ const head = decision.head.length ? decision.head : msgs.slice(0, firstSpillable); // system block, always kept
468
491
  // EVERY FILE THE AGENT TOUCHED, AT FULL SIZE.
469
492
  //
470
493
  // The saving ratio is corpus/sent, so on a fresh session — where the corpus
@@ -493,7 +516,8 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
493
516
  // Nothing recalls a file during the turn that read it � the model already has
494
517
  // the tool result in its window. Files are only worth having bound for the
495
518
  // NEXT ask, so they append after the conversation bind completes.
496
- const turns = msgs.slice(firstSpillable, cut).map(msgText).filter(Boolean).join('\n\n');
519
+ const turns = decision.prefix
520
+ || msgs.slice(firstSpillable, cut).map(msgText).filter(Boolean).join('\n\n');
497
521
  const corpus = turns;
498
522
  if (!sessionKey) sessionKey = corpus.slice(0, SPILL_CONTENT_ANCHOR_CHARS);
499
523
 
@@ -704,7 +728,10 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
704
728
  const topK = Math.max(4, Math.min(12, Math.round(budget / 320)));
705
729
 
706
730
  return {
707
- body: Buffer.from(JSON.stringify({ ...body, messages: [...head, ...stubbed.messages.slice(cut)] })),
731
+ body: Buffer.from(JSON.stringify({
732
+ ...body,
733
+ messages: decision.forwarded || [...head, ...stubbed.messages.slice(cut)],
734
+ })),
708
735
  topK,
709
736
  contextId: bind.contextId,
710
737
  hash: bind.hash,
@@ -740,10 +767,10 @@ async function maybeCacheCorpus(req, bodyBuf, log, stats, extra = {}) {
740
767
  // the ask verbatim. Anything else (an agent transcript) falls through to the
741
768
  // transcript spill, which used to be a silent no-op.
742
769
  const last = msgs[msgs.length - 1];
743
- const oneShot = typeof last?.content === 'string'
744
- && last.content.length > BIND_MIN_CHARS
745
- && last.content.lastIndexOf('\n\n') >= BIND_MIN_CHARS;
746
- if (!oneShot) return spillTranscript(body, log, req, stats, extra);
770
+ // Same gate decideChatSpill uses 1-model and race share it. zoo_ask
771
+ // stays on the corpus+question bind; everything else (including grokui
772
+ // AUTO, raced or not) falls through to spillTranscript ? decideChatSpill.
773
+ if (!isOneShotCorpusAsk(msgs, BIND_MIN_CHARS)) return spillTranscript(body, log, req, stats, extra);
747
774
  const cut = last.content.lastIndexOf('\n\n');
748
775
  const corpus = last.content.slice(0, cut);
749
776
  const ask = last.content.slice(cut + 2).trim();
@@ -933,7 +960,13 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
933
960
  refreshPrices();
934
961
  const money = walletMoney();
935
962
  res.writeHead(200, { 'content-type': 'application/json' });
963
+ // Same package.json the startup banner reads � grokui-app refuses to
964
+ // attach to a leftover :8402 whose version is older than it shipped with.
965
+ const { version } = JSON.parse(
966
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
967
+ );
936
968
  res.end(JSON.stringify({
969
+ version,
937
970
  spentUsd: sessionSpent, cogsUsd: sessionCogs, directUsd: sessionDirect, paidCalls,
938
971
  creditUsd, chainUsd: money.chainUsd, lastQuoteUsd,
939
972
  subscription: subscriptionPublicView(),
package/lib/spill.js CHANGED
@@ -552,6 +552,15 @@ export function hudDollarX({
552
552
  /** Tool result larger than this is "fat" — stub it in the forwarded tail. */
553
553
  export const FAT_TOOL_CHARS = 400;
554
554
 
555
+ /**
556
+ * Over-budget forwarded-tail shrink (SHRINK_OVER). When the tail exceeds
557
+ * this many chars (or the caller `budget`), oldest tool_results are
558
+ * considered for `[bound]` stubs. A stub is emitted only if HRR recall
559
+ * actually returns overlapping bytes for that item — `boundFiles` /
560
+ * `boundAbs` is not proof.
561
+ */
562
+ export const SHRINK_OVER = 6000;
563
+
555
564
  /**
556
565
  * Last-round bodies under this floor stay even after a follow-up ask
557
566
  * (0.48.77: a 461-byte bound Read must remain visible). Older rounds use
@@ -667,6 +676,84 @@ function toolContentLength(content) {
667
676
  return 0;
668
677
  }
669
678
 
679
+ function toolContentText(content) {
680
+ if (typeof content === 'string') return content;
681
+ if (Array.isArray(content)) {
682
+ return content.map((b) => (typeof b === 'string' ? b : String(b?.text ?? b?.content ?? ''))).join('');
683
+ }
684
+ if (content && typeof content === 'object') return JSON.stringify(content);
685
+ return content == null ? '' : String(content);
686
+ }
687
+
688
+ /** Normalize a recall hook result to text. Empty means a miss. */
689
+ export function recallText(got) {
690
+ if (got == null) return '';
691
+ if (typeof got === 'string') return got;
692
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer(got)) return got.toString('utf8');
693
+ if (typeof got === 'object') {
694
+ if (typeof got.text === 'string') return got.text;
695
+ if (typeof got.content === 'string') return got.content;
696
+ if (typeof got.bytes === 'string') return got.bytes;
697
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer(got.bytes)) return got.bytes.toString('utf8');
698
+ }
699
+ return '';
700
+ }
701
+
702
+ /** True when recalled text is non-empty and overlaps the original body. */
703
+ export function recallOverlapsBody(recalled, body) {
704
+ const a = recallText(recalled);
705
+ const b = toolContentText(body);
706
+ if (!a || !b) return false;
707
+ if (b.includes(a) || a.includes(b)) return true;
708
+ const n = Math.min(32, a.length, b.length);
709
+ if (n < 1) return false;
710
+ for (let i = 0; i + n <= a.length; i += Math.max(1, Math.floor(n / 2))) {
711
+ if (b.includes(a.slice(i, i + n))) return true;
712
+ }
713
+ return false;
714
+ }
715
+
716
+ /**
717
+ * Probe `recall` for this tool_result / file. True only when the hook
718
+ * actually produced overlapping bytes — not when we merely think the
719
+ * path is bound.
720
+ */
721
+ export function recallReturnedBytes(recall, item = {}) {
722
+ if (typeof recall !== 'function') return false;
723
+ let got;
724
+ try {
725
+ const content = toolContentText(item.content);
726
+ got = recall({
727
+ id: item.id,
728
+ paths: item.paths || [],
729
+ content,
730
+ query: item.query || (Array.isArray(item.paths) && item.paths[0]) || content.slice(0, 80),
731
+ });
732
+ } catch {
733
+ return false;
734
+ }
735
+ return recallOverlapsBody(got, item.content);
736
+ }
737
+
738
+ /**
739
+ * Local recall over already-bound corpus text. Hard-proof stand-in when a
740
+ * live HRR probe is not available: the exact bytes must be in the corpus
741
+ * AND the probe must return a non-empty overlapping slice.
742
+ */
743
+ export function corpusRecall(corpus) {
744
+ const text = typeof corpus === 'string' ? corpus : '';
745
+ return (item = {}) => {
746
+ if (!text) return '';
747
+ const body = toolContentText(item.content);
748
+ const snippet = body.length >= 16 ? body.slice(0, 64) : body;
749
+ if (snippet && text.includes(snippet)) {
750
+ const i = text.indexOf(snippet);
751
+ return text.slice(i, i + Math.min(Math.max(body.length, snippet.length), 8192));
752
+ }
753
+ return '';
754
+ };
755
+ }
756
+
670
757
  function resolveBoundPath(raw, cwd, boundAbs) {
671
758
  if (!boundAbs?.size) return null;
672
759
  const t = typeof raw === 'string' ? raw.trim() : '';
@@ -679,10 +766,17 @@ function resolveBoundPath(raw, cwd, boundAbs) {
679
766
 
680
767
  /**
681
768
  * After a file is bound, drop its tool_result / file body from the forwarded
682
- * tail. Keep the path and a short marker. The model already has the bytes in
683
- * the bound corpus via recall; shipping them again makes sent ≈ corpus and
684
- * the gateway's `counterfactualTokens > promptTokens` gate barely fires
685
- * (live: 5MB filebind, lastSend 13/107, savingX 1.22 instead of ~7x).
769
+ * tail. Keep the path and a short marker. Shipping those bytes again makes
770
+ * sent corpus and the gateway's `counterfactualTokens > promptTokens`
771
+ * gate barely fires (live: 5MB filebind, lastSend 13/107, savingX 1.22
772
+ * instead of ~7x).
773
+ *
774
+ * A `[bound]` marker is a promise that HRR recall can return those bytes.
775
+ * `boundAbs` / `boundFiles` only means we think we bound the path — that
776
+ * is not proof. On the SHRINK_OVER path (tail over `budget`), and whenever
777
+ * a `recall` hook is installed on a budgeted tail, stub only if recall
778
+ * actually returns non-empty overlapping content. Prefer the original body
779
+ * over a lying stub (context missing, file not in corpus, recall miss).
686
780
  *
687
781
  * Cheap rewrite — no disk I/O. First-read results (not yet in boundAbs) and
688
782
  * non-file tool output stay verbatim UNLESS `aggressive` / `stubMore` or a
@@ -728,6 +822,9 @@ export function stubBoundFileResults(msgs, {
728
822
  // assistant(tool_calls)+results chain. Ignored when the tail is severable.
729
823
  keepTail = null,
730
824
  fatChars = FAT_TOOL_CHARS,
825
+ // (query) => bytes. SHRINK_OVER [bound] stubs require a non-empty
826
+ // overlapping hit. boundFiles alone is not enough.
827
+ recall = null,
731
828
  } = {}) {
732
829
  const absSet = boundAbs || boundAbsFromKeys(boundFiles);
733
830
  const wantBudget = budget != null && Number.isFinite(Number(budget));
@@ -816,6 +913,29 @@ export function stubBoundFileResults(msgs, {
816
913
  return false;
817
914
  };
818
915
 
916
+ const recallCache = new Map();
917
+ const probeRecall = (id, content) => {
918
+ const key = id || toolContentText(content);
919
+ if (recallCache.has(key)) return recallCache.get(key);
920
+ const ok = recallReturnedBytes(recall, {
921
+ id,
922
+ content,
923
+ paths: idPaths.get(id) || [],
924
+ });
925
+ recallCache.set(key, ok);
926
+ return ok;
927
+ };
928
+
929
+ const allowBoundStub = (id, content, { overBudget = false } = {}) => {
930
+ // SHRINK_OVER leftovers always need real recall bytes (fail closed
931
+ // when no hook is installed). When a recall hook is installed on a
932
+ // budgeted tail, every [bound] claim uses the same gate.
933
+ if (overBudget || (wantBudget && typeof recall === 'function')) {
934
+ return probeRecall(id, content);
935
+ }
936
+ return true;
937
+ };
938
+
819
939
  let stubbed = 0;
820
940
  let dropped = 0;
821
941
  let messages = msgs;
@@ -849,6 +969,7 @@ export function stubBoundFileResults(msgs, {
849
969
  if (m.role === 'tool') {
850
970
  const n = toolContentLength(m.content);
851
971
  if (!shouldStubBody(m.tool_call_id, n) || isStubText(m.content)) return m;
972
+ if (!allowBoundStub(m.tool_call_id, m.content)) return m;
852
973
  dropped += n;
853
974
  stubbed += 1;
854
975
  return { ...m, content: stubFor(m.tool_call_id, n) };
@@ -882,6 +1003,7 @@ export function stubBoundFileResults(msgs, {
882
1003
  if (b?.type !== 'tool_result') return b;
883
1004
  const n = toolContentLength(b.content);
884
1005
  if (!shouldStubBody(b.tool_use_id, n) || isStubText(b.content)) return b;
1006
+ if (!allowBoundStub(b.tool_use_id, b.content)) return b;
885
1007
  dropped += n;
886
1008
  stubbed += 1;
887
1009
  changed = true;
@@ -890,11 +1012,12 @@ export function stubBoundFileResults(msgs, {
890
1012
  return changed ? { ...next, content: blocks } : next;
891
1013
  });
892
1014
 
893
- // Byte budget wins inside a tool chain. cutTranscript cannot move tailStart
894
- // past assistant(tool_calls) / role:tool (pairing 400s the provider), so a
895
- // 300-result storm used to ride in at ~728k after file-only stubs. Stub
896
- // bodies first, then fat tool_call JSON; if still over, drop older pairs.
897
- // Never drop the ask. Never orphan a remaining tool_result.
1015
+ // SHRINK_OVER: byte budget wins inside a tool chain. cutTranscript cannot
1016
+ // move tailStart past assistant(tool_calls) / role:tool (pairing 400s the
1017
+ // provider), so a 300-result storm used to ride in at ~728k after file-only
1018
+ // stubs. Stub bodies first, then fat tool_call JSON; if still over, drop
1019
+ // older pairs. Never drop the ask. Never orphan a remaining tool_result.
1020
+ // Never write `[bound]` unless recall returned those bytes.
898
1021
  if (wantBudget) {
899
1022
  const cap = Number(budget);
900
1023
  let used = sliceChars(messages, fromIndex);
@@ -908,6 +1031,7 @@ export function stubBoundFileResults(msgs, {
908
1031
  if (isStubText(m.content)) continue;
909
1032
  const n = toolContentLength(m.content);
910
1033
  if (!shouldStubBody(m.tool_call_id, n, { overBudget: true })) continue;
1034
+ if (!allowBoundStub(m.tool_call_id, m.content, { overBudget: true })) continue;
911
1035
  const stub = stubFor(m.tool_call_id, n);
912
1036
  if (stub.length >= n) continue;
913
1037
  used = used - n + stub.length;
@@ -941,6 +1065,7 @@ export function stubBoundFileResults(msgs, {
941
1065
  if (b?.type !== 'tool_result' || used <= cap || isStubText(b.content)) return b;
942
1066
  const n = toolContentLength(b.content);
943
1067
  if (!shouldStubBody(b.tool_use_id, n, { overBudget: true })) return b;
1068
+ if (!allowBoundStub(b.tool_use_id, b.content, { overBudget: true })) return b;
944
1069
  const stub = stubFor(b.tool_use_id, n);
945
1070
  if (stub.length >= n) return b;
946
1071
  used = used - n + stub.length;
@@ -984,7 +1109,7 @@ export const LAST_SEND_TIGHTEN = 24;
984
1109
  export const KNOB_DEFAULTS = Object.freeze({
985
1110
  keepTail: 8,
986
1111
  minTurns: 6,
987
- budget: 6000,
1112
+ budget: SHRINK_OVER,
988
1113
  stubMore: false,
989
1114
  });
990
1115
 
@@ -1397,6 +1522,8 @@ export function cutTranscript(msgs, knobs = {}) {
1397
1522
  const minTurns = Math.max(2, k.minTurns);
1398
1523
  const budget = k.budget;
1399
1524
 
1525
+ const lastUser = lastUserAskIndex(msgs, firstSpillable);
1526
+
1400
1527
  let cut = -1;
1401
1528
  for (let i = msgs.length - keepTail; i > firstSpillable; i--) {
1402
1529
  if (isSeverable(msgs, i, firstSpillable)) { cut = i; break; }
@@ -1407,7 +1534,7 @@ export function cutTranscript(msgs, knobs = {}) {
1407
1534
  }
1408
1535
  }
1409
1536
  if (cut <= firstSpillable) {
1410
- return { cut: -1, firstSpillable, lastUser: lastUserAskIndex(msgs, firstSpillable), knobs: k };
1537
+ return { cut: -1, firstSpillable, lastUser, knobs: k };
1411
1538
  }
1412
1539
 
1413
1540
  // Only moves the cut at a severable index. A current-turn tool storm
@@ -1415,27 +1542,44 @@ export function cutTranscript(msgs, knobs = {}) {
1415
1542
  // index inside the chain, so this walk is a no-op — the byte budget is
1416
1543
  // applied by stubbing bodies in stubBoundFileResults, not by orphaning
1417
1544
  // a tool_result.
1545
+ //
1546
+ // When the overflow IS severable (grokui text hops: a fat previous
1547
+ // command output sitting just before the current ask), start the tail
1548
+ // AFTER that message so the live window is actually bounded. Starting
1549
+ // AT the overflowing index left the fat hop in the forwarded body —
1550
+ // spent≈direct on 1-model AUTO even after a cut existed.
1418
1551
  let tailStart = cut;
1419
1552
  {
1420
1553
  let used = 0;
1421
1554
  for (let i = msgs.length - 1; i >= cut; i--) {
1422
1555
  used += msgText(msgs[i]).length;
1423
- if (used > budget && isSeverable(msgs, i, firstSpillable)) { tailStart = i; break; }
1556
+ if (used <= budget) continue;
1557
+ const after = i + 1;
1558
+ // Plain user/assistant hops only. Jumping after a tool_result would
1559
+ // drop the current tool chain from the tail (orphans / lost pairing);
1560
+ // those storms stay in-window and get stubbed.
1561
+ const overflow = msgs[i];
1562
+ const plainHop = overflow?.role === 'user' || (overflow?.role === 'assistant' && !toolCallIds(overflow).length);
1563
+ if (plainHop && lastUser >= 0 && after <= lastUser && after > firstSpillable
1564
+ && isSeverable(msgs, after, firstSpillable)) {
1565
+ tailStart = after;
1566
+ break;
1567
+ }
1568
+ if (isSeverable(msgs, i, firstSpillable)) { tailStart = i; break; }
1424
1569
  }
1425
1570
  }
1426
1571
  if (tailStart > cut) cut = tailStart;
1427
1572
 
1428
- const lastUser = lastUserAskIndex(msgs, firstSpillable);
1429
-
1430
- // minTurns may still size the tail on a long thread. If walking earlier
1431
- // would empty the bind prefix (the old firstSpillable+1 fallback), keep a
1432
- // non-empty prefix and allow fewer than minTurns in the tail. On a short
1433
- // thread that cannot satisfy minTurns at all, pin the last ask so early
1434
- // user/assistant turns bind instead of riding in the forwarded tail.
1573
+ // minTurns may still size the tail on a long thread, but never past the
1574
+ // byte budget (that pulled fat 1-model hops back so sent≈unspilled).
1575
+ // If walking earlier would empty the bind prefix, keep a non-empty prefix
1576
+ // and allow fewer than minTurns. On a short thread that cannot satisfy
1577
+ // minTurns at all, pin the last ask so early turns bind.
1435
1578
  if (countRealTurns(msgs, cut) < minTurns) {
1436
1579
  let moved = false;
1437
1580
  for (let i = cut - 1; i > firstSpillable; i--) {
1438
1581
  if (isSeverable(msgs, i, firstSpillable) && countRealTurns(msgs, i) >= minTurns) {
1582
+ if (sliceChars(msgs, i) > budget) break;
1439
1583
  cut = i;
1440
1584
  moved = true;
1441
1585
  break;
@@ -1450,11 +1594,13 @@ export function cutTranscript(msgs, knobs = {}) {
1450
1594
 
1451
1595
  // 2-real-turn floor protects the last user ask: never drop it, and expand
1452
1596
  // the tail to two turns on a LONG thread. Do not steal the first
1453
- // user+assistant pair from the prefix just to pad a short tail.
1597
+ // user+assistant pair from the prefix just to pad a short tail, and do
1598
+ // not expand past the byte budget (that re-imported fat 1-model hops).
1454
1599
  if (lastUser > firstSpillable && countRealTurns(msgs, cut) < 2) {
1455
1600
  for (let i = cut - 1; i > firstSpillable; i--) {
1456
1601
  if (!isSeverable(msgs, i, firstSpillable) || countRealTurns(msgs, i) < 2) continue;
1457
1602
  if (countRealTurns(msgs, firstSpillable, i) < 2) continue;
1603
+ if (sliceChars(msgs, i) > budget) break;
1458
1604
  cut = i;
1459
1605
  break;
1460
1606
  }
@@ -1588,6 +1734,7 @@ function stubForCut(msgs, cut, opts) {
1588
1734
  aggressive: Boolean(opts.aggressive),
1589
1735
  budget: opts.budget,
1590
1736
  keepTail: opts.keepTail,
1737
+ recall: opts.recall,
1591
1738
  });
1592
1739
  }
1593
1740
 
@@ -1611,6 +1758,7 @@ export function applySpillCut(msgs, {
1611
1758
  home,
1612
1759
  dollarX,
1613
1760
  lastSend,
1761
+ recall,
1614
1762
  } = {}) {
1615
1763
  const persistOpts = { persist, file, home };
1616
1764
  let k = sanitizeKnobs(knobs || getLiveKnobs(persistOpts));
@@ -1631,7 +1779,7 @@ export function applySpillCut(msgs, {
1631
1779
  // 300-result storm is not forwarded at full size.
1632
1780
  const stubFrom = plan.firstSpillable >= 0 ? plan.firstSpillable : 0;
1633
1781
  let stubbed = stubForCut(msgs, stubFrom, {
1634
- boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail,
1782
+ boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail, recall,
1635
1783
  });
1636
1784
  let sentChars = sliceChars(stubbed.messages, stubFrom);
1637
1785
  const corpus = Math.max(Number(corpusChars) || 0, sentChars);
@@ -1651,7 +1799,7 @@ export function applySpillCut(msgs, {
1651
1799
  action = decision.action;
1652
1800
  if (decision.recut) {
1653
1801
  stubbed = stubForCut(msgs, stubFrom, {
1654
- boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail,
1802
+ boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail, recall,
1655
1803
  });
1656
1804
  sentChars = sliceChars(stubbed.messages, stubFrom);
1657
1805
  ratio = spillRatio(corpus, sentChars);
@@ -1677,7 +1825,7 @@ export function applySpillCut(msgs, {
1677
1825
  };
1678
1826
 
1679
1827
  let stubbed = stubForCut(msgs, plan.cut, {
1680
- boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail,
1828
+ boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail, recall,
1681
1829
  });
1682
1830
  let stats = measure(plan.cut, stubbed, k);
1683
1831
  let action = 'hold';
@@ -1698,7 +1846,7 @@ export function applySpillCut(msgs, {
1698
1846
  plan = cutTranscript(msgs, k);
1699
1847
  if (plan.cut > plan.firstSpillable) {
1700
1848
  stubbed = stubForCut(msgs, plan.cut, {
1701
- boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail,
1849
+ boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail, recall,
1702
1850
  });
1703
1851
  stats = measure(plan.cut, stubbed, k);
1704
1852
  }
@@ -1721,6 +1869,119 @@ export function applySpillCut(msgs, {
1721
1869
  };
1722
1870
  }
1723
1871
 
1872
+ /**
1873
+ * Same threshold hrr.js BIND_MIN_CHARS uses. Kept here so the 1-model /
1874
+ * raced AUTO decision can be tested without standing up the bind client.
1875
+ */
1876
+ export const SPILL_MIN_PREFIX_CHARS = Number(process.env.OPENZOO_CONTEXT_MIN_CHARS || 16384);
1877
+
1878
+ /**
1879
+ * zoo_ask / chat-surface shape: one huge final string ending in `\n\n<ask>`.
1880
+ * Agent transcripts (Claude Code, grokui) never look like this.
1881
+ */
1882
+ export function isOneShotCorpusAsk(msgs, minChars = SPILL_MIN_PREFIX_CHARS) {
1883
+ if (!Array.isArray(msgs) || !msgs.length) return false;
1884
+ const last = msgs[msgs.length - 1];
1885
+ if (typeof last?.content !== 'string') return false;
1886
+ if (last.content.length <= minChars) return false;
1887
+ if (last.content.lastIndexOf('\n\n') < minChars) return false;
1888
+ const ask = last.content.slice(last.content.lastIndexOf('\n\n') + 2).trim();
1889
+ return Boolean(ask && ask.length <= 8000);
1890
+ }
1891
+
1892
+ function emptySpillDecision(msgs, reason) {
1893
+ return {
1894
+ mode: 'passthrough',
1895
+ reason,
1896
+ setHrrContext: false,
1897
+ forwarded: msgs,
1898
+ prefix: '',
1899
+ head: [],
1900
+ tail: msgs,
1901
+ sentChars: sliceChars(msgs),
1902
+ unspilledChars: sliceChars(msgs),
1903
+ };
1904
+ }
1905
+
1906
+ /**
1907
+ * THE spill/bind/forward gate. 1-model grokui and raced grokui AUTO both
1908
+ * POST /chat/completions through maybeCacheCorpus → this decision. `race`,
1909
+ * `race_need`, and `tier` on the body are ignored — the cut is a property
1910
+ * of messages[], not of how many models will read them.
1911
+ *
1912
+ * Oversized: bind the old prefix, forward system + bounded tail, and the
1913
+ * sidecar must set x-hrr-context (setHrrContext). Small: passthrough,
1914
+ * no bind, no header. Does not talk to the network.
1915
+ */
1916
+ export function decideChatSpill(body, opts = {}) {
1917
+ const msgs = Array.isArray(body?.messages) ? body.messages : null;
1918
+ if (!msgs?.length) return emptySpillDecision(msgs, 'no-messages');
1919
+ const minChars = opts.minPrefixChars ?? SPILL_MIN_PREFIX_CHARS;
1920
+
1921
+ if (isOneShotCorpusAsk(msgs, minChars)) {
1922
+ const last = msgs[msgs.length - 1];
1923
+ const at = last.content.lastIndexOf('\n\n');
1924
+ const prefix = last.content.slice(0, at);
1925
+ const ask = last.content.slice(at + 2).trim();
1926
+ const forwarded = [...msgs.slice(0, -1), { ...last, content: ask }];
1927
+ return {
1928
+ mode: 'oneshot',
1929
+ reason: 'corpus-question',
1930
+ setHrrContext: true,
1931
+ prefix,
1932
+ ask,
1933
+ forwarded,
1934
+ head: msgs.slice(0, -1),
1935
+ tail: [{ ...last, content: ask }],
1936
+ sentChars: sliceChars(forwarded),
1937
+ unspilledChars: sliceChars(msgs),
1938
+ };
1939
+ }
1940
+
1941
+ const adapted = applySpillCut(msgs, opts);
1942
+ if (adapted.cut <= adapted.firstSpillable) {
1943
+ if (adapted.stubbed?.dropped || adapted.stubbed?.stubbed) {
1944
+ return {
1945
+ mode: 'stub-only',
1946
+ reason: 'no-cut-stubbed',
1947
+ setHrrContext: false,
1948
+ adapted,
1949
+ forwarded: adapted.stubbed.messages,
1950
+ prefix: '',
1951
+ head: [],
1952
+ tail: adapted.stubbed.messages,
1953
+ sentChars: sliceChars(adapted.stubbed.messages),
1954
+ unspilledChars: sliceChars(msgs),
1955
+ };
1956
+ }
1957
+ return { ...emptySpillDecision(msgs, 'no-cut'), adapted };
1958
+ }
1959
+
1960
+ const prefix = msgs.slice(adapted.firstSpillable, adapted.cut)
1961
+ .map(msgText).filter(Boolean).join('\n\n');
1962
+ if (prefix.length <= minChars) {
1963
+ return { ...emptySpillDecision(msgs, 'prefix-under-threshold'), adapted, prefix };
1964
+ }
1965
+
1966
+ const head = msgs.slice(0, adapted.firstSpillable);
1967
+ const tail = adapted.stubbed.messages.slice(adapted.cut);
1968
+ const forwarded = [...head, ...tail];
1969
+ return {
1970
+ mode: 'spill',
1971
+ reason: 'oversized-prefix',
1972
+ setHrrContext: true,
1973
+ adapted,
1974
+ prefix,
1975
+ head,
1976
+ tail,
1977
+ forwarded,
1978
+ cut: adapted.cut,
1979
+ firstSpillable: adapted.firstSpillable,
1980
+ sentChars: sliceChars(forwarded),
1981
+ unspilledChars: sliceChars(msgs),
1982
+ };
1983
+ }
1984
+
1724
1985
  /** Session counters the HUD reads off /v1/info. */
1725
1986
  export function createSpillStats() {
1726
1987
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.49.6",
3
+ "version": "0.49.7",
4
4
  "description": "Local x402-paying proxy + MCP server for openzoo.fun — 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",