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 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
- + 'if(sc||j.paidCalls)bits.push(sc+" spilled");'
237
- + 'if(fb||sc)bits.push(fb+" filebind");'
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, createSpillStats,
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
- // 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;
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 (!files) return;
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
- void bindCorpus(files, {
396
- appendTo,
397
- onStage: (stage, info) => {
398
- if (stage === 'binding') log(`binding ${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 });
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
- }).catch((e) => log(`file bind failed: ${e.message}`));
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 extraction + the file-bind
587
- // log already ran at the top of this function (files / fileResult).
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: files.length,
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
- if (files) {
720
- stats?.noteFileBind(fileResult.files, fileResult.bytes);
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, ...msgs.slice(cut)] })),
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
- ...spill.snapshot(),
1049
- // ~4 chars/token is the usual rough rule; this is the context that
1050
- // did NOT ride upstream on those calls, which is the number the
1051
- // saving is actually made of.
1052
- boundChars: [...boundChars.values()].reduce((a, b) => a + b, 0),
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
- result = await send(cached.body, cached.contextId, cached.topK, boundChars.get(cached.contextId) || cached.corpus?.length);
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.get(rebound.contextId) || cached.corpus?.length);
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
- * Apply the bind/append/file rule in one place so spillTranscript and the
98
- * tests cannot drift.
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
- * First bind initializes to the conversation corpus; each append adds the
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, reused, corpusChars = 0, deltaChars = 0, fileChars = 0, ...opts
103
+ contextId, corpusChars = 0, fileChars = 0, ...opts
105
104
  } = {}) {
106
105
  if (!contextId) return 0;
107
- if (reused) {
108
- if (deltaChars) accumulateBoundChars(boundChars, contextId, deltaChars, { ...opts, persist: false });
109
- } else {
110
- accumulateBoundChars(boundChars, contextId, corpusChars, { ...opts, init: true, persist: false });
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 boundChars.get(contextId) || 0;
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 collectFromValue(value, out, depth) {
148
- if (depth > 6 || value == null) return;
149
- if (typeof value === 'string') {
150
- if (looksLikePath(value)) out.push(value);
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
- if (Array.isArray(value)) {
158
- for (const x of value) collectFromValue(x, out, depth + 1);
159
- return;
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
- if (typeof value !== 'object') return;
162
- for (const [k, v] of Object.entries(value)) {
163
- if (PATH_KEYS.has(k) && typeof v === 'string' && v) out.push(v);
164
- else if (PATH_ARRAY_KEYS.has(k) && Array.isArray(v)) {
165
- for (const x of v) {
166
- if (typeof x === 'string') out.push(x);
167
- else collectFromValue(x, out, depth + 1);
168
- }
169
- } else if (k === 'input' || k === 'arguments' || k === 'params' || k === 'parameters') {
170
- collectFromValue(typeof v === 'string' ? parseArgs(v) : v, out, depth + 1);
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
- * Pull file paths out of a transcript that may still be Anthropic-shaped,
177
- * already translated to OpenAI tool_calls, or a mix (Responses → chat).
178
- *
179
- * Structured fields only never walks tool_result *bodies*, which are file
180
- * contents and would harvest every import path in the source.
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 extractFilePaths(msgs) {
183
- const raw = [];
184
- if (!Array.isArray(msgs)) return [];
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
- const blocks = Array.isArray(m.content) ? m.content : [];
188
- for (const b of blocks) {
189
- if (!b || typeof b !== 'object') continue;
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
- collectFromValue(parseArgs(c?.function?.arguments ?? c?.arguments), raw, 0);
207
- if (c?.input) collectFromValue(c.input, raw, 0);
208
- if (typeof c?.function?.name === 'string' && c.function.arguments == null && typeof c.name === 'string') {
209
- collectFromValue(c, raw, 0);
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 p of raw) {
216
- if (typeof p !== 'string') continue;
217
- const t = p.trim();
218
- if (!t || seen.has(t)) continue;
219
- seen.add(t);
220
- out.push(t);
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
- * Read every new file the agent touched and return the corpus slice to bind.
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
- * Read-only, size-capped, path+mtime deduped. Failures never throw this
229
- * runs on the request path.
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
- * Always reports `file-bind N files / X bytes` or `file-bind 0 because …`
232
- * so a silent empty extract cannot hide again.
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
- log('file-bind 0 because OPENZOO_BIND_FILES=0');
245
- return { text: '', files: 0, bytes: 0, reason: 'disabled' };
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
- let skippedCap = 0;
255
- let skippedBound = 0;
256
- let skippedMissing = 0;
257
- let skippedRelative = 0;
258
- for (const raw of paths) {
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
- const st = statSync(p);
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
- skippedMissing += 1;
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
- if (!chunks.length) {
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
- log(`file-bind ${chunks.length} files / ${text.length} bytes`);
288
- return { text, files: chunks.length, bytes: text.length, reason: null };
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
- tokensApprox: Math.round(this.spilledChars / 4),
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.70",
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",