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 +12 -1
- package/lib/proxy.js +154 -109
- package/lib/spill.js +400 -0
- package/package.json +2 -2
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
|
|
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
|
|
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)
|
|
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
|
|
533
|
-
//
|
|
534
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
597
|
-
// against this session's context
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
854
|
-
let
|
|
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
|
-
|
|
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
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
-
|
|
1511
|
+
spill.noteSpill({ corpusChars: cached.corpus?.length || 0, reused: cached.reused });
|
|
1466
1512
|
didSpill = true;
|
|
1467
|
-
|
|
1468
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
4
|
-
"description": "Local x402-paying proxy + MCP server for openzoo.fun
|
|
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": {
|