openzoo 0.48.69 → 0.48.71

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/launch.js CHANGED
@@ -230,7 +230,18 @@ export async function launchClaude(argv) {
230
230
  + 'const sp=j.spilled||{};'
231
231
  + 'const tk=Number(sp.tokensApprox)||0;'
232
232
  + 'const ht=tk>=1e6?(tk/1e6).toFixed(1)+"M":tk>=1e3?Math.round(tk/1e3)+"k":String(tk);'
233
- + 'const spill=tk?(" \\u00b7 "+ht+" tok offloaded"):"";'
233
+ + 'const paid=Number(j.paidCalls)||0;'
234
+ + 'const sc=Number(sp.calls)||0;'
235
+ + 'const fb=Number(sp.fileBinds)||0;'
236
+ + 'const rb=Number(sp.reusedBinds)||0;'
237
+ + 'const ls=sp.lastSend||{};'
238
+ + 'const bits=[];'
239
+ + 'bits.push("spilled "+sc+"/"+paid+" calls");'
240
+ + 'if(rb)bits.push(rb+" reused");'
241
+ + 'bits.push(fb+" filebind");'
242
+ + 'if(ls.sent!=null&&ls.msgs!=null)bits.push("sending "+ls.sent+"/"+ls.msgs);'
243
+ + 'if(tk)bits.push(ht+" tok offloaded");'
244
+ + 'const spill=bits.length?(" \\u00b7 "+bits.join(" \\u00b7 ")):"";'
234
245
  // "how can I easily see what credit i'm left" — asked by a user who
235
246
  // could not tell whether x402 had charged them at all.
236
247
  + 'const cr=(j.creditUsd==null)?"":(" \\u00b7 $"+Number(j.creditUsd).toFixed(2)+" credit");'
package/lib/proxy.js CHANGED
@@ -12,6 +12,9 @@ import { PayClient, QuoteTooHighError, UnderfundedError } from './pay.js';
12
12
  import { tokenBalance } from './x402.js';
13
13
  import { evmTokenBalance } from './evm.js';
14
14
  import { bindCorpus, contextCacheDisabled, BIND_MIN_CHARS } from './hrr.js';
15
+ import {
16
+ loadBoundChars, noteCorpusLedger, filesForCorpus, createSpillStats, corpusCharsForSend,
17
+ } from './spill.js';
15
18
  import { maybeRewriteModel, rewritablePath, augmentModelList, ALIAS_IDS } from './models.js';
16
19
  import { forgetContext } from './contexts.js';
17
20
  import { injectBrief } from './brief.js';
@@ -122,6 +125,10 @@ const boundFiles = new Set();
122
125
  // context_id -> total chars BOUND (turns + files appended). The counterfactual
123
126
  // basis must reflect what the corpus actually holds, not just this turn's slice.
124
127
  const boundChars = new Map();
128
+ // sessionKey -> { contextId, chars } so a sidecar restart can keep appending
129
+ // to the same context and still send the accumulated x-hrr-corpus-chars.
130
+ const sessionLedger = new Map();
131
+ loadBoundChars(boundChars, { sessions: sessionLedger, boundFiles });
125
132
 
126
133
  // THIS MAP SURVIVES A RESTART, OR THE COUNTERFACTUAL DOES NOT.
127
134
  //
@@ -363,9 +370,53 @@ function msgText(m) {
363
370
  * severed at a plain `user` message — everything before one is self-contained.
364
371
  * A system message is never spilled: it is the operating contract, not history.
365
372
  */
366
- async function spillTranscript(body, log, req) {
373
+ async function spillTranscript(body, log, req, stats) {
367
374
  const msgs = Array.isArray(body?.messages) ? body.messages : null;
368
- if (!msgs || msgs.length < 6) return null;
375
+ if (!msgs?.length) return null;
376
+
377
+ // FILES FIRST. The cut/length gates below used to run before filesForCorpus,
378
+ // so a short agent turn that Read a file never bound it � and when the
379
+ // extract itself returned empty, nothing logged. Extract + log unconditionally.
380
+ const fileResult = filesForCorpus(msgs, { boundFiles, log });
381
+ const files = fileResult.text;
382
+ const sessionId = req?.headers?.['x-claude-code-session-id']
383
+ || req?.headers?.['x-session-id']
384
+ || req?.headers?.['x-claude-session-id']
385
+ || (typeof body?.metadata?.user_id === 'string' ? body.metadata.user_id : null);
386
+ let sessionKey = sessionId ? `sid:${sessionId}` : null;
387
+
388
+ const ledgerOpts = () => ({ sessionKey, sessions: sessionLedger, boundFiles });
389
+ const bindFilesInBackground = (label) => {
390
+ if (!files) return;
391
+ const known = (sessionKey && spillMemo.get(sessionKey))
392
+ || (sessionKey && sessionLedger.get(sessionKey))
393
+ || null;
394
+ const appendTo = known?.contextId || null;
395
+ void bindCorpus(files, {
396
+ appendTo,
397
+ onStage: (stage, info) => {
398
+ if (stage === 'binding') log(`binding ${info.bytes} bytes (${mb(info.bytes)}MB) of FILES (${label})`);
399
+ },
400
+ }).then((b) => {
401
+ if (!b?.contextId) return;
402
+ noteCorpusLedger(boundChars, {
403
+ contextId: b.contextId,
404
+ reused: Boolean(appendTo),
405
+ corpusChars: 0,
406
+ fileChars: files.length,
407
+ ...ledgerOpts(),
408
+ });
409
+ stats?.noteFileBind(fileResult.files, fileResult.bytes);
410
+ if (sessionKey && !spillMemo.has(sessionKey)) {
411
+ spillMemo.set(sessionKey, { corpus: '', contextId: b.contextId, hash: b.hash });
412
+ }
413
+ }).catch((e) => log(`file bind failed: ${e.message}`));
414
+ };
415
+
416
+ if (msgs.length < 6) {
417
+ bindFilesInBackground('conversation under 6 messages, background');
418
+ return null;
419
+ }
369
420
 
370
421
  // Keep the recent tail, but never more than half the transcript: a fixed 8 on
371
422
  // a 10-message body left only index 2 to search, which is rarely a user turn,
@@ -415,7 +466,10 @@ async function spillTranscript(body, log, req) {
415
466
  if (severable(i)) { cut = i; break; }
416
467
  }
417
468
  }
418
- if (cut <= firstSpillable) return null; // nothing safely severable
469
+ if (cut <= firstSpillable) {
470
+ bindFilesInBackground('no severable cut, files only');
471
+ return null; // nothing safely severable
472
+ }
419
473
 
420
474
  // TRIM THE TAIL BY BYTES, NOT MESSAGE COUNT.
421
475
  //
@@ -529,93 +583,40 @@ async function spillTranscript(body, log, req) {
529
583
  // sitting on this machine. Binding it makes the corpus large immediately
530
584
  // instead of eventually, and makes the truncated read whole again.
531
585
  //
532
- // Read-only, bounded, deduped by path+mtime, and failures are silent: this
533
- // runs on the request path and must never be the reason a turn does not go.
534
- const filesForCorpus = () => {
535
- if (process.env.OPENZOO_BIND_FILES === '0') return '';
536
- const cap = Number(process.env.OPENZOO_BIND_FILE_MAX || 400_000);
537
- const out = [];
538
- const seen = new Set();
539
- for (const m of msgs) {
540
- const blocks = Array.isArray(m?.content) ? m.content : [];
541
- const calls = Array.isArray(m?.tool_calls) ? m.tool_calls : [];
542
- const paths = [];
543
- for (const b of blocks) {
544
- const p = b?.input?.file_path || b?.input?.path;
545
- if (typeof p === 'string') paths.push(p);
546
- }
547
- for (const c of calls) {
548
- try {
549
- const a = JSON.parse(c?.function?.arguments || '{}');
550
- if (typeof a.file_path === 'string') paths.push(a.file_path);
551
- else if (typeof a.path === 'string') paths.push(a.path);
552
- } catch { /* not json args */ }
553
- }
554
- for (const p of paths) {
555
- if (seen.has(p) || !path.isAbsolute(p)) continue;
556
- seen.add(p);
557
- try {
558
- const st = fs.statSync(p);
559
- if (!st.isFile() || st.size > cap) continue;
560
- const key = `${p}:${st.mtimeMs}`;
561
- if (boundFiles.has(key)) continue;
562
- boundFiles.add(key);
563
- out.push(`FILE ${p}\n${fs.readFileSync(p, 'utf8')}`);
564
- } catch { /* unreadable, gone, or binary — simply not corpus */ }
565
- }
566
- }
567
- return out.join('\n\n');
568
- };
569
-
586
+ // Read-only, bounded, deduped by path+mtime. Path extraction + the file-bind
587
+ // log already ran at the top of this function (files / fileResult).
588
+ //
570
589
  // FILES RIDE THE BACKGROUND, NEVER THE CRITICAL PATH.
571
590
  //
572
- // The FIRST bind of a session is necessarily synchronous the request cannot
591
+ // The FIRST bind of a session is necessarily synchronous the request cannot
573
592
  // go until the context_id exists, because it travels as x-hrr-context. Folding
574
593
  // file bytes into that bind put a 400KB upload in front of the caller's turn,
575
594
  // which is the cold-bind stall this whole exercise was meant to remove: the
576
595
  // bind endpoint measures 0.34-0.48s on a 613KB corpus, and that is 0.34-0.48s
577
596
  // the user waits before a single token appears.
578
597
  //
579
- // Nothing recalls a file during the turn that read it the model already has
598
+ // Nothing recalls a file during the turn that read it the model already has
580
599
  // the tool result in its window. Files are only worth having bound for the
581
600
  // NEXT ask. So the conversation binds inline and the files are appended after
582
601
  // the fact, off the clock.
583
602
  const turns = msgs.slice(firstSpillable, cut).map(msgText).filter(Boolean).join('\n\n');
584
- const files = filesForCorpus();
585
603
  const corpus = turns;
604
+ if (!sessionKey) sessionKey = corpus.slice(0, 2048);
586
605
 
587
606
  // FILES BIND EVEN WHEN THE CONVERSATION IS TOO SMALL TO SPILL.
588
607
  //
589
- // The threshold exists to stop us binding a two-line chat it was never
608
+ // The threshold exists to stop us binding a two-line chat it was never
590
609
  // meant to gate FILES. But bailing here skipped them entirely, so a fresh
591
610
  // session that reads a 200KB file bound nothing and scored 1.00x forever:
592
611
  // OBSERVED on a live session that read files all turn and never produced a
593
612
  // corpus, because its conversation stayed under the threshold the whole time.
594
613
  //
595
614
  // A file is worth binding on its own merit. So when the turns are too small
596
- // to spill but files exist, bind the files anyway in the background,
597
- // against this session's context and let this turn go unspilled. The corpus
615
+ // to spill but files exist, bind the files anyway in the background,
616
+ // against this session's context and let this turn go unspilled. The corpus
598
617
  // is then waiting for the next ask.
599
- const sessionId = req?.headers?.['x-claude-code-session-id']
600
- || req?.headers?.['x-session-id']
601
- || req?.headers?.['x-claude-session-id']
602
- || (typeof body?.metadata?.user_id === 'string' ? body.metadata.user_id : null);
603
- const sessionKey = sessionId ? `sid:${sessionId}` : corpus.slice(0, 2048);
604
618
  if (corpus.length <= BIND_MIN_CHARS) {
605
- if (files) {
606
- const known = spillMemo.get(sessionKey);
607
- void bindCorpus(files, {
608
- appendTo: known?.contextId || null,
609
- onStage: (stage, info) => {
610
- if (stage === 'binding') log(`binding ${mb(info.bytes)}MB of FILES (conversation under spill threshold, background)`);
611
- },
612
- }).then((b) => {
613
- if (!b?.contextId) return;
614
- boundChars.set(b.contextId, (boundChars.get(b.contextId) || 0) + files.length);
615
- persistBoundChars();
616
- if (!known) spillMemo.set(sessionKey, { corpus, contextId: b.contextId, hash: b.hash });
617
- }).catch((e) => log(`file bind failed: ${e.message}`));
618
- }
619
+ bindFilesInBackground('conversation under spill threshold, background');
619
620
  return null;
620
621
  }
621
622
 
@@ -650,11 +651,30 @@ async function spillTranscript(body, log, req) {
650
651
  // anthropic-beta, anthropic-version, x-app, x-claude-code-session-id,
651
652
  // x-stainless-*
652
653
  const anchor = sessionKey;
653
- const prior = spillMemo.get(anchor);
654
+ const persisted = sessionKey ? sessionLedger.get(sessionKey) : null;
655
+ const prior = spillMemo.get(anchor) || (persisted?.contextId
656
+ ? { contextId: persisted.contextId, hash: persisted.hash || '', corpus: null, restored: true }
657
+ : null);
654
658
  let bind;
655
- if (prior && corpus.startsWith(prior.corpus) && corpus.length > prior.corpus.length) {
659
+ let deltaChars = 0;
660
+ let appended = false;
661
+ if (prior?.restored && prior.contextId) {
662
+ // Sidecar came back up: we still know the context_id and the accumulated
663
+ // char count, but not the prior prefix string, so we cannot slice a delta.
664
+ // Re-append the current prefix (some overlap is harmless) and keep the
665
+ // restored ledger � do not add corpus.length again.
666
+ void bindCorpus(corpus, {
667
+ appendTo: prior.contextId,
668
+ onStage: (stage, info) => {
669
+ if (stage === 'binding') log(`appending ${mb(info.bytes)}MB to ${prior.contextId} (restored session, background)`);
670
+ },
671
+ }).catch((e) => log(`append failed (history may lag one turn): ${e.message}`));
672
+ appended = true;
673
+ bind = { contextId: prior.contextId, hash: prior.hash, reused: true, bytes: 0 };
674
+ } else if (prior && typeof prior.corpus === 'string' && corpus.startsWith(prior.corpus) && corpus.length > prior.corpus.length) {
656
675
  const delta = corpus.slice(prior.corpus.length);
657
- // FIRE AND FORGET. This delta is history for FUTURE turns — the answer
676
+ deltaChars = delta.length;
677
+ // FIRE AND FORGET. This delta is history for FUTURE turns � the answer
658
678
  // being generated right now is served from the tail plus what is already
659
679
  // bound, so waiting on the upload buys nothing and costs the user the
660
680
  // round trip on every single turn. The context id is already known, so
@@ -662,7 +682,7 @@ async function spillTranscript(body, log, req) {
662
682
  //
663
683
  // The FIRST bind is deliberately NOT async: the request must carry
664
684
  // x-hrr-context, and that id does not exist until the bind returns. Firing
665
- // that one off would send the opening turn with no context at all a
685
+ // that one off would send the opening turn with no context at all a
666
686
  // silently worse answer traded for a shorter pause, which is the wrong way
667
687
  // round.
668
688
  void bindCorpus(delta, {
@@ -671,6 +691,7 @@ async function spillTranscript(body, log, req) {
671
691
  if (stage === 'binding') log(`appending ${mb(info.bytes)}MB to ${prior.contextId} (delta, background)`);
672
692
  },
673
693
  }).catch((e) => log(`append failed (history may lag one turn): ${e.message}`));
694
+ appended = true;
674
695
  bind = { contextId: prior.contextId, hash: prior.hash, reused: true, bytes: delta.length };
675
696
  } else {
676
697
  bind = await bindCorpus(corpus, {
@@ -679,17 +700,28 @@ async function spillTranscript(body, log, req) {
679
700
  },
680
701
  });
681
702
  }
703
+ // CONVERSATION LEDGER � every successful bind AND append, not only when
704
+ // files exist. First bind initializes to the bound corpus size; each append
705
+ // adds the delta; file bytes ride on top. This is what makes x-hrr-corpus-chars
706
+ // the accumulated bound corpus instead of this-turn's prefix.
707
+ noteCorpusLedger(boundChars, {
708
+ contextId: bind.contextId,
709
+ reused: appended,
710
+ corpusChars: corpus.length,
711
+ deltaChars,
712
+ fileChars: files.length,
713
+ ...ledgerOpts(),
714
+ });
682
715
  // APPEND THE FILES AFTER, off the clock. Fire-and-forget against the context
683
716
  // we just secured: this turn is already answerable without them, and the next
684
717
  // ask gets them for free. `boundFiles` already deduped by path:mtime, so this
685
718
  // uploads each version exactly once no matter how often the agent re-reads it.
686
719
  if (files) {
687
- boundChars.set(bind.contextId, (boundChars.get(bind.contextId) || corpus.length) + files.length);
688
- persistBoundChars();
720
+ stats?.noteFileBind(fileResult.files, fileResult.bytes);
689
721
  void bindCorpus(files, {
690
722
  appendTo: bind.contextId,
691
723
  onStage: (stage, info) => {
692
- if (stage === 'binding') log(`appending ${mb(info.bytes)}MB of FILES to ${bind.contextId} (background)`);
724
+ if (stage === 'binding') log(`appending ${info.bytes} bytes (${mb(info.bytes)}MB) of FILES to ${bind.contextId} (background)`);
693
725
  },
694
726
  }).catch((e) => log(`file append failed (corpus lags one turn): ${e.message}`));
695
727
  }
@@ -744,14 +776,26 @@ async function spillTranscript(body, log, req) {
744
776
  corpus,
745
777
  reused: bind.reused,
746
778
  savedBytes: bind.bytes,
779
+ sent,
780
+ msgs: msgs.length,
747
781
  };
748
782
  }
749
783
 
750
- async function maybeCacheCorpus(req, bodyBuf, log) {
784
+ async function maybeCacheCorpus(req, bodyBuf, log, stats) {
751
785
  if (contextCacheDisabled()) return null;
752
786
  if (req.method !== 'POST' || !(req.url || '').includes('/chat/completions')) return null;
753
787
  if (req.headers['x-hrr-context']) return null; // harness manages its own context
754
- if (bodyBuf.length <= BIND_MIN_CHARS) return null;
788
+ if (bodyBuf.length <= BIND_MIN_CHARS) {
789
+ // Still walk the transcript: a short agent turn that Read a file should
790
+ // bind it even when there is nothing large enough to spill.
791
+ try {
792
+ const body = JSON.parse(bodyBuf.toString('utf8'));
793
+ if (Array.isArray(body?.messages) && body.messages.length) {
794
+ return spillTranscript(body, log, req, stats);
795
+ }
796
+ } catch { /* not json */ }
797
+ return null;
798
+ }
755
799
  let body;
756
800
  try { body = JSON.parse(bodyBuf.toString('utf8')); } catch { return null; }
757
801
  const msgs = Array.isArray(body?.messages) ? body.messages : null;
@@ -764,17 +808,24 @@ async function maybeCacheCorpus(req, bodyBuf, log) {
764
808
  const oneShot = typeof last?.content === 'string'
765
809
  && last.content.length > BIND_MIN_CHARS
766
810
  && last.content.lastIndexOf('\n\n') >= BIND_MIN_CHARS;
767
- if (!oneShot) return spillTranscript(body, log, req);
811
+ if (!oneShot) return spillTranscript(body, log, req, stats);
768
812
  const cut = last.content.lastIndexOf('\n\n');
769
813
  const corpus = last.content.slice(0, cut);
770
814
  const ask = last.content.slice(cut + 2).trim();
771
- if (!ask || ask.length > 8000) return spillTranscript(body, log, req);
815
+ if (!ask || ask.length > 8000) return spillTranscript(body, log, req, stats);
772
816
 
773
817
  const bind = await bindCorpus(corpus, {
774
818
  onStage: (stage, info) => {
775
819
  if (stage === 'binding') log(`binding ${mb(info.bytes)}MB corpus to holographic memory (one-time)...`);
776
820
  },
777
821
  });
822
+ noteCorpusLedger(boundChars, {
823
+ contextId: bind.contextId,
824
+ reused: false,
825
+ corpusChars: corpus.length,
826
+ sessions: sessionLedger,
827
+ boundFiles,
828
+ });
778
829
  if (bind.reused) {
779
830
  log(`corpus already bound (${bind.hash.slice(0, 12)}… → ${bind.contextId}) — skipped ${mb(bind.bytes)}MB upload`);
780
831
  } else {
@@ -788,6 +839,8 @@ async function maybeCacheCorpus(req, bodyBuf, log) {
788
839
  corpus,
789
840
  reused: bind.reused,
790
841
  savedBytes: bind.bytes,
842
+ sent: 1,
843
+ msgs: msgs.length,
791
844
  };
792
845
  }
793
846
 
@@ -850,16 +903,14 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
850
903
  // SPILL ACCOUNTING. The product's whole claim is that context is offloaded
851
904
  // instead of re-sent, and nothing measured it — the status line showed spend
852
905
  // and call count, which is the cost side with none of the benefit.
853
- let spillCalls = 0;
854
- let spilledChars = 0;
855
- let spillReuses = 0;
906
+ const spill = createSpillStats();
907
+ let lastSpillSend = null;
856
908
  // Spend/direct for ONLY the calls that spilled. The session-wide savingX
857
909
  // averages these with every small turn that had nothing to offload, so it
858
910
  // slides toward 1.0 as a conversation grows — which reads as the mechanism
859
911
  // degrading when it is just the mix changing. OBSERVED: 1.3166 -> 1.1823
860
912
  // while spilled calls and offloaded tokens both sat completely still.
861
- let spillSpend = 0;
862
- let spillDirect = 0;
913
+ // spillSpend / spillDirect live on `spill` (createSpillStats).
863
914
  // ACTUAL UPSTREAM SPEND, FROM THE PROVIDER — not our estimate of it.
864
915
  //
865
916
  // Every other dollar figure here is derived from OpenRouter's CATALOG price
@@ -998,19 +1049,14 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
998
1049
  reachedVia: viaTunnel ? 'public tunnel' : 'localhost',
999
1050
  publicTunnel: tunnelGate?.publicUrl ? `${tunnelGate.publicUrl}/v1` : null,
1000
1051
  servedRequests,
1001
- spilled: {
1002
- calls: spillCalls,
1003
- chars: spilledChars,
1004
- // ~4 chars/token is the usual rough rule; this is the context that
1005
- // did NOT ride upstream on those calls, which is the number the
1006
- // saving is actually made of.
1007
- tokensApprox: Math.round(spilledChars / 4),
1008
- reusedBinds: spillReuses,
1009
- spend: spillSpend,
1010
- direct: spillDirect,
1011
- savedUsd: Math.max(0, spillDirect - spillSpend),
1012
- savingX: spillSpend > 0 ? Number((spillDirect / spillSpend).toFixed(4)) : null,
1013
- },
1052
+ spilled: (() => {
1053
+ const ledgerTotal = [...boundChars.values()].reduce((a, b) => a + b, 0);
1054
+ return {
1055
+ ...spill.snapshot({ boundChars: ledgerTotal }),
1056
+ boundChars: ledgerTotal,
1057
+ lastSend: lastSpillSend,
1058
+ };
1059
+ })(),
1014
1060
  spendUsd: sessionSpent,
1015
1061
  creditUsd,
1016
1062
  // WHAT THE SAME CALLS WOULD HAVE COST DIRECT. Spend on its own is a
@@ -1433,7 +1479,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1433
1479
  try {
1434
1480
  let cached = null;
1435
1481
  try {
1436
- cached = await maybeCacheCorpus(req, bodyBuf, log);
1482
+ cached = await maybeCacheCorpus(req, bodyBuf, log, spill);
1437
1483
  } catch (err) {
1438
1484
  log(`context cache skipped for this call: ${err.message}`);
1439
1485
  }
@@ -1462,11 +1508,10 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1462
1508
  let didSpill = false;
1463
1509
  let result;
1464
1510
  if (cached) {
1465
- spillCalls += 1;
1511
+ spill.noteSpill({ corpusChars: cached.corpus?.length || 0, reused: cached.reused });
1466
1512
  didSpill = true;
1467
- spilledChars += cached.corpus?.length || 0;
1468
- if (cached.reused) spillReuses += 1;
1469
- result = await send(cached.body, cached.contextId, cached.topK, boundChars.get(cached.contextId) || cached.corpus?.length);
1513
+ if (cached.sent != null) lastSpillSend = { sent: cached.sent, msgs: cached.msgs };
1514
+ result = await send(cached.body, cached.contextId, cached.topK, corpusCharsForSend(boundChars, cached.contextId, cached.corpus?.length));
1470
1515
  // Sidecar wiped between runs: the gateway 404s BEFORE the 402 (nothing
1471
1516
  // paid). Never fail on a stale manifest — re-bind once and retry.
1472
1517
  if (result.response.status === 404) {
@@ -1475,7 +1520,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1475
1520
  log('bound context is gone on the zoo — re-binding once...');
1476
1521
  forgetContext(config.apiBase, cached.hash);
1477
1522
  const rebound = await bindCorpus(cached.corpus, { force: true });
1478
- result = await send(cached.body, rebound.contextId, cached.topK, boundChars.get(rebound.contextId) || cached.corpus?.length);
1523
+ result = await send(cached.body, rebound.contextId, cached.topK, corpusCharsForSend(boundChars, rebound.contextId, cached.corpus?.length));
1479
1524
  } else {
1480
1525
  res.writeHead(404, { 'content-type': 'application/json' });
1481
1526
  res.end(text);
@@ -1506,8 +1551,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1506
1551
  // orders of magnitude above what was billed. directUsd is exact and
1507
1552
  // always present; savesVsDirect is the same number as a ratio.
1508
1553
  if (didSpill) {
1509
- spillSpend += receipt.billedUsd || 0;
1510
- spillDirect += typeof receipt.directUsd === 'number' ? receipt.directUsd : (receipt.billedUsd || 0);
1554
+ spill.spillSpend += receipt.billedUsd || 0;
1555
+ spill.spillDirect += typeof receipt.directUsd === 'number' ? receipt.directUsd : (receipt.billedUsd || 0);
1511
1556
  }
1512
1557
  sessionDirect += typeof receipt.directUsd === 'number'
1513
1558
  ? receipt.directUsd
@@ -1527,10 +1572,10 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1527
1572
  // the receipt lines go to a file (they corrupt a TUI). But the running
1528
1573
  // total should still be visible — so write it to the terminal TITLE via
1529
1574
  // an OSC escape, which updates the window/tab title without touching the
1530
- // TUI's content. `openzoo $0.0042 · 12 calls` in the title bar, live.
1575
+ // TUI's content. `openzoo $0.0042 · 12 calls` in the title bar, live.
1531
1576
  if (receipt.ok && typeof receipt.billedUsd === 'number') { paidCalls += 1; }
1532
1577
  if (sayFile) {
1533
- try { process.stderr.write(`]0;openzoo $${sessionSpent.toFixed(4)} · ${paidCalls} call${paidCalls === 1 ? '' : 's'}`); } catch { /* no tty */ }
1578
+ try { process.stderr.write(`]0;openzoo $${sessionSpent.toFixed(4)} · ${paidCalls} call${paidCalls === 1 ? '' : 's'}`); } catch { /* no tty */ }
1534
1579
  }
1535
1580
  scheduleRefresh(4000); // settlement lands on-chain in a few seconds
1536
1581
  }
@@ -1583,8 +1628,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1583
1628
  // night's debugging after a number that was never the input.
1584
1629
  const basis = x.counterfactualTokensUsed ?? lc.corpusTokens;
1585
1630
  log(`spill priced: ${x.pricing} · basis ${basis ?? '?'} tok vs sent ${lc.tokensBefore ?? '?'} -> ${lc.tokensAfter ?? '?'} · billed ${(x.billedUsd ?? 0).toFixed(5)} direct ${(x.directUsd ?? 0).toFixed(5)}`);
1586
- spillSpend += x.billedUsd;
1587
- spillDirect += typeof x.directUsd === 'number' ? x.directUsd : x.billedUsd;
1631
+ spill.spillSpend += x.billedUsd;
1632
+ spill.spillDirect += typeof x.directUsd === 'number' ? x.directUsd : x.billedUsd;
1588
1633
  }
1589
1634
  paidCalls += 1;
1590
1635
  if (viaTunnel) tunnelSpent += x.billedUsd;
@@ -1639,8 +1684,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1639
1684
  if (didSpill) {
1640
1685
  const lc = x.lecore || {};
1641
1686
  log(`spill priced (streamed): ${x.pricing} · basis ${x.counterfactualTokensUsed ?? '?'} tok vs sent ${lc.tokensBefore ?? '?'} -> ${lc.tokensAfter ?? '?'} · billed ${(x.billedUsd ?? 0).toFixed(5)} direct ${(x.directUsd ?? 0).toFixed(5)}`);
1642
- spillSpend += x.billedUsd;
1643
- spillDirect += typeof x.directUsd === 'number' ? x.directUsd : x.billedUsd;
1687
+ spill.spillSpend += x.billedUsd;
1688
+ spill.spillDirect += typeof x.directUsd === 'number' ? x.directUsd : x.billedUsd;
1644
1689
  }
1645
1690
  paidCalls += 1;
1646
1691
  if (viaTunnel) tunnelSpent += x.billedUsd;
package/lib/spill.js ADDED
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Spill-side bookkeeping that used to live (and die) inside proxy.js.
3
+ *
4
+ * Three things the status line needs, and that a live session was not getting:
5
+ * 1. A corpus ledger that accumulates bind + append + file bytes, persisted
6
+ * so a sidecar restart still knows how big the bound context is.
7
+ * 2. File-path extraction that matches Claude Code / Anthropic tool_use
8
+ * (and the OpenAI shape those requests are translated into).
9
+ * 3. Counters for spilled calls, file-bind events, and offloaded chars —
10
+ * incremented on the spill path, never on a pass-through.
11
+ *
12
+ * Kept as pure-enough helpers so test/spill.test.js can cover them without
13
+ * standing up the proxy or the gateway.
14
+ */
15
+ import fs from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+
19
+ const PATH_KEYS = new Set([
20
+ 'file_path', 'path', 'target_file', 'filePath', 'filename', 'file',
21
+ 'targetFile', 'filepath',
22
+ ]);
23
+ const PATH_ARRAY_KEYS = new Set(['files', 'file_paths', 'paths', 'filePaths']);
24
+
25
+ export function boundCharsFile(home = os.homedir()) {
26
+ return process.env.OPENZOO_BOUND_CHARS_PATH
27
+ || path.join(home, '.openzoo', 'bound-chars.json');
28
+ }
29
+
30
+ /**
31
+ * Load ~/.openzoo/bound-chars.json into the maps the proxy holds.
32
+ * Missing / corrupt file is a cold start, not an error.
33
+ */
34
+ export function loadBoundChars(boundChars, extra = {}) {
35
+ const file = extra.file || boundCharsFile(extra.home);
36
+ let raw;
37
+ try { raw = fs.readFileSync(file, 'utf8'); } catch { return { ok: false, reason: 'missing' }; }
38
+ let data;
39
+ try { data = JSON.parse(raw); } catch { return { ok: false, reason: 'corrupt' }; }
40
+ const chars = data?.chars && typeof data.chars === 'object' ? data.chars
41
+ : (data && typeof data === 'object' && !data.sessions && !data.files ? data : {});
42
+ for (const [id, n] of Object.entries(chars)) {
43
+ const v = Number(n);
44
+ if (id && Number.isFinite(v) && v > 0) boundChars.set(id, v);
45
+ }
46
+ if (extra.sessions && data?.sessions && typeof data.sessions === 'object') {
47
+ for (const [k, v] of Object.entries(data.sessions)) {
48
+ if (k && v && typeof v.contextId === 'string') extra.sessions.set(k, v);
49
+ }
50
+ }
51
+ if (extra.boundFiles && Array.isArray(data?.files)) {
52
+ for (const key of data.files) {
53
+ if (typeof key === 'string') extra.boundFiles.add(key);
54
+ }
55
+ }
56
+ return { ok: true, contexts: boundChars.size };
57
+ }
58
+
59
+ export function persistBoundChars(boundChars, extra = {}) {
60
+ const file = extra.file || boundCharsFile(extra.home);
61
+ try {
62
+ fs.mkdirSync(path.dirname(file), { recursive: true });
63
+ const payload = {
64
+ chars: Object.fromEntries(boundChars),
65
+ };
66
+ if (extra.sessions) payload.sessions = Object.fromEntries(extra.sessions);
67
+ if (extra.boundFiles) payload.files = [...extra.boundFiles];
68
+ const tmp = `${file}.tmp`;
69
+ fs.writeFileSync(tmp, JSON.stringify(payload));
70
+ fs.renameSync(tmp, file);
71
+ return true;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Accumulate bound bytes for a context.
79
+ *
80
+ * init — first bind: ledger becomes `chars` (the bound corpus size)
81
+ * add — append / file: ledger += chars
82
+ *
83
+ * Persist after every successful update so a crash mid-session keeps the count.
84
+ */
85
+ export function accumulateBoundChars(boundChars, contextId, chars, opts = {}) {
86
+ if (!contextId || !Number.isFinite(chars) || chars < 0) return boundChars.get(contextId) || 0;
87
+ const next = opts.init ? chars : (boundChars.get(contextId) || 0) + chars;
88
+ boundChars.set(contextId, next);
89
+ if (opts.sessionKey && opts.sessions) {
90
+ opts.sessions.set(opts.sessionKey, { contextId, chars: next });
91
+ }
92
+ if (opts.persist !== false) persistBoundChars(boundChars, opts);
93
+ return next;
94
+ }
95
+
96
+ /**
97
+ * Conversation chars use Math.max so a stale smaller ledger (the 34056
98
+ * files-only row) cannot cap a later, larger prefix. New file bytes add on top.
99
+ *
100
+ * next = max(prev, corpusChars) + fileChars
101
+ */
102
+ export function noteCorpusLedger(boundChars, {
103
+ contextId, corpusChars = 0, fileChars = 0, ...opts
104
+ } = {}) {
105
+ if (!contextId) return 0;
106
+ const prev = boundChars.get(contextId) || 0;
107
+ const next = Math.max(prev, Number(corpusChars) || 0) + (Number(fileChars) || 0);
108
+ boundChars.set(contextId, next);
109
+ if (opts.sessionKey && opts.sessions) {
110
+ opts.sessions.set(opts.sessionKey, { contextId, chars: next });
111
+ }
112
+ persistBoundChars(boundChars, opts);
113
+ return next;
114
+ }
115
+
116
+ /** send() must not let `stale || thisTurn` pick the smaller number. */
117
+ export function corpusCharsForSend(boundChars, contextId, thisTurn) {
118
+ return Math.max(boundChars.get(contextId) || 0, thisTurn || 0);
119
+ }
120
+
121
+ /** Expand ~ and resolve relative paths against cwd. Returns null if unusable. */
122
+ export function resolveReadablePath(p, cwd = process.cwd()) {
123
+ if (typeof p !== 'string') return null;
124
+ let s = p.trim();
125
+ if (!s || s.length > 1024 || /[\n\r]/.test(s)) return null;
126
+ if (/^https?:\/\//i.test(s)) return null;
127
+ if (s.startsWith('~/') || s === '~') s = path.join(os.homedir(), s.slice(1).replace(/^\//, '') || '');
128
+ if (!path.isAbsolute(s)) s = path.resolve(cwd, s);
129
+ return s;
130
+ }
131
+
132
+ function looksLikePath(s) {
133
+ if (typeof s !== 'string') return false;
134
+ const t = s.trim();
135
+ if (t.length < 2 || t.length > 1024 || /[\n\r]/.test(t)) return false;
136
+ if (/^https?:\/\//i.test(t)) return false;
137
+ return /[\\/]/.test(t) || /\.\w{1,10}$/.test(t) || t.startsWith('~') || t.startsWith('.');
138
+ }
139
+
140
+ function parseArgs(args) {
141
+ if (args == null) return {};
142
+ if (typeof args === 'object' && !Array.isArray(args)) return args;
143
+ if (typeof args === 'string') {
144
+ const t = args.trim();
145
+ if (!t) return {};
146
+ try { return JSON.parse(t); } catch { return {}; }
147
+ }
148
+ return {};
149
+ }
150
+
151
+ function collectStructuredPaths(args, out) {
152
+ if (!args || typeof args !== 'object') return;
153
+ for (const k of PATH_KEYS) {
154
+ if (typeof args[k] === 'string' && args[k]) out.push(args[k]);
155
+ }
156
+ for (const k of PATH_ARRAY_KEYS) {
157
+ if (!Array.isArray(args[k])) continue;
158
+ for (const x of args[k]) {
159
+ if (typeof x === 'string') out.push(x);
160
+ else if (x && typeof x === 'object') collectStructuredPaths(x, out);
161
+ }
162
+ }
163
+ }
164
+
165
+ const CWD_HINT = /(?:current working directory is[:\s]+|<cwd>\s*|cwd:\s+)([^\s<]+)/i;
166
+
167
+ export function parseCwdHint(text) {
168
+ if (typeof text !== 'string' || !text) return null;
169
+ const m = text.match(CWD_HINT);
170
+ if (!m) return null;
171
+ const p = m[1].trim();
172
+ return path.isAbsolute(p) ? p : null;
173
+ }
174
+
175
+ /**
176
+ * Pull path-like tokens out of a Bash `command` string.
177
+ * `head -80 programs/README.md` and `cat /abs/file` both count; bare `ls` does not.
178
+ */
179
+ export function extractBashPaths(command, cwd = process.cwd()) {
180
+ const found = [];
181
+ if (typeof command !== 'string' || !command) return { paths: found, cwd };
182
+ let localCwd = cwd;
183
+ for (const part of command.split(/(?:&&|\|\||;|\n)/)) {
184
+ const cd = part.match(/^\s*cd\s+(?:\/[dD]\s+)?(['"]?)(.+?)\1\s*$/);
185
+ if (cd) {
186
+ const dest = resolveReadablePath(cd[2].trim(), localCwd);
187
+ if (dest) localCwd = dest;
188
+ continue;
189
+ }
190
+ for (const m of part.matchAll(/(['"])([^'"]+)\1/g)) {
191
+ const t = m[2].trim();
192
+ if (looksLikePath(t) || path.isAbsolute(t)) found.push({ raw: t, cwd: localCwd });
193
+ }
194
+ for (const tok of part.split(/\s+/)) {
195
+ const t = tok.replace(/^[`'"]|[`'"]$/g, '');
196
+ if (!t || t.startsWith('-') || t.startsWith('$') || t === '.' || t === '..') continue;
197
+ if (looksLikePath(t) || path.isAbsolute(t)) found.push({ raw: t, cwd: localCwd });
198
+ }
199
+ }
200
+ return { paths: found, cwd: localCwd };
201
+ }
202
+
203
+ /**
204
+ * Live Claude Code msgs are OpenAI-shaped (spill runs AFTER anthropicToOpenAI).
205
+ * Read/Edit/Write land on tool_calls[].function.arguments as a JSON string
206
+ * {file_path:"/abs/..."}. Bash is {command:"head -80 programs/README.md"}.
207
+ * Do not expect Read tool_result to carry the path. Do not harvest import
208
+ * paths out of tool_result bodies.
209
+ */
210
+ export function extractFileCandidates(msgs, { cwd = process.cwd() } = {}) {
211
+ const structured = [];
212
+ const bash = [];
213
+ let currentCwd = cwd;
214
+ if (!Array.isArray(msgs)) return { structured, bash, cwd: currentCwd };
215
+ for (const m of msgs) {
216
+ if (!m || typeof m !== 'object') continue;
217
+ if (m.role === 'tool' && typeof m.content === 'string') {
218
+ const hint = parseCwdHint(m.content);
219
+ if (hint) currentCwd = hint;
220
+ }
221
+ const calls = [
222
+ ...(Array.isArray(m.tool_calls) ? m.tool_calls : []),
223
+ ...(m.function_call ? [m.function_call] : []),
224
+ ];
225
+ for (const c of calls) {
226
+ const args = parseArgs(c?.function?.arguments ?? c?.arguments);
227
+ const fromArgs = [];
228
+ collectStructuredPaths(args, fromArgs);
229
+ for (const raw of fromArgs) structured.push({ raw, cwd: currentCwd });
230
+ if (typeof args.command === 'string') {
231
+ const got = extractBashPaths(args.command, currentCwd);
232
+ for (const p of got.paths) bash.push({ ...p, bash: true });
233
+ currentCwd = got.cwd;
234
+ }
235
+ }
236
+ // Harmless leftover: pre-conversion Anthropic tool_use. Live Claude Code
237
+ // never has this by the time spillTranscript runs.
238
+ const blocks = Array.isArray(m.content) ? m.content : [];
239
+ for (const b of blocks) {
240
+ if (b?.input) {
241
+ const fromInput = [];
242
+ collectStructuredPaths(b.input, fromInput);
243
+ for (const raw of fromInput) structured.push({ raw, cwd: currentCwd });
244
+ if (typeof b.input.command === 'string') {
245
+ const got = extractBashPaths(b.input.command, currentCwd);
246
+ for (const p of got.paths) bash.push({ ...p, bash: true });
247
+ currentCwd = got.cwd;
248
+ }
249
+ }
250
+ }
251
+ }
252
+ return { structured, bash, cwd: currentCwd };
253
+ }
254
+
255
+ export function extractFilePaths(msgs, opts) {
256
+ const { structured, bash } = extractFileCandidates(msgs, opts);
257
+ const seen = new Set();
258
+ const out = [];
259
+ for (const item of [...structured, ...bash]) {
260
+ const t = typeof item === 'string' ? item : item?.raw;
261
+ if (typeof t !== 'string') continue;
262
+ const s = t.trim();
263
+ if (!s || seen.has(s)) continue;
264
+ seen.add(s);
265
+ out.push(s);
266
+ }
267
+ return out;
268
+ }
269
+
270
+ const SKIP_DIR_NAMES = new Set(['node_modules', '.git', 'dist', 'build', '__pycache__', '.venv', 'target']);
271
+
272
+ function fileBindLog({ kept, bytes, enoent, cap, dir, rel, bash }) {
273
+ return `file-bind kept=${kept} bytes=${bytes} skip enoent=${enoent} cap=${cap} dir=${dir} rel=${rel} bash=${bash}`;
274
+ }
275
+
276
+ /**
277
+ * Read every new file the agent touched and return the corpus slice to bind.
278
+ *
279
+ * Live path is OpenAI tool_calls (Read/Edit/Write + Bash command). Relative
280
+ * paths resolve against cwd / last "current working directory is …" hint.
281
+ * Directories expand to children that are files under the cap.
282
+ *
283
+ * Always logs `file-bind kept=N bytes=B skip enoent=X cap=Y dir=Z rel=W bash=K`
284
+ * so grep-for-FILES is no longer the only signal. Bytes, not 0.0MB.
285
+ */
286
+ export function filesForCorpus(msgs, {
287
+ boundFiles,
288
+ cwd = process.cwd(),
289
+ cap = Number(process.env.OPENZOO_BIND_FILE_MAX || 400_000),
290
+ disabled = process.env.OPENZOO_BIND_FILES === '0',
291
+ log = () => {},
292
+ statSync = (p) => fs.statSync(p),
293
+ readFileSync = (p) => fs.readFileSync(p, 'utf8'),
294
+ readdirSync = (p) => fs.readdirSync(p),
295
+ } = {}) {
296
+ const empty = { kept: 0, bytes: 0, enoent: 0, cap: 0, dir: 0, rel: 0, bash: 0 };
297
+ if (disabled) {
298
+ log(fileBindLog(empty));
299
+ return { text: '', files: 0, bytes: 0, reason: 'disabled', ...empty };
300
+ }
301
+ const { structured, bash } = extractFileCandidates(msgs, { cwd });
302
+ const candidates = [
303
+ ...structured.map((p) => ({ ...p, bash: false })),
304
+ ...bash.map((p) => ({ ...p, bash: true })),
305
+ ];
306
+ const skip = { enoent: 0, cap: 0, dir: 0, rel: 0 };
307
+ const seen = new Set();
308
+ const chunks = [];
309
+
310
+ const tryBind = (abs) => {
311
+ if (!abs || seen.has(abs)) return;
312
+ seen.add(abs);
313
+ let st;
314
+ try { st = statSync(abs); } catch { skip.enoent += 1; return; }
315
+ if (st.isDirectory()) {
316
+ skip.dir += 1;
317
+ let kids = [];
318
+ try { kids = readdirSync(abs); } catch { skip.enoent += 1; return; }
319
+ for (const name of kids) {
320
+ if (!name || name.startsWith('.') || SKIP_DIR_NAMES.has(name)) continue;
321
+ const kid = path.join(abs, name);
322
+ let ks;
323
+ try { ks = statSync(kid); } catch { skip.enoent += 1; continue; }
324
+ if (ks.isFile()) tryBind(kid);
325
+ }
326
+ return;
327
+ }
328
+ if (!st.isFile()) { skip.enoent += 1; return; }
329
+ if (st.size > cap) { skip.cap += 1; return; }
330
+ const key = `${abs}:${st.mtimeMs}`;
331
+ if (boundFiles?.has(key)) return;
332
+ boundFiles?.add(key);
333
+ try {
334
+ chunks.push(`FILE ${abs}\n${readFileSync(abs)}`);
335
+ } catch {
336
+ skip.enoent += 1;
337
+ }
338
+ };
339
+
340
+ for (const item of candidates) {
341
+ const raw = item.raw;
342
+ if (!path.isAbsolute(raw) && !(raw.startsWith('~/') || raw === '~')) skip.rel += 1;
343
+ const abs = resolveReadablePath(raw, item.cwd || cwd);
344
+ if (!abs) { skip.enoent += 1; continue; }
345
+ tryBind(abs);
346
+ }
347
+
348
+ const text = chunks.join('\n\n');
349
+ const stats = {
350
+ kept: chunks.length,
351
+ bytes: text.length,
352
+ enoent: skip.enoent,
353
+ cap: skip.cap,
354
+ dir: skip.dir,
355
+ rel: skip.rel,
356
+ bash: bash.length,
357
+ };
358
+ log(fileBindLog(stats));
359
+ return { text, files: chunks.length, bytes: text.length, reason: chunks.length ? null : 'none-kept', ...stats };
360
+ }
361
+
362
+ /** Session counters the HUD reads off /v1/info. */
363
+ export function createSpillStats() {
364
+ return {
365
+ spillCalls: 0,
366
+ spilledChars: 0,
367
+ spillReuses: 0,
368
+ fileBinds: 0,
369
+ fileBindBytes: 0,
370
+ spillSpend: 0,
371
+ spillDirect: 0,
372
+ noteSpill({ corpusChars = 0, reused = false } = {}) {
373
+ this.spillCalls += 1;
374
+ this.spilledChars += corpusChars;
375
+ if (reused) this.spillReuses += 1;
376
+ },
377
+ noteFileBind(n, bytes = 0) {
378
+ if (!n) return;
379
+ this.fileBinds += n;
380
+ this.fileBindBytes += bytes;
381
+ this.spilledChars += bytes;
382
+ },
383
+ snapshot({ boundChars = null } = {}) {
384
+ const unique = boundChars == null ? this.spilledChars : boundChars;
385
+ return {
386
+ calls: this.spillCalls,
387
+ chars: this.spilledChars,
388
+ // Unique bound size — do not re-add the same prefix on every reuse.
389
+ tokensApprox: Math.round(unique / 4),
390
+ reusedBinds: this.spillReuses,
391
+ fileBinds: this.fileBinds,
392
+ fileBindBytes: this.fileBindBytes,
393
+ spend: this.spillSpend,
394
+ direct: this.spillDirect,
395
+ savedUsd: Math.max(0, this.spillDirect - this.spillSpend),
396
+ savingX: this.spillSpend > 0 ? Number((this.spillDirect / this.spillSpend).toFixed(4)) : null,
397
+ };
398
+ },
399
+ };
400
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.48.69",
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.",
3
+ "version": "0.48.71",
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",
7
7
  "bin": {