openzoo 0.48.71 → 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.
Files changed (3) hide show
  1. package/lib/proxy.js +73 -40
  2. package/lib/spill.js +277 -26
  3. package/package.json +1 -1
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, corpusCharsForSend,
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 ${info.bytes} bytes (${mb(info.bytes)}MB) of FILES (${label})`);
399
- },
400
- }).then((b) => {
401
- if (!b?.contextId) return;
402
- noteCorpusLedger(boundChars, {
403
- contextId: b.contextId,
404
- reused: Boolean(appendTo),
405
- corpusChars: 0,
406
- fileChars: files.length,
407
- ...ledgerOpts(),
408
- });
409
- stats?.noteFileBind(fileResult.files, fileResult.bytes);
410
- if (sessionKey && !spillMemo.has(sessionKey)) {
411
- spillMemo.set(sessionKey, { corpus: '', contextId: b.contextId, hash: b.hash });
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 ${info.bytes} bytes (${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,7 +802,7 @@ 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,
package/lib/spill.js CHANGED
@@ -273,15 +273,39 @@ function fileBindLog({ kept, bytes, enoent, cap, dir, rel, bash }) {
273
273
  return `file-bind kept=${kept} bytes=${bytes} skip enoent=${enoent} cap=${cap} dir=${dir} rel=${rel} bash=${bash}`;
274
274
  }
275
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
+
276
293
  /**
277
- * Read every new file the agent touched and return the corpus slice to bind.
294
+ * Request-path filebind: collect paths + cheap stat/mtime only.
278
295
  *
279
296
  * Live path is OpenAI tool_calls (Read/Edit/Write + Bash command). Relative
280
297
  * paths resolve against cwd / last "current working directory is …" hint.
281
- * Directories expand to children that are files under the cap.
282
298
  *
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.
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.
303
+ *
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.
285
309
  */
286
310
  export function filesForCorpus(msgs, {
287
311
  boundFiles,
@@ -290,13 +314,11 @@ export function filesForCorpus(msgs, {
290
314
  disabled = process.env.OPENZOO_BIND_FILES === '0',
291
315
  log = () => {},
292
316
  statSync = (p) => fs.statSync(p),
293
- readFileSync = (p) => fs.readFileSync(p, 'utf8'),
294
- readdirSync = (p) => fs.readdirSync(p),
295
317
  } = {}) {
296
- const empty = { kept: 0, bytes: 0, enoent: 0, cap: 0, dir: 0, rel: 0, bash: 0 };
297
318
  if (disabled) {
319
+ const empty = emptyFileBind({ reason: 'disabled' });
298
320
  log(fileBindLog(empty));
299
- return { text: '', files: 0, bytes: 0, reason: 'disabled', ...empty };
321
+ return empty;
300
322
  }
301
323
  const { structured, bash } = extractFileCandidates(msgs, { cwd });
302
324
  const candidates = [
@@ -305,24 +327,19 @@ export function filesForCorpus(msgs, {
305
327
  ];
306
328
  const skip = { enoent: 0, cap: 0, dir: 0, rel: 0 };
307
329
  const seen = new Set();
308
- const chunks = [];
330
+ const pending = [];
309
331
 
310
- const tryBind = (abs) => {
332
+ const queue = (abs) => {
311
333
  if (!abs || seen.has(abs)) return;
312
334
  seen.add(abs);
313
335
  let st;
314
336
  try { st = statSync(abs); } catch { skip.enoent += 1; return; }
315
337
  if (st.isDirectory()) {
316
338
  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
- }
339
+ const key = `${abs}:${st.mtimeMs}`;
340
+ if (boundFiles?.has(key)) return;
341
+ boundFiles?.add(key);
342
+ pending.push({ abs, kind: 'dir' });
326
343
  return;
327
344
  }
328
345
  if (!st.isFile()) { skip.enoent += 1; return; }
@@ -330,11 +347,7 @@ export function filesForCorpus(msgs, {
330
347
  const key = `${abs}:${st.mtimeMs}`;
331
348
  if (boundFiles?.has(key)) return;
332
349
  boundFiles?.add(key);
333
- try {
334
- chunks.push(`FILE ${abs}\n${readFileSync(abs)}`);
335
- } catch {
336
- skip.enoent += 1;
337
- }
350
+ pending.push({ abs, kind: 'file' });
338
351
  };
339
352
 
340
353
  for (const item of candidates) {
@@ -342,7 +355,92 @@ export function filesForCorpus(msgs, {
342
355
  if (!path.isAbsolute(raw) && !(raw.startsWith('~/') || raw === '~')) skip.rel += 1;
343
356
  const abs = resolveReadablePath(raw, item.cwd || cwd);
344
357
  if (!abs) { skip.enoent += 1; continue; }
345
- tryBind(abs);
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;
407
+ const chunks = [];
408
+ const readSeen = new Set();
409
+
410
+ const readOne = (abs) => {
411
+ if (!abs || readSeen.has(abs)) return;
412
+ readSeen.add(abs);
413
+ try {
414
+ chunks.push(`FILE ${abs}\n${readFileSync(abs)}`);
415
+ } catch {
416
+ skip.enoent += 1;
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);
346
444
  }
347
445
 
348
446
  const text = chunks.join('\n\n');
@@ -353,12 +451,165 @@ export function filesForCorpus(msgs, {
353
451
  cap: skip.cap,
354
452
  dir: skip.dir,
355
453
  rel: skip.rel,
356
- bash: bash.length,
454
+ bash,
357
455
  };
358
456
  log(fileBindLog(stats));
359
457
  return { text, files: chunks.length, bytes: text.length, reason: chunks.length ? null : 'none-kept', ...stats };
360
458
  }
361
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 };
611
+ }
612
+
362
613
  /** Session counters the HUD reads off /v1/info. */
363
614
  export function createSpillStats() {
364
615
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.48.71",
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",