openzoo 0.48.70 → 0.48.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/launch.js CHANGED
@@ -230,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,7 @@ 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, createSpillStats, corpusCharsForSend,
17
17
  } from './spill.js';
18
18
  import { maybeRewriteModel, rewritablePath, augmentModelList, ALIAS_IDS } from './models.js';
19
19
  import { forgetContext } from './contexts.js';
@@ -395,7 +395,7 @@ async function spillTranscript(body, log, req, stats) {
395
395
  void bindCorpus(files, {
396
396
  appendTo,
397
397
  onStage: (stage, info) => {
398
- if (stage === 'binding') log(`binding ${mb(info.bytes)}MB of FILES (${label})`);
398
+ if (stage === 'binding') log(`binding ${info.bytes} bytes (${mb(info.bytes)}MB) of FILES (${label})`);
399
399
  },
400
400
  }).then((b) => {
401
401
  if (!b?.contextId) return;
@@ -721,7 +721,7 @@ async function spillTranscript(body, log, req, stats) {
721
721
  void bindCorpus(files, {
722
722
  appendTo: bind.contextId,
723
723
  onStage: (stage, info) => {
724
- 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)`);
725
725
  },
726
726
  }).catch((e) => log(`file append failed (corpus lags one turn): ${e.message}`));
727
727
  }
@@ -776,6 +776,8 @@ async function spillTranscript(body, log, req, stats) {
776
776
  corpus,
777
777
  reused: bind.reused,
778
778
  savedBytes: bind.bytes,
779
+ sent,
780
+ msgs: msgs.length,
779
781
  };
780
782
  }
781
783
 
@@ -837,6 +839,8 @@ async function maybeCacheCorpus(req, bodyBuf, log, stats) {
837
839
  corpus,
838
840
  reused: bind.reused,
839
841
  savedBytes: bind.bytes,
842
+ sent: 1,
843
+ msgs: msgs.length,
840
844
  };
841
845
  }
842
846
 
@@ -900,6 +904,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
900
904
  // instead of re-sent, and nothing measured it — the status line showed spend
901
905
  // and call count, which is the cost side with none of the benefit.
902
906
  const spill = createSpillStats();
907
+ let lastSpillSend = null;
903
908
  // Spend/direct for ONLY the calls that spilled. The session-wide savingX
904
909
  // averages these with every small turn that had nothing to offload, so it
905
910
  // slides toward 1.0 as a conversation grows — which reads as the mechanism
@@ -1044,13 +1049,14 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1044
1049
  reachedVia: viaTunnel ? 'public tunnel' : 'localhost',
1045
1050
  publicTunnel: tunnelGate?.publicUrl ? `${tunnelGate.publicUrl}/v1` : null,
1046
1051
  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
- },
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
+ })(),
1054
1060
  spendUsd: sessionSpent,
1055
1061
  creditUsd,
1056
1062
  // WHAT THE SAME CALLS WOULD HAVE COST DIRECT. Spend on its own is a
@@ -1504,7 +1510,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1504
1510
  if (cached) {
1505
1511
  spill.noteSpill({ corpusChars: cached.corpus?.length || 0, reused: cached.reused });
1506
1512
  didSpill = true;
1507
- 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));
1508
1515
  // Sidecar wiped between runs: the gateway 404s BEFORE the 402 (nothing
1509
1516
  // paid). Never fail on a stale manifest — re-bind once and retry.
1510
1517
  if (result.response.status === 404) {
@@ -1513,7 +1520,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1513
1520
  log('bound context is gone on the zoo — re-binding once...');
1514
1521
  forgetContext(config.apiBase, cached.hash);
1515
1522
  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);
1523
+ result = await send(cached.body, rebound.contextId, cached.topK, corpusCharsForSend(boundChars, rebound.contextId, cached.corpus?.length));
1517
1524
  } else {
1518
1525
  res.writeHead(404, { 'content-type': 'application/json' });
1519
1526
  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,140 @@ 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
+
225
276
  /**
226
277
  * Read every new file the agent touched and return the corpus slice to bind.
227
278
  *
228
- * Read-only, size-capped, path+mtime deduped. Failures never throw this
229
- * runs on the request path.
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.
230
282
  *
231
- * Always reports `file-bind N files / X bytes` or `file-bind 0 because …`
232
- * so a silent empty extract cannot hide again.
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.
233
285
  */
234
286
  export function filesForCorpus(msgs, {
235
287
  boundFiles,
@@ -239,53 +291,72 @@ export function filesForCorpus(msgs, {
239
291
  log = () => {},
240
292
  statSync = (p) => fs.statSync(p),
241
293
  readFileSync = (p) => fs.readFileSync(p, 'utf8'),
294
+ readdirSync = (p) => fs.readdirSync(p),
242
295
  } = {}) {
296
+ const empty = { kept: 0, bytes: 0, enoent: 0, cap: 0, dir: 0, rel: 0, bash: 0 };
243
297
  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' };
298
+ log(fileBindLog(empty));
299
+ return { text: '', files: 0, bytes: 0, reason: 'disabled', ...empty };
251
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 };
252
307
  const seen = new Set();
253
308
  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);
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);
263
333
  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)}`);
334
+ chunks.push(`FILE ${abs}\n${readFileSync(abs)}`);
271
335
  } catch {
272
- skippedMissing += 1;
336
+ skip.enoent += 1;
273
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);
274
346
  }
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
- }
347
+
286
348
  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 };
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 };
289
360
  }
290
361
 
291
362
  /** Session counters the HUD reads off /v1/info. */
@@ -309,11 +380,13 @@ export function createSpillStats() {
309
380
  this.fileBindBytes += bytes;
310
381
  this.spilledChars += bytes;
311
382
  },
312
- snapshot() {
383
+ snapshot({ boundChars = null } = {}) {
384
+ const unique = boundChars == null ? this.spilledChars : boundChars;
313
385
  return {
314
386
  calls: this.spillCalls,
315
387
  chars: this.spilledChars,
316
- tokensApprox: Math.round(this.spilledChars / 4),
388
+ // Unique bound size — do not re-add the same prefix on every reuse.
389
+ tokensApprox: Math.round(unique / 4),
317
390
  reusedBinds: this.spillReuses,
318
391
  fileBinds: this.fileBinds,
319
392
  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.71",
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",