openzoo 0.48.70 → 0.48.72
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 +7 -2
- package/lib/proxy.js +89 -49
- package/lib/spill.js +433 -109
- package/package.json +1 -1
package/lib/launch.js
CHANGED
|
@@ -230,11 +230,16 @@ 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 paid=Number(j.paidCalls)||0;'
|
|
233
234
|
+ 'const sc=Number(sp.calls)||0;'
|
|
234
235
|
+ 'const fb=Number(sp.fileBinds)||0;'
|
|
236
|
+
+ 'const rb=Number(sp.reusedBinds)||0;'
|
|
237
|
+
+ 'const ls=sp.lastSend||{};'
|
|
235
238
|
+ 'const bits=[];'
|
|
236
|
-
+ '
|
|
237
|
-
+ 'if(
|
|
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);'
|
|
238
243
|
+ 'if(tk)bits.push(ht+" tok offloaded");'
|
|
239
244
|
+ 'const spill=bits.length?(" \\u00b7 "+bits.join(" \\u00b7 ")):"";'
|
|
240
245
|
// "how can I easily see what credit i'm left" — asked by a user who
|
package/lib/proxy.js
CHANGED
|
@@ -13,7 +13,8 @@ import { tokenBalance } from './x402.js';
|
|
|
13
13
|
import { evmTokenBalance } from './evm.js';
|
|
14
14
|
import { bindCorpus, contextCacheDisabled, BIND_MIN_CHARS } from './hrr.js';
|
|
15
15
|
import {
|
|
16
|
-
loadBoundChars, noteCorpusLedger, filesForCorpus,
|
|
16
|
+
loadBoundChars, noteCorpusLedger, filesForCorpus, readFilesForCorpus, boundAbsFromKeys,
|
|
17
|
+
stubBoundFileResults, createSpillStats, corpusCharsForSend,
|
|
17
18
|
} from './spill.js';
|
|
18
19
|
import { maybeRewriteModel, rewritablePath, augmentModelList, ALIAS_IDS } from './models.js';
|
|
19
20
|
import { forgetContext } from './contexts.js';
|
|
@@ -374,11 +375,18 @@ async function spillTranscript(body, log, req, stats) {
|
|
|
374
375
|
const msgs = Array.isArray(body?.messages) ? body.messages : null;
|
|
375
376
|
if (!msgs?.length) return null;
|
|
376
377
|
|
|
377
|
-
//
|
|
378
|
-
// so a short agent turn that Read a file never bound it �
|
|
379
|
-
// extract itself returned empty, nothing logged.
|
|
380
|
-
|
|
381
|
-
|
|
378
|
+
// PATHS FIRST, BYTES LATER. The cut/length gates below used to run before
|
|
379
|
+
// filesForCorpus, so a short agent turn that Read a file never bound it �
|
|
380
|
+
// and when the extract itself returned empty, nothing logged. Collect
|
|
381
|
+
// unconditionally, but only paths + cheap stat/mtime: a 2MB Read must not
|
|
382
|
+
// stall this turn. readdir + readFile + bindCorpus run after we return,
|
|
383
|
+
// via setImmediate, so the chat request goes first.
|
|
384
|
+
//
|
|
385
|
+
// Snapshot bound paths BEFORE collect so this turn's first-read files stay
|
|
386
|
+
// verbatim in the tail (not yet in the corpus for recall). Previously
|
|
387
|
+
// bound files get their tool_result bodies stubbed at return time.
|
|
388
|
+
const previouslyBoundAbs = boundAbsFromKeys(boundFiles);
|
|
389
|
+
const fileCollect = filesForCorpus(msgs, { boundFiles });
|
|
382
390
|
const sessionId = req?.headers?.['x-claude-code-session-id']
|
|
383
391
|
|| req?.headers?.['x-session-id']
|
|
384
392
|
|| req?.headers?.['x-claude-session-id']
|
|
@@ -386,31 +394,46 @@ async function spillTranscript(body, log, req, stats) {
|
|
|
386
394
|
let sessionKey = sessionId ? `sid:${sessionId}` : null;
|
|
387
395
|
|
|
388
396
|
const ledgerOpts = () => ({ sessionKey, sessions: sessionLedger, boundFiles });
|
|
389
|
-
const bindFilesInBackground = (label) => {
|
|
390
|
-
if (!
|
|
397
|
+
const bindFilesInBackground = (label, { appendTo: forcedAppend, asAppend = false } = {}) => {
|
|
398
|
+
if (!fileCollect.pending.length) return;
|
|
391
399
|
const known = (sessionKey && spillMemo.get(sessionKey))
|
|
392
400
|
|| (sessionKey && sessionLedger.get(sessionKey))
|
|
393
401
|
|| null;
|
|
394
|
-
const appendTo = known?.contextId || null;
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
|
|
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 });
|
|
402
|
+
const appendTo = forcedAppend !== undefined ? forcedAppend : (known?.contextId || null);
|
|
403
|
+
setImmediate(() => {
|
|
404
|
+
let read;
|
|
405
|
+
try {
|
|
406
|
+
read = readFilesForCorpus(fileCollect, { boundFiles, log });
|
|
407
|
+
} catch (e) {
|
|
408
|
+
log(`${asAppend ? 'file append failed (corpus lags one turn)' : 'file bind failed'}: ${e.message}`);
|
|
409
|
+
return;
|
|
412
410
|
}
|
|
413
|
-
|
|
411
|
+
if (!read.text) return;
|
|
412
|
+
void bindCorpus(read.text, {
|
|
413
|
+
appendTo,
|
|
414
|
+
onStage: (stage, info) => {
|
|
415
|
+
if (stage !== 'binding') return;
|
|
416
|
+
if (asAppend && appendTo) {
|
|
417
|
+
log(`appending ${info.bytes} bytes (${mb(info.bytes)}MB) of FILES to ${appendTo} (background)`);
|
|
418
|
+
} else {
|
|
419
|
+
log(`binding ${info.bytes} bytes (${mb(info.bytes)}MB) of FILES (${label})`);
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
}).then((b) => {
|
|
423
|
+
if (!b?.contextId) return;
|
|
424
|
+
noteCorpusLedger(boundChars, {
|
|
425
|
+
contextId: b.contextId,
|
|
426
|
+
reused: Boolean(appendTo),
|
|
427
|
+
corpusChars: 0,
|
|
428
|
+
fileChars: read.bytes,
|
|
429
|
+
...ledgerOpts(),
|
|
430
|
+
});
|
|
431
|
+
stats?.noteFileBind(read.files, read.bytes);
|
|
432
|
+
if (sessionKey && !spillMemo.has(sessionKey)) {
|
|
433
|
+
spillMemo.set(sessionKey, { corpus: '', contextId: b.contextId, hash: b.hash });
|
|
434
|
+
}
|
|
435
|
+
}).catch((e) => log(`${asAppend ? 'file append failed (corpus lags one turn)' : 'file bind failed'}: ${e.message}`));
|
|
436
|
+
});
|
|
414
437
|
};
|
|
415
438
|
|
|
416
439
|
if (msgs.length < 6) {
|
|
@@ -583,8 +606,9 @@ async function spillTranscript(body, log, req, stats) {
|
|
|
583
606
|
// sitting on this machine. Binding it makes the corpus large immediately
|
|
584
607
|
// instead of eventually, and makes the truncated read whole again.
|
|
585
608
|
//
|
|
586
|
-
// Read-only, bounded, deduped by path+mtime. Path
|
|
587
|
-
//
|
|
609
|
+
// Read-only, bounded, deduped by path+mtime. Path collection already ran at
|
|
610
|
+
// the top of this function (fileCollect.pending). Bytes + dir expansion
|
|
611
|
+
// happen in bindFilesInBackground after this turn is forwarded.
|
|
588
612
|
//
|
|
589
613
|
// FILES RIDE THE BACKGROUND, NEVER THE CRITICAL PATH.
|
|
590
614
|
//
|
|
@@ -709,22 +733,15 @@ async function spillTranscript(body, log, req, stats) {
|
|
|
709
733
|
reused: appended,
|
|
710
734
|
corpusChars: corpus.length,
|
|
711
735
|
deltaChars,
|
|
712
|
-
fileChars:
|
|
736
|
+
fileChars: 0,
|
|
713
737
|
...ledgerOpts(),
|
|
714
738
|
});
|
|
715
739
|
// APPEND THE FILES AFTER, off the clock. Fire-and-forget against the context
|
|
716
740
|
// we just secured: this turn is already answerable without them, and the next
|
|
717
741
|
// ask gets them for free. `boundFiles` already deduped by path:mtime, so this
|
|
718
742
|
// uploads each version exactly once no matter how often the agent re-reads it.
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
void bindCorpus(files, {
|
|
722
|
-
appendTo: bind.contextId,
|
|
723
|
-
onStage: (stage, info) => {
|
|
724
|
-
if (stage === 'binding') log(`appending ${mb(info.bytes)}MB of FILES to ${bind.contextId} (background)`);
|
|
725
|
-
},
|
|
726
|
-
}).catch((e) => log(`file append failed (corpus lags one turn): ${e.message}`));
|
|
727
|
-
}
|
|
743
|
+
// Read + readdir are inside setImmediate � they must not run before send().
|
|
744
|
+
bindFilesInBackground('background', { appendTo: bind.contextId, asAppend: true });
|
|
728
745
|
spillMemo.set(anchor, { corpus, contextId: bind.contextId, hash: bind.hash });
|
|
729
746
|
if (spillMemo.size > 32) spillMemo.delete(spillMemo.keys().next().value);
|
|
730
747
|
const sent = msgs.length - cut;
|
|
@@ -742,6 +759,22 @@ async function spillTranscript(body, log, req, stats) {
|
|
|
742
759
|
? `transcript prefix already bound (${bind.contextId}, ${keyKind}) — sending ${sent}/${msgs.length} turns`
|
|
743
760
|
: `transcript prefix bound (${mb(bind.bytes)}MB → ${bind.contextId}, ${keyKind}) — sending ${sent}/${msgs.length} turns`);
|
|
744
761
|
|
|
762
|
+
// STUB BOUND FILE BODIES IN THE TAIL.
|
|
763
|
+
//
|
|
764
|
+
// Tests bind a 250k pile and send a one-line ask: 7x. Live Claude Code
|
|
765
|
+
// still forwards the last ~13 turns, which are Read/Bash tool_results of
|
|
766
|
+
// those same files. Sent ~= corpus, so counterfactualTokens > promptTokens
|
|
767
|
+
// barely fires and dollars stay ~1.2x with 5MB already bound. After a file
|
|
768
|
+
// is bound, drop its bytes from the forwarded tail (path + marker only).
|
|
769
|
+
// First-read results and non-file tool output stay verbatim. No disk I/O.
|
|
770
|
+
const stubbed = stubBoundFileResults(msgs, {
|
|
771
|
+
boundAbs: previouslyBoundAbs,
|
|
772
|
+
fromIndex: cut,
|
|
773
|
+
});
|
|
774
|
+
if (stubbed.dropped) {
|
|
775
|
+
log(`file-stub stubbed=${stubbed.stubbed} dropped=${stubbed.dropped}`);
|
|
776
|
+
}
|
|
777
|
+
|
|
745
778
|
// ADAPTIVE TOP-K. A fixed 32 chunks is what was actually eating the saving:
|
|
746
779
|
// MEASURED on a 56,265-token corpus, top_k 32 handed 9,990 tokens back and
|
|
747
780
|
// scored 2.45x, while 8 handed back 2,574 and scored 4.73x — same answer,
|
|
@@ -769,13 +802,15 @@ async function spillTranscript(body, log, req, stats) {
|
|
|
769
802
|
const topK = Math.max(4, Math.min(12, Math.round(budget / 320)));
|
|
770
803
|
|
|
771
804
|
return {
|
|
772
|
-
body: Buffer.from(JSON.stringify({ ...body, messages: [...head, ...
|
|
805
|
+
body: Buffer.from(JSON.stringify({ ...body, messages: [...head, ...stubbed.messages.slice(cut)] })),
|
|
773
806
|
topK,
|
|
774
807
|
contextId: bind.contextId,
|
|
775
808
|
hash: bind.hash,
|
|
776
809
|
corpus,
|
|
777
810
|
reused: bind.reused,
|
|
778
811
|
savedBytes: bind.bytes,
|
|
812
|
+
sent,
|
|
813
|
+
msgs: msgs.length,
|
|
779
814
|
};
|
|
780
815
|
}
|
|
781
816
|
|
|
@@ -837,6 +872,8 @@ async function maybeCacheCorpus(req, bodyBuf, log, stats) {
|
|
|
837
872
|
corpus,
|
|
838
873
|
reused: bind.reused,
|
|
839
874
|
savedBytes: bind.bytes,
|
|
875
|
+
sent: 1,
|
|
876
|
+
msgs: msgs.length,
|
|
840
877
|
};
|
|
841
878
|
}
|
|
842
879
|
|
|
@@ -900,6 +937,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
900
937
|
// instead of re-sent, and nothing measured it — the status line showed spend
|
|
901
938
|
// and call count, which is the cost side with none of the benefit.
|
|
902
939
|
const spill = createSpillStats();
|
|
940
|
+
let lastSpillSend = null;
|
|
903
941
|
// Spend/direct for ONLY the calls that spilled. The session-wide savingX
|
|
904
942
|
// averages these with every small turn that had nothing to offload, so it
|
|
905
943
|
// slides toward 1.0 as a conversation grows — which reads as the mechanism
|
|
@@ -1044,13 +1082,14 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1044
1082
|
reachedVia: viaTunnel ? 'public tunnel' : 'localhost',
|
|
1045
1083
|
publicTunnel: tunnelGate?.publicUrl ? `${tunnelGate.publicUrl}/v1` : null,
|
|
1046
1084
|
servedRequests,
|
|
1047
|
-
spilled: {
|
|
1048
|
-
...
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1085
|
+
spilled: (() => {
|
|
1086
|
+
const ledgerTotal = [...boundChars.values()].reduce((a, b) => a + b, 0);
|
|
1087
|
+
return {
|
|
1088
|
+
...spill.snapshot({ boundChars: ledgerTotal }),
|
|
1089
|
+
boundChars: ledgerTotal,
|
|
1090
|
+
lastSend: lastSpillSend,
|
|
1091
|
+
};
|
|
1092
|
+
})(),
|
|
1054
1093
|
spendUsd: sessionSpent,
|
|
1055
1094
|
creditUsd,
|
|
1056
1095
|
// WHAT THE SAME CALLS WOULD HAVE COST DIRECT. Spend on its own is a
|
|
@@ -1504,7 +1543,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1504
1543
|
if (cached) {
|
|
1505
1544
|
spill.noteSpill({ corpusChars: cached.corpus?.length || 0, reused: cached.reused });
|
|
1506
1545
|
didSpill = true;
|
|
1507
|
-
|
|
1546
|
+
if (cached.sent != null) lastSpillSend = { sent: cached.sent, msgs: cached.msgs };
|
|
1547
|
+
result = await send(cached.body, cached.contextId, cached.topK, corpusCharsForSend(boundChars, cached.contextId, cached.corpus?.length));
|
|
1508
1548
|
// Sidecar wiped between runs: the gateway 404s BEFORE the 402 (nothing
|
|
1509
1549
|
// paid). Never fail on a stale manifest — re-bind once and retry.
|
|
1510
1550
|
if (result.response.status === 404) {
|
|
@@ -1513,7 +1553,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1513
1553
|
log('bound context is gone on the zoo — re-binding once...');
|
|
1514
1554
|
forgetContext(config.apiBase, cached.hash);
|
|
1515
1555
|
const rebound = await bindCorpus(cached.corpus, { force: true });
|
|
1516
|
-
result = await send(cached.body, rebound.contextId, cached.topK, boundChars
|
|
1556
|
+
result = await send(cached.body, rebound.contextId, cached.topK, corpusCharsForSend(boundChars, rebound.contextId, cached.corpus?.length));
|
|
1517
1557
|
} else {
|
|
1518
1558
|
res.writeHead(404, { 'content-type': 'application/json' });
|
|
1519
1559
|
res.end(text);
|
package/lib/spill.js
CHANGED
|
@@ -94,24 +94,28 @@ export function accumulateBoundChars(boundChars, contextId, chars, opts = {}) {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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
99
|
*
|
|
100
|
-
*
|
|
101
|
-
* delta; file bytes are added on top either way.
|
|
100
|
+
* next = max(prev, corpusChars) + fileChars
|
|
102
101
|
*/
|
|
103
102
|
export function noteCorpusLedger(boundChars, {
|
|
104
|
-
contextId,
|
|
103
|
+
contextId, corpusChars = 0, fileChars = 0, ...opts
|
|
105
104
|
} = {}) {
|
|
106
105
|
if (!contextId) return 0;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
111
|
}
|
|
112
|
-
if (fileChars) accumulateBoundChars(boundChars, contextId, fileChars, { ...opts, persist: false });
|
|
113
112
|
persistBoundChars(boundChars, opts);
|
|
114
|
-
return
|
|
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);
|
|
115
119
|
}
|
|
116
120
|
|
|
117
121
|
/** Expand ~ and resolve relative paths against cwd. Returns null if unusable. */
|
|
@@ -144,92 +148,164 @@ function parseArgs(args) {
|
|
|
144
148
|
return {};
|
|
145
149
|
}
|
|
146
150
|
|
|
147
|
-
function
|
|
148
|
-
if (
|
|
149
|
-
|
|
150
|
-
if (
|
|
151
|
-
const t = value.trim();
|
|
152
|
-
if ((t.startsWith('{') || t.startsWith('[')) && t.length < 100_000) {
|
|
153
|
-
try { collectFromValue(JSON.parse(t), out, depth + 1); } catch { /* not json */ }
|
|
154
|
-
}
|
|
155
|
-
return;
|
|
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]);
|
|
156
155
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
+
}
|
|
160
162
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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 });
|
|
171
198
|
}
|
|
172
199
|
}
|
|
200
|
+
return { paths: found, cwd: localCwd };
|
|
173
201
|
}
|
|
174
202
|
|
|
175
203
|
/**
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
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.
|
|
181
209
|
*/
|
|
182
|
-
export function
|
|
183
|
-
const
|
|
184
|
-
|
|
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 };
|
|
185
215
|
for (const m of msgs) {
|
|
186
216
|
if (!m || typeof m !== 'object') continue;
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (
|
|
190
|
-
if (b.input) collectFromValue(b.input, raw, 0);
|
|
191
|
-
for (const k of PATH_KEYS) {
|
|
192
|
-
if (typeof b[k] === 'string') raw.push(b[k]);
|
|
193
|
-
}
|
|
194
|
-
// tool_result: only structured content, never a long body string
|
|
195
|
-
if (b.type === 'tool_result' && b.content && typeof b.content === 'object') {
|
|
196
|
-
collectFromValue(b.content, raw, 0);
|
|
197
|
-
} else if (b.type === 'tool_result' && typeof b.content === 'string' && b.content.length < 512 && looksLikePath(b.content)) {
|
|
198
|
-
raw.push(b.content.trim());
|
|
199
|
-
}
|
|
217
|
+
if (m.role === 'tool' && typeof m.content === 'string') {
|
|
218
|
+
const hint = parseCwdHint(m.content);
|
|
219
|
+
if (hint) currentCwd = hint;
|
|
200
220
|
}
|
|
201
221
|
const calls = [
|
|
202
222
|
...(Array.isArray(m.tool_calls) ? m.tool_calls : []),
|
|
203
223
|
...(m.function_call ? [m.function_call] : []),
|
|
204
224
|
];
|
|
205
225
|
for (const c of calls) {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
+
}
|
|
210
249
|
}
|
|
211
250
|
}
|
|
212
251
|
}
|
|
252
|
+
return { structured, bash, cwd: currentCwd };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function extractFilePaths(msgs, opts) {
|
|
256
|
+
const { structured, bash } = extractFileCandidates(msgs, opts);
|
|
213
257
|
const seen = new Set();
|
|
214
258
|
const out = [];
|
|
215
|
-
for (const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
seen.
|
|
220
|
-
|
|
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);
|
|
221
266
|
}
|
|
222
267
|
return out;
|
|
223
268
|
}
|
|
224
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
|
+
function emptyFileBind({ reason = 'none-kept', ...extra } = {}) {
|
|
277
|
+
return {
|
|
278
|
+
text: '',
|
|
279
|
+
files: 0,
|
|
280
|
+
bytes: 0,
|
|
281
|
+
pending: [],
|
|
282
|
+
kept: 0,
|
|
283
|
+
enoent: 0,
|
|
284
|
+
cap: 0,
|
|
285
|
+
dir: 0,
|
|
286
|
+
rel: 0,
|
|
287
|
+
bash: 0,
|
|
288
|
+
reason,
|
|
289
|
+
...extra,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
225
293
|
/**
|
|
226
|
-
*
|
|
294
|
+
* Request-path filebind: collect paths + cheap stat/mtime only.
|
|
295
|
+
*
|
|
296
|
+
* Live path is OpenAI tool_calls (Read/Edit/Write + Bash command). Relative
|
|
297
|
+
* paths resolve against cwd / last "current working directory is …" hint.
|
|
227
298
|
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
299
|
+
* MUST NOT read file contents and MUST NOT readdir children. A 2MB Read
|
|
300
|
+
* (or a directory the agent listed) used to stall the chat turn here.
|
|
301
|
+
* Bytes, directory expansion, and bindCorpus belong in readFilesForCorpus,
|
|
302
|
+
* which the proxy runs after the turn is already on the wire.
|
|
230
303
|
*
|
|
231
|
-
*
|
|
232
|
-
* so
|
|
304
|
+
* Dedupes on path+mtime into `boundFiles`, applies the size cap, and
|
|
305
|
+
* reserves directory keys so the same tree is not re-queued every turn.
|
|
306
|
+
* The `file-bind kept=N bytes=B …` line is logged here only when there is
|
|
307
|
+
* nothing to read (disabled / none-kept) — otherwise readFilesForCorpus
|
|
308
|
+
* fills bytes after the background read, never on the hot path.
|
|
233
309
|
*/
|
|
234
310
|
export function filesForCorpus(msgs, {
|
|
235
311
|
boundFiles,
|
|
@@ -238,54 +314,300 @@ export function filesForCorpus(msgs, {
|
|
|
238
314
|
disabled = process.env.OPENZOO_BIND_FILES === '0',
|
|
239
315
|
log = () => {},
|
|
240
316
|
statSync = (p) => fs.statSync(p),
|
|
241
|
-
readFileSync = (p) => fs.readFileSync(p, 'utf8'),
|
|
242
317
|
} = {}) {
|
|
243
318
|
if (disabled) {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const paths = extractFilePaths(msgs);
|
|
248
|
-
if (!paths.length) {
|
|
249
|
-
log('file-bind 0 because no file paths in tool_use / tool_calls');
|
|
250
|
-
return { text: '', files: 0, bytes: 0, reason: 'no-paths' };
|
|
319
|
+
const empty = emptyFileBind({ reason: 'disabled' });
|
|
320
|
+
log(fileBindLog(empty));
|
|
321
|
+
return empty;
|
|
251
322
|
}
|
|
323
|
+
const { structured, bash } = extractFileCandidates(msgs, { cwd });
|
|
324
|
+
const candidates = [
|
|
325
|
+
...structured.map((p) => ({ ...p, bash: false })),
|
|
326
|
+
...bash.map((p) => ({ ...p, bash: true })),
|
|
327
|
+
];
|
|
328
|
+
const skip = { enoent: 0, cap: 0, dir: 0, rel: 0 };
|
|
252
329
|
const seen = new Set();
|
|
330
|
+
const pending = [];
|
|
331
|
+
|
|
332
|
+
const queue = (abs) => {
|
|
333
|
+
if (!abs || seen.has(abs)) return;
|
|
334
|
+
seen.add(abs);
|
|
335
|
+
let st;
|
|
336
|
+
try { st = statSync(abs); } catch { skip.enoent += 1; return; }
|
|
337
|
+
if (st.isDirectory()) {
|
|
338
|
+
skip.dir += 1;
|
|
339
|
+
const key = `${abs}:${st.mtimeMs}`;
|
|
340
|
+
if (boundFiles?.has(key)) return;
|
|
341
|
+
boundFiles?.add(key);
|
|
342
|
+
pending.push({ abs, kind: 'dir' });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (!st.isFile()) { skip.enoent += 1; return; }
|
|
346
|
+
if (st.size > cap) { skip.cap += 1; return; }
|
|
347
|
+
const key = `${abs}:${st.mtimeMs}`;
|
|
348
|
+
if (boundFiles?.has(key)) return;
|
|
349
|
+
boundFiles?.add(key);
|
|
350
|
+
pending.push({ abs, kind: 'file' });
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
for (const item of candidates) {
|
|
354
|
+
const raw = item.raw;
|
|
355
|
+
if (!path.isAbsolute(raw) && !(raw.startsWith('~/') || raw === '~')) skip.rel += 1;
|
|
356
|
+
const abs = resolveReadablePath(raw, item.cwd || cwd);
|
|
357
|
+
if (!abs) { skip.enoent += 1; continue; }
|
|
358
|
+
queue(abs);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const stats = {
|
|
362
|
+
kept: 0,
|
|
363
|
+
bytes: 0,
|
|
364
|
+
enoent: skip.enoent,
|
|
365
|
+
cap: skip.cap,
|
|
366
|
+
dir: skip.dir,
|
|
367
|
+
rel: skip.rel,
|
|
368
|
+
bash: bash.length,
|
|
369
|
+
};
|
|
370
|
+
if (!pending.length) log(fileBindLog(stats));
|
|
371
|
+
return {
|
|
372
|
+
text: '',
|
|
373
|
+
files: 0,
|
|
374
|
+
bytes: 0,
|
|
375
|
+
pending,
|
|
376
|
+
reason: pending.length ? null : 'none-kept',
|
|
377
|
+
...stats,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Background filebind: expand directories, read bytes, log the real totals.
|
|
383
|
+
*
|
|
384
|
+
* Takes the `pending` list (or the whole collect result) from filesForCorpus.
|
|
385
|
+
* Children of a directory skip node_modules/.git/dist/build/__pycache__/.venv/target
|
|
386
|
+
* and hidden names. Same size cap as the request-path collector.
|
|
387
|
+
*
|
|
388
|
+
* Always logs `file-bind kept=N bytes=B skip enoent=X cap=Y dir=Z rel=W bash=K`
|
|
389
|
+
* so grep-for-FILES is no longer the only signal. Bytes, not 0.0MB.
|
|
390
|
+
*/
|
|
391
|
+
export function readFilesForCorpus(collected, {
|
|
392
|
+
boundFiles,
|
|
393
|
+
cap = Number(process.env.OPENZOO_BIND_FILE_MAX || 400_000),
|
|
394
|
+
log = () => {},
|
|
395
|
+
statSync = (p) => fs.statSync(p),
|
|
396
|
+
readFileSync = (p) => fs.readFileSync(p, 'utf8'),
|
|
397
|
+
readdirSync = (p) => fs.readdirSync(p),
|
|
398
|
+
} = {}) {
|
|
399
|
+
const pending = Array.isArray(collected) ? collected : (collected?.pending || []);
|
|
400
|
+
const skip = {
|
|
401
|
+
enoent: Number(collected?.enoent) || 0,
|
|
402
|
+
cap: Number(collected?.cap) || 0,
|
|
403
|
+
dir: Number(collected?.dir) || 0,
|
|
404
|
+
rel: Number(collected?.rel) || 0,
|
|
405
|
+
};
|
|
406
|
+
const bash = Number(collected?.bash) || 0;
|
|
253
407
|
const chunks = [];
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
const p = resolveReadablePath(raw, cwd);
|
|
260
|
-
if (!p) { skippedRelative += 1; continue; }
|
|
261
|
-
if (seen.has(p)) continue;
|
|
262
|
-
seen.add(p);
|
|
408
|
+
const readSeen = new Set();
|
|
409
|
+
|
|
410
|
+
const readOne = (abs) => {
|
|
411
|
+
if (!abs || readSeen.has(abs)) return;
|
|
412
|
+
readSeen.add(abs);
|
|
263
413
|
try {
|
|
264
|
-
|
|
265
|
-
if (!st.isFile()) { skippedMissing += 1; continue; }
|
|
266
|
-
if (st.size > cap) { skippedCap += 1; continue; }
|
|
267
|
-
const key = `${p}:${st.mtimeMs}`;
|
|
268
|
-
if (boundFiles?.has(key)) { skippedBound += 1; continue; }
|
|
269
|
-
boundFiles?.add(key);
|
|
270
|
-
chunks.push(`FILE ${p}\n${readFileSync(p)}`);
|
|
414
|
+
chunks.push(`FILE ${abs}\n${readFileSync(abs)}`);
|
|
271
415
|
} catch {
|
|
272
|
-
|
|
416
|
+
skip.enoent += 1;
|
|
273
417
|
}
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
const bindChild = (abs) => {
|
|
421
|
+
let st;
|
|
422
|
+
try { st = statSync(abs); } catch { skip.enoent += 1; return; }
|
|
423
|
+
if (!st.isFile()) return;
|
|
424
|
+
if (st.size > cap) { skip.cap += 1; return; }
|
|
425
|
+
const key = `${abs}:${st.mtimeMs}`;
|
|
426
|
+
if (boundFiles?.has(key)) return;
|
|
427
|
+
boundFiles?.add(key);
|
|
428
|
+
readOne(abs);
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
for (const item of pending) {
|
|
432
|
+
const abs = typeof item === 'string' ? item : item?.abs;
|
|
433
|
+
if (!abs) continue;
|
|
434
|
+
if (item?.kind === 'dir') {
|
|
435
|
+
let kids = [];
|
|
436
|
+
try { kids = readdirSync(abs); } catch { skip.enoent += 1; continue; }
|
|
437
|
+
for (const name of kids) {
|
|
438
|
+
if (!name || name.startsWith('.') || SKIP_DIR_NAMES.has(name)) continue;
|
|
439
|
+
bindChild(path.join(abs, name));
|
|
440
|
+
}
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
readOne(abs);
|
|
274
444
|
}
|
|
275
|
-
|
|
276
|
-
const why = skippedBound && !skippedMissing && !skippedCap
|
|
277
|
-
? 'already bound'
|
|
278
|
-
: skippedCap && !skippedMissing
|
|
279
|
-
? `over OPENZOO_BIND_FILE_MAX (${cap})`
|
|
280
|
-
: skippedMissing
|
|
281
|
-
? 'unreadable or not a file'
|
|
282
|
-
: 'paths did not resolve';
|
|
283
|
-
log(`file-bind 0 because ${why}`);
|
|
284
|
-
return { text: '', files: 0, bytes: 0, reason: why };
|
|
285
|
-
}
|
|
445
|
+
|
|
286
446
|
const text = chunks.join('\n\n');
|
|
287
|
-
|
|
288
|
-
|
|
447
|
+
const stats = {
|
|
448
|
+
kept: chunks.length,
|
|
449
|
+
bytes: text.length,
|
|
450
|
+
enoent: skip.enoent,
|
|
451
|
+
cap: skip.cap,
|
|
452
|
+
dir: skip.dir,
|
|
453
|
+
rel: skip.rel,
|
|
454
|
+
bash,
|
|
455
|
+
};
|
|
456
|
+
log(fileBindLog(stats));
|
|
457
|
+
return { text, files: chunks.length, bytes: text.length, reason: chunks.length ? null : 'none-kept', ...stats };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* path:mtime keys -> absolute paths. mtime is always the last `:Number` segment
|
|
462
|
+
* so `C:\foo:1734.2` still splits correctly.
|
|
463
|
+
*/
|
|
464
|
+
export function boundAbsFromKeys(boundFiles) {
|
|
465
|
+
const out = new Set();
|
|
466
|
+
if (!boundFiles) return out;
|
|
467
|
+
for (const key of boundFiles) {
|
|
468
|
+
if (typeof key !== 'string' || !key) continue;
|
|
469
|
+
const i = key.lastIndexOf(':');
|
|
470
|
+
if (i <= 0) { out.add(key); continue; }
|
|
471
|
+
const rest = key.slice(i + 1);
|
|
472
|
+
if (rest !== '' && Number.isFinite(Number(rest))) out.add(key.slice(0, i));
|
|
473
|
+
else out.add(key);
|
|
474
|
+
}
|
|
475
|
+
return out;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const FILE_VIEW = /^(head|tail|cat|less|more|type|Get-Content|gc)\b/i;
|
|
479
|
+
|
|
480
|
+
/** `head -80 notes.md` and `cd dir && cat x` count; `npm test` and `cat x | rg y` do not. */
|
|
481
|
+
export function looksLikeFileView(command) {
|
|
482
|
+
if (typeof command !== 'string' || !command.trim()) return false;
|
|
483
|
+
let sawView = false;
|
|
484
|
+
for (const part of command.split(/(?:&&|\|\||;|\n)/)) {
|
|
485
|
+
const t = part.trim();
|
|
486
|
+
if (!t) continue;
|
|
487
|
+
if (/^cd\s+/.test(t)) continue;
|
|
488
|
+
if (/\|/.test(t)) return false;
|
|
489
|
+
if (FILE_VIEW.test(t)) { sawView = true; continue; }
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
return sawView;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export function fileBoundStub(paths) {
|
|
496
|
+
const list = [...new Set((paths || []).filter(Boolean))].join(' ');
|
|
497
|
+
return list ? `FILE ${list} [bound]` : 'FILE [bound]';
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function toolContentLength(content) {
|
|
501
|
+
if (typeof content === 'string') return content.length;
|
|
502
|
+
if (Array.isArray(content)) {
|
|
503
|
+
return content.reduce((n, b) => n + (typeof b === 'string' ? b.length : String(b?.text ?? b?.content ?? '').length), 0);
|
|
504
|
+
}
|
|
505
|
+
if (content && typeof content === 'object') return JSON.stringify(content).length;
|
|
506
|
+
return 0;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function resolveBoundPath(raw, cwd, boundAbs) {
|
|
510
|
+
if (!boundAbs?.size) return null;
|
|
511
|
+
const t = typeof raw === 'string' ? raw.trim() : '';
|
|
512
|
+
if (!t) return null;
|
|
513
|
+
const abs = resolveReadablePath(t, cwd);
|
|
514
|
+
if (abs && boundAbs.has(abs)) return abs;
|
|
515
|
+
if (boundAbs.has(t)) return t;
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* After a file is bound, drop its tool_result / file body from the forwarded
|
|
521
|
+
* tail. Keep the path and a short marker. The model already has the bytes in
|
|
522
|
+
* the bound corpus via recall; shipping them again makes sent ≈ corpus and
|
|
523
|
+
* the gateway's `counterfactualTokens > promptTokens` gate barely fires
|
|
524
|
+
* (live: 5MB filebind, lastSend 13/107, savingX 1.22 instead of ~7x).
|
|
525
|
+
*
|
|
526
|
+
* Cheap rewrite — no disk I/O. First-read results (not yet in boundAbs) and
|
|
527
|
+
* non-file tool output (npm test, grep, …) stay verbatim. The ask stays.
|
|
528
|
+
*
|
|
529
|
+
* `fromIndex` limits the rewrite to the forwarded tail so the spilled prefix
|
|
530
|
+
* that becomes the conversation corpus is unchanged.
|
|
531
|
+
*/
|
|
532
|
+
export function stubBoundFileResults(msgs, {
|
|
533
|
+
boundFiles,
|
|
534
|
+
boundAbs,
|
|
535
|
+
cwd = process.cwd(),
|
|
536
|
+
fromIndex = 0,
|
|
537
|
+
} = {}) {
|
|
538
|
+
const absSet = boundAbs || boundAbsFromKeys(boundFiles);
|
|
539
|
+
if (!Array.isArray(msgs) || !absSet.size) {
|
|
540
|
+
return { messages: msgs, stubbed: 0, dropped: 0 };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const stubIds = new Set();
|
|
544
|
+
const idPaths = new Map();
|
|
545
|
+
let currentCwd = cwd;
|
|
546
|
+
|
|
547
|
+
const noteCall = (c) => {
|
|
548
|
+
const id = c?.id || c?.tool_call_id;
|
|
549
|
+
const args = parseArgs(c?.function?.arguments ?? c?.arguments);
|
|
550
|
+
const raws = [];
|
|
551
|
+
collectStructuredPaths(args, raws);
|
|
552
|
+
if (typeof args.command === 'string' && looksLikeFileView(args.command)) {
|
|
553
|
+
for (const p of extractBashPaths(args.command, currentCwd).paths) raws.push(p.raw);
|
|
554
|
+
}
|
|
555
|
+
const resolved = [];
|
|
556
|
+
for (const raw of raws) {
|
|
557
|
+
const hit = resolveBoundPath(raw, currentCwd, absSet);
|
|
558
|
+
if (hit) resolved.push(hit);
|
|
559
|
+
}
|
|
560
|
+
if (!resolved.length || !id) return;
|
|
561
|
+
stubIds.add(id);
|
|
562
|
+
idPaths.set(id, [...(idPaths.get(id) || []), ...resolved]);
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
for (const m of msgs) {
|
|
566
|
+
if (!m || typeof m !== 'object') continue;
|
|
567
|
+
if (m.role === 'tool' && typeof m.content === 'string') {
|
|
568
|
+
const hint = parseCwdHint(m.content);
|
|
569
|
+
if (hint) currentCwd = hint;
|
|
570
|
+
}
|
|
571
|
+
const calls = [
|
|
572
|
+
...(Array.isArray(m.tool_calls) ? m.tool_calls : []),
|
|
573
|
+
...(m.function_call ? [m.function_call] : []),
|
|
574
|
+
];
|
|
575
|
+
for (const c of calls) noteCall(c);
|
|
576
|
+
const blocks = Array.isArray(m.content) ? m.content : [];
|
|
577
|
+
for (const b of blocks) {
|
|
578
|
+
if (b?.type === 'tool_use') {
|
|
579
|
+
noteCall({ id: b.id, arguments: b.input, function: { name: b.name, arguments: b.input } });
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (!stubIds.size) return { messages: msgs, stubbed: 0, dropped: 0 };
|
|
585
|
+
|
|
586
|
+
let stubbed = 0;
|
|
587
|
+
let dropped = 0;
|
|
588
|
+
const messages = msgs.map((m, i) => {
|
|
589
|
+
if (i < fromIndex || !m) return m;
|
|
590
|
+
if (m.role === 'tool' && stubIds.has(m.tool_call_id)) {
|
|
591
|
+
const n = toolContentLength(m.content);
|
|
592
|
+
if (!n) return m;
|
|
593
|
+
dropped += n;
|
|
594
|
+
stubbed += 1;
|
|
595
|
+
return { ...m, content: fileBoundStub(idPaths.get(m.tool_call_id)) };
|
|
596
|
+
}
|
|
597
|
+
if (!Array.isArray(m.content)) return m;
|
|
598
|
+
let changed = false;
|
|
599
|
+
const blocks = m.content.map((b) => {
|
|
600
|
+
if (b?.type !== 'tool_result' || !stubIds.has(b.tool_use_id)) return b;
|
|
601
|
+
const n = toolContentLength(b.content);
|
|
602
|
+
if (!n) return b;
|
|
603
|
+
dropped += n;
|
|
604
|
+
stubbed += 1;
|
|
605
|
+
changed = true;
|
|
606
|
+
return { ...b, content: fileBoundStub(idPaths.get(b.tool_use_id)) };
|
|
607
|
+
});
|
|
608
|
+
return changed ? { ...m, content: blocks } : m;
|
|
609
|
+
});
|
|
610
|
+
return { messages, stubbed, dropped };
|
|
289
611
|
}
|
|
290
612
|
|
|
291
613
|
/** Session counters the HUD reads off /v1/info. */
|
|
@@ -309,11 +631,13 @@ export function createSpillStats() {
|
|
|
309
631
|
this.fileBindBytes += bytes;
|
|
310
632
|
this.spilledChars += bytes;
|
|
311
633
|
},
|
|
312
|
-
snapshot() {
|
|
634
|
+
snapshot({ boundChars = null } = {}) {
|
|
635
|
+
const unique = boundChars == null ? this.spilledChars : boundChars;
|
|
313
636
|
return {
|
|
314
637
|
calls: this.spillCalls,
|
|
315
638
|
chars: this.spilledChars,
|
|
316
|
-
|
|
639
|
+
// Unique bound size — do not re-add the same prefix on every reuse.
|
|
640
|
+
tokensApprox: Math.round(unique / 4),
|
|
317
641
|
reusedBinds: this.spillReuses,
|
|
318
642
|
fileBinds: this.fileBinds,
|
|
319
643
|
fileBindBytes: this.fileBindBytes,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.48.
|
|
3
|
+
"version": "0.48.72",
|
|
4
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",
|