openzoo 0.49.5 → 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/spill.js CHANGED
@@ -118,6 +118,31 @@ export function corpusCharsForSend(boundChars, contextId, thisTurn) {
118
118
  return Math.max(boundChars.get(contextId) || 0, thisTurn || 0);
119
119
  }
120
120
 
121
+ /**
122
+ * Pricing / unspilled basis: the unspilled size, never the shrunken sent
123
+ * (tokensAfter) size. Same unit on both args — tokens or chars, not mixed.
124
+ */
125
+ export function unspilledBasis({ tokensBefore, corpus } = {}) {
126
+ return Math.max(Number(tokensBefore) || 0, Number(corpus) || 0);
127
+ }
128
+
129
+ /**
130
+ * Gateway counterfactual the tell-line must print. Missing / non-finite /
131
+ * non-positive → null (`basis ?`). Never reads lecore.corpusTokens.
132
+ */
133
+ export function spillPricedTellBasis(x) {
134
+ const n = Number(x?.counterfactualTokensUsed);
135
+ return Number.isFinite(n) && n > 0 ? n : null;
136
+ }
137
+
138
+ /** Exact `spill priced:` / `spill priced (streamed):` line the proxy logs. */
139
+ export function spillPricedLine(x, { streamed = false } = {}) {
140
+ const lc = x?.lecore || {};
141
+ const basis = spillPricedTellBasis(x);
142
+ const prefix = streamed ? 'spill priced (streamed):' : 'spill priced:';
143
+ return `${prefix} ${x?.pricing} · basis ${basis ?? '?'} tok vs sent ${lc.tokensBefore ?? '?'} -> ${lc.tokensAfter ?? '?'} · billed ${(x?.billedUsd ?? 0).toFixed(5)} direct ${(x?.directUsd ?? 0).toFixed(5)}`;
144
+ }
145
+
121
146
  /** Expand ~ and resolve relative paths against cwd. Returns null if unusable. */
122
147
  export function resolveReadablePath(p, cwd = process.cwd()) {
123
148
  if (typeof p !== 'string') return null;
@@ -527,6 +552,15 @@ export function hudDollarX({
527
552
  /** Tool result larger than this is "fat" — stub it in the forwarded tail. */
528
553
  export const FAT_TOOL_CHARS = 400;
529
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
+
530
564
  /**
531
565
  * Last-round bodies under this floor stay even after a follow-up ask
532
566
  * (0.48.77: a 461-byte bound Read must remain visible). Older rounds use
@@ -642,6 +676,84 @@ function toolContentLength(content) {
642
676
  return 0;
643
677
  }
644
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
+
645
757
  function resolveBoundPath(raw, cwd, boundAbs) {
646
758
  if (!boundAbs?.size) return null;
647
759
  const t = typeof raw === 'string' ? raw.trim() : '';
@@ -654,10 +766,17 @@ function resolveBoundPath(raw, cwd, boundAbs) {
654
766
 
655
767
  /**
656
768
  * After a file is bound, drop its tool_result / file body from the forwarded
657
- * tail. Keep the path and a short marker. The model already has the bytes in
658
- * the bound corpus via recall; shipping them again makes sent ≈ corpus and
659
- * the gateway's `counterfactualTokens > promptTokens` gate barely fires
660
- * (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).
661
780
  *
662
781
  * Cheap rewrite — no disk I/O. First-read results (not yet in boundAbs) and
663
782
  * non-file tool output stay verbatim UNLESS `aggressive` / `stubMore` or a
@@ -703,6 +822,9 @@ export function stubBoundFileResults(msgs, {
703
822
  // assistant(tool_calls)+results chain. Ignored when the tail is severable.
704
823
  keepTail = null,
705
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,
706
828
  } = {}) {
707
829
  const absSet = boundAbs || boundAbsFromKeys(boundFiles);
708
830
  const wantBudget = budget != null && Number.isFinite(Number(budget));
@@ -791,6 +913,29 @@ export function stubBoundFileResults(msgs, {
791
913
  return false;
792
914
  };
793
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
+
794
939
  let stubbed = 0;
795
940
  let dropped = 0;
796
941
  let messages = msgs;
@@ -824,6 +969,7 @@ export function stubBoundFileResults(msgs, {
824
969
  if (m.role === 'tool') {
825
970
  const n = toolContentLength(m.content);
826
971
  if (!shouldStubBody(m.tool_call_id, n) || isStubText(m.content)) return m;
972
+ if (!allowBoundStub(m.tool_call_id, m.content)) return m;
827
973
  dropped += n;
828
974
  stubbed += 1;
829
975
  return { ...m, content: stubFor(m.tool_call_id, n) };
@@ -857,6 +1003,7 @@ export function stubBoundFileResults(msgs, {
857
1003
  if (b?.type !== 'tool_result') return b;
858
1004
  const n = toolContentLength(b.content);
859
1005
  if (!shouldStubBody(b.tool_use_id, n) || isStubText(b.content)) return b;
1006
+ if (!allowBoundStub(b.tool_use_id, b.content)) return b;
860
1007
  dropped += n;
861
1008
  stubbed += 1;
862
1009
  changed = true;
@@ -865,11 +1012,12 @@ export function stubBoundFileResults(msgs, {
865
1012
  return changed ? { ...next, content: blocks } : next;
866
1013
  });
867
1014
 
868
- // Byte budget wins inside a tool chain. cutTranscript cannot move tailStart
869
- // past assistant(tool_calls) / role:tool (pairing 400s the provider), so a
870
- // 300-result storm used to ride in at ~728k after file-only stubs. Stub
871
- // bodies first, then fat tool_call JSON; if still over, drop older pairs.
872
- // 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.
873
1021
  if (wantBudget) {
874
1022
  const cap = Number(budget);
875
1023
  let used = sliceChars(messages, fromIndex);
@@ -883,6 +1031,7 @@ export function stubBoundFileResults(msgs, {
883
1031
  if (isStubText(m.content)) continue;
884
1032
  const n = toolContentLength(m.content);
885
1033
  if (!shouldStubBody(m.tool_call_id, n, { overBudget: true })) continue;
1034
+ if (!allowBoundStub(m.tool_call_id, m.content, { overBudget: true })) continue;
886
1035
  const stub = stubFor(m.tool_call_id, n);
887
1036
  if (stub.length >= n) continue;
888
1037
  used = used - n + stub.length;
@@ -916,6 +1065,7 @@ export function stubBoundFileResults(msgs, {
916
1065
  if (b?.type !== 'tool_result' || used <= cap || isStubText(b.content)) return b;
917
1066
  const n = toolContentLength(b.content);
918
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;
919
1069
  const stub = stubFor(b.tool_use_id, n);
920
1070
  if (stub.length >= n) return b;
921
1071
  used = used - n + stub.length;
@@ -959,7 +1109,7 @@ export const LAST_SEND_TIGHTEN = 24;
959
1109
  export const KNOB_DEFAULTS = Object.freeze({
960
1110
  keepTail: 8,
961
1111
  minTurns: 6,
962
- budget: 6000,
1112
+ budget: SHRINK_OVER,
963
1113
  stubMore: false,
964
1114
  });
965
1115
 
@@ -1256,9 +1406,9 @@ function lastUserAskIndex(msgs, firstSpillable) {
1256
1406
  return -1;
1257
1407
  }
1258
1408
 
1259
- function countRealTurns(msgs, from) {
1409
+ function countRealTurns(msgs, from, to = msgs.length) {
1260
1410
  let n = 0;
1261
- for (let i = from; i < msgs.length; i++) {
1411
+ for (let i = from; i < to; i++) {
1262
1412
  const r = msgs[i]?.role;
1263
1413
  if (r === 'user' || r === 'assistant') n += 1;
1264
1414
  }
@@ -1356,7 +1506,9 @@ export function trimUnseverablePairs(msgs, {
1356
1506
 
1357
1507
  /**
1358
1508
  * Pick a severable cut: keep a recent tail, honour the byte budget, floor
1359
- * at minTurns of user/assistant, and never drop the last user ask.
1509
+ * at minTurns of user/assistant on a LONG thread, and never drop the last
1510
+ * user ask. minTurns must not empty the bind prefix — a short AUTO thread
1511
+ * binds early turns and may forward a tail with fewer than minTurns.
1360
1512
  */
1361
1513
  export function cutTranscript(msgs, knobs = {}) {
1362
1514
  const k = sanitizeKnobs({ ...envKnobs(), ...knobs });
@@ -1370,6 +1522,8 @@ export function cutTranscript(msgs, knobs = {}) {
1370
1522
  const minTurns = Math.max(2, k.minTurns);
1371
1523
  const budget = k.budget;
1372
1524
 
1525
+ const lastUser = lastUserAskIndex(msgs, firstSpillable);
1526
+
1373
1527
  let cut = -1;
1374
1528
  for (let i = msgs.length - keepTail; i > firstSpillable; i--) {
1375
1529
  if (isSeverable(msgs, i, firstSpillable)) { cut = i; break; }
@@ -1380,7 +1534,7 @@ export function cutTranscript(msgs, knobs = {}) {
1380
1534
  }
1381
1535
  }
1382
1536
  if (cut <= firstSpillable) {
1383
- return { cut: -1, firstSpillable, lastUser: lastUserAskIndex(msgs, firstSpillable), knobs: k };
1537
+ return { cut: -1, firstSpillable, lastUser, knobs: k };
1384
1538
  }
1385
1539
 
1386
1540
  // Only moves the cut at a severable index. A current-turn tool storm
@@ -1388,31 +1542,67 @@ export function cutTranscript(msgs, knobs = {}) {
1388
1542
  // index inside the chain, so this walk is a no-op — the byte budget is
1389
1543
  // applied by stubbing bodies in stubBoundFileResults, not by orphaning
1390
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.
1391
1551
  let tailStart = cut;
1392
1552
  {
1393
1553
  let used = 0;
1394
1554
  for (let i = msgs.length - 1; i >= cut; i--) {
1395
1555
  used += msgText(msgs[i]).length;
1396
- 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; }
1397
1569
  }
1398
1570
  }
1399
1571
  if (tailStart > cut) cut = tailStart;
1400
1572
 
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.
1401
1578
  if (countRealTurns(msgs, cut) < minTurns) {
1579
+ let moved = false;
1402
1580
  for (let i = cut - 1; i > firstSpillable; i--) {
1403
- if (isSeverable(msgs, i, firstSpillable) && countRealTurns(msgs, i) >= minTurns) { cut = i; break; }
1404
- if (i === firstSpillable + 1) { if (isSeverable(msgs, i, firstSpillable)) cut = i; break; }
1581
+ if (isSeverable(msgs, i, firstSpillable) && countRealTurns(msgs, i) >= minTurns) {
1582
+ if (sliceChars(msgs, i) > budget) break;
1583
+ cut = i;
1584
+ moved = true;
1585
+ break;
1586
+ }
1587
+ }
1588
+ if (!moved && lastUser > firstSpillable && isSeverable(msgs, lastUser, firstSpillable)) {
1589
+ cut = lastUser;
1405
1590
  }
1406
1591
  }
1407
1592
 
1408
- const lastUser = lastUserAskIndex(msgs, firstSpillable);
1409
1593
  if (lastUser > firstSpillable && cut > lastUser) cut = lastUser;
1410
1594
 
1411
- // Never shrink below 2 real turns when that would drop the ask — the ask
1412
- // always stays; expand earlier only if two turns exist and remain after it.
1595
+ // 2-real-turn floor protects the last user ask: never drop it, and expand
1596
+ // the tail to two turns on a LONG thread. Do not steal the first
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).
1413
1599
  if (lastUser > firstSpillable && countRealTurns(msgs, cut) < 2) {
1414
1600
  for (let i = cut - 1; i > firstSpillable; i--) {
1415
- if (isSeverable(msgs, i, firstSpillable) && countRealTurns(msgs, i) >= 2) { cut = i; break; }
1601
+ if (!isSeverable(msgs, i, firstSpillable) || countRealTurns(msgs, i) < 2) continue;
1602
+ if (countRealTurns(msgs, firstSpillable, i) < 2) continue;
1603
+ if (sliceChars(msgs, i) > budget) break;
1604
+ cut = i;
1605
+ break;
1416
1606
  }
1417
1607
  if (cut > lastUser) cut = lastUser;
1418
1608
  }
@@ -1420,6 +1610,121 @@ export function cutTranscript(msgs, knobs = {}) {
1420
1610
  return { cut, firstSpillable, lastUser, knobs: k };
1421
1611
  }
1422
1612
 
1613
+ /** Opening slice used as a content-anchor when no session header is sent. */
1614
+ export const SPILL_CONTENT_ANCHOR_CHARS = 2048;
1615
+
1616
+ /**
1617
+ * Find a memoized / ledger bind for this request. Prefers an explicit
1618
+ * session key; otherwise matches a stored prefix so a growing content-anchor
1619
+ * (grokui AUTO sends no session header) still recalls the same contextId.
1620
+ */
1621
+ export function lookupSpillMemo(spillMemo, sessionLedger, { sessionKey, corpus } = {}) {
1622
+ if (sessionKey && spillMemo?.has(sessionKey)) {
1623
+ return { key: sessionKey, source: 'memo', ...spillMemo.get(sessionKey) };
1624
+ }
1625
+ if (sessionKey && sessionLedger?.has(sessionKey)) {
1626
+ const led = sessionLedger.get(sessionKey);
1627
+ if (led?.contextId) return { key: sessionKey, source: 'ledger', restored: !led.corpus, ...led };
1628
+ }
1629
+ if (corpus && spillMemo) {
1630
+ for (const [k, v] of spillMemo) {
1631
+ if (typeof v?.corpus === 'string' && v.corpus.length && corpus.startsWith(v.corpus)) {
1632
+ return { key: k, source: 'memo-prefix', ...v };
1633
+ }
1634
+ }
1635
+ const opening = corpus.slice(0, SPILL_CONTENT_ANCHOR_CHARS);
1636
+ for (const [k, v] of spillMemo) {
1637
+ if (typeof k !== 'string' || k.startsWith('sid:')) continue;
1638
+ if (opening.startsWith(k) || (k && k.startsWith(opening))) {
1639
+ return { key: k, source: 'memo-anchor', ...v };
1640
+ }
1641
+ }
1642
+ }
1643
+ return null;
1644
+ }
1645
+
1646
+ export function rememberSpillMemo(spillMemo, key, entry, { max = 32 } = {}) {
1647
+ if (!spillMemo || !key) return;
1648
+ spillMemo.set(key, entry);
1649
+ while (spillMemo.size > max) spillMemo.delete(spillMemo.keys().next().value);
1650
+ }
1651
+
1652
+ /**
1653
+ * Decide first-bind vs later-turn recall. No I/O — bindCorpus is injected
1654
+ * by the caller (or mocked in tests).
1655
+ *
1656
+ * cold-bind — fire-and-forget first bind; this turn may go unspilled
1657
+ * await-pending — later turn while that bind is in flight; await, then tail
1658
+ * recall — contextId known; send tail (+ optional delta append)
1659
+ */
1660
+ export function planConversationBind({ sessionKey, corpus, spillMemo, sessionLedger } = {}) {
1661
+ const key = sessionKey || (corpus ? corpus.slice(0, SPILL_CONTENT_ANCHOR_CHARS) : null);
1662
+ const prior = lookupSpillMemo(spillMemo, sessionLedger, { sessionKey: key, corpus });
1663
+ if (!prior) {
1664
+ return { action: 'cold-bind', send: 'full', key, corpus };
1665
+ }
1666
+ if (prior.pending && !prior.contextId) {
1667
+ return {
1668
+ action: 'await-pending',
1669
+ send: 'tail',
1670
+ key: prior.key || key,
1671
+ corpus,
1672
+ ready: prior.ready,
1673
+ };
1674
+ }
1675
+ if (prior.contextId) {
1676
+ let delta = '';
1677
+ let append = false;
1678
+ if (prior.restored) {
1679
+ append = true;
1680
+ } else if (typeof prior.corpus === 'string' && corpus.startsWith(prior.corpus) && corpus.length > prior.corpus.length) {
1681
+ delta = corpus.slice(prior.corpus.length);
1682
+ append = true;
1683
+ }
1684
+ return {
1685
+ action: 'recall',
1686
+ send: 'tail',
1687
+ key: prior.key || key,
1688
+ corpus,
1689
+ contextId: prior.contextId,
1690
+ hash: prior.hash,
1691
+ reused: true,
1692
+ append,
1693
+ delta,
1694
+ restored: Boolean(prior.restored),
1695
+ };
1696
+ }
1697
+ return { action: 'cold-bind', send: 'full', key, corpus };
1698
+ }
1699
+
1700
+ /**
1701
+ * Cut the transcript and plan the conversation bind. Tests use this instead
1702
+ * of standing up the proxy or a live gateway.
1703
+ */
1704
+ export function planTranscriptSpill(msgs, {
1705
+ knobs,
1706
+ sessionKey,
1707
+ spillMemo,
1708
+ sessionLedger,
1709
+ corpusChars = 0,
1710
+ adapt = false,
1711
+ persist = false,
1712
+ ...cutOpts
1713
+ } = {}) {
1714
+ const adapted = applySpillCut(msgs, {
1715
+ knobs,
1716
+ corpusChars,
1717
+ adapt,
1718
+ persist,
1719
+ ...cutOpts,
1720
+ });
1721
+ const empty = { ...adapted, corpus: '', bindPlan: null };
1722
+ if (adapted.cut <= adapted.firstSpillable) return empty;
1723
+ const corpus = msgs.slice(adapted.firstSpillable, adapted.cut).map(msgText).filter(Boolean).join('\n\n');
1724
+ const bindPlan = planConversationBind({ sessionKey, corpus, spillMemo, sessionLedger });
1725
+ return { ...adapted, corpus, bindPlan };
1726
+ }
1727
+
1423
1728
  function stubForCut(msgs, cut, opts) {
1424
1729
  return stubBoundFileResults(msgs, {
1425
1730
  boundFiles: opts.boundFiles,
@@ -1429,6 +1734,7 @@ function stubForCut(msgs, cut, opts) {
1429
1734
  aggressive: Boolean(opts.aggressive),
1430
1735
  budget: opts.budget,
1431
1736
  keepTail: opts.keepTail,
1737
+ recall: opts.recall,
1432
1738
  });
1433
1739
  }
1434
1740
 
@@ -1452,6 +1758,7 @@ export function applySpillCut(msgs, {
1452
1758
  home,
1453
1759
  dollarX,
1454
1760
  lastSend,
1761
+ recall,
1455
1762
  } = {}) {
1456
1763
  const persistOpts = { persist, file, home };
1457
1764
  let k = sanitizeKnobs(knobs || getLiveKnobs(persistOpts));
@@ -1472,7 +1779,7 @@ export function applySpillCut(msgs, {
1472
1779
  // 300-result storm is not forwarded at full size.
1473
1780
  const stubFrom = plan.firstSpillable >= 0 ? plan.firstSpillable : 0;
1474
1781
  let stubbed = stubForCut(msgs, stubFrom, {
1475
- 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,
1476
1783
  });
1477
1784
  let sentChars = sliceChars(stubbed.messages, stubFrom);
1478
1785
  const corpus = Math.max(Number(corpusChars) || 0, sentChars);
@@ -1492,7 +1799,7 @@ export function applySpillCut(msgs, {
1492
1799
  action = decision.action;
1493
1800
  if (decision.recut) {
1494
1801
  stubbed = stubForCut(msgs, stubFrom, {
1495
- 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,
1496
1803
  });
1497
1804
  sentChars = sliceChars(stubbed.messages, stubFrom);
1498
1805
  ratio = spillRatio(corpus, sentChars);
@@ -1518,7 +1825,7 @@ export function applySpillCut(msgs, {
1518
1825
  };
1519
1826
 
1520
1827
  let stubbed = stubForCut(msgs, plan.cut, {
1521
- 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,
1522
1829
  });
1523
1830
  let stats = measure(plan.cut, stubbed, k);
1524
1831
  let action = 'hold';
@@ -1539,7 +1846,7 @@ export function applySpillCut(msgs, {
1539
1846
  plan = cutTranscript(msgs, k);
1540
1847
  if (plan.cut > plan.firstSpillable) {
1541
1848
  stubbed = stubForCut(msgs, plan.cut, {
1542
- 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,
1543
1850
  });
1544
1851
  stats = measure(plan.cut, stubbed, k);
1545
1852
  }
@@ -1562,6 +1869,119 @@ export function applySpillCut(msgs, {
1562
1869
  };
1563
1870
  }
1564
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
+
1565
1985
  /** Session counters the HUD reads off /v1/info. */
1566
1986
  export function createSpillStats() {
1567
1987
  return {
package/lib/x402.js CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  * payTo: "<wallet>", resource, description, maxTimeoutSeconds,
23
23
  * extra: { facilitator, feePayer, symbol, billedUsd, tokenUsd,
24
24
  * pricedAt, pricing: "markup"|"counterfactual",
25
- * markup? , directUsd?, savesVsDirect?,
25
+ * billedUsd, directUsd?, savedUsd?, savesVsDirect?,
26
26
  * acquire?: { method: "spl-token-wrap", steps: WRAP_ACQUIRE_STEPS } } } ],
27
27
  * error: "payment required",
28
28
  * help: "Wrap ix has 9 accounts. Program CPIs the deposit — do not send a separate TransferChecked. 0x6a = NotEnoughAccounts (old 5-account wrap is dead)." }
@@ -232,12 +232,21 @@ export function receiptLine(accept, settle) {
232
232
  // reaches the spill threshold, so there is nothing to compress and nothing
233
233
  // to save — which is worth saying out loud, because the fix on the caller's
234
234
  // side is to BIND a corpus, not to change models.
235
- const ratio = x.savesVsDirect != null ? Number(x.savesVsDirect) : null;
235
+ const billed = Number(x.billedUsd);
236
+ const direct = x.directUsd != null ? Number(x.directUsd) : null;
237
+ const saved = x.savedUsd != null ? Number(x.savedUsd)
238
+ : (Number.isFinite(billed) && Number.isFinite(direct) ? Math.max(0, direct - billed) : null);
239
+ const ratio = x.savesVsDirect != null ? Number(x.savesVsDirect)
240
+ : (Number.isFinite(billed) && billed > 0 && Number.isFinite(direct) ? direct / billed : null);
241
+ // Wallet path: OpenRouter price, plus 33% of savings vs direct when any.
242
+ // Never print extra.markup — that field is leftover 3× and is not the quote.
236
243
  const saves = ratio != null
237
244
  ? (ratio >= 1.05
238
245
  ? ` (${ratio.toFixed(1)}× cheaper than direct)`
239
- : ' (at direct price — nothing to compress; bind a corpus to save)')
240
- : (x.markup != null ? ` (markup ${x.markup}×, short body)` : '');
246
+ : ' (at OpenRouter price — nothing to compress; bind a corpus to save)')
247
+ : (Number.isFinite(saved) && saved > 0
248
+ ? ` ($${saved.toFixed(4)} saved vs direct)`
249
+ : ' (at OpenRouter price — short body)');
241
250
  const tx = settle?.transaction || settle?.txHash || settle?.signature;
242
251
  const rail = railOf(accept);
243
252
  return `paid ${usd}${saves}${rail ? ` · rail ${rail}` : ''}${tx ? ` · tx ${tx}` : ''}`;