openzoo 0.49.6 → 0.49.8

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/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.8",
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",