openzoo 0.48.74 → 0.48.75

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/proxy.js CHANGED
@@ -749,6 +749,9 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
749
749
  mkdirSync(path.dirname(sayFile), { recursive: true });
750
750
  } catch { sayFile = null; }
751
751
  }
752
+ // Spill/adapt/classifier lines must use this channel: `log` is a no-op when
753
+ // `openzoo claude` starts us with silent:true, and printing them on stdout
754
+ // corrupts the Claude Code TTY. say() writes ~/.openzoo/proxy.log then.
752
755
  const say = (...a) => {
753
756
  const line = a.join(' ');
754
757
  if (sayFile) { try { appendFileSync(sayFile, line + '\n'); return; } catch { /* fall through */ } }
@@ -1187,7 +1190,9 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1187
1190
  const policy = rewriteChatModel(parsed, ids, { bodyLen: bodyBuf.length });
1188
1191
  parsed = policy.parsed;
1189
1192
  if (policy.tiny) {
1190
- log(`classifier tiny max_tokens=${Number(parsed?.max_tokens)} "${policy.from}" -> ${policy.to} (no reasoning floor)`);
1193
+ // `openzoo claude` starts us silent `log` is a no-op then. say()
1194
+ // is the proxy.log channel and never the Claude Code TTY.
1195
+ say(`classifier tiny max_tokens=${Number(parsed?.max_tokens)} "${policy.from}" -> ${policy.to} (no reasoning floor)`);
1191
1196
  } else {
1192
1197
  // SAY WHETHER THE OVERRIDE IS ACTUALLY SET, and name it.
1193
1198
  //
@@ -1372,7 +1377,9 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1372
1377
  try {
1373
1378
  let cached = null;
1374
1379
  try {
1375
- cached = await maybeCacheCorpus(req, bodyBuf, log, spill);
1380
+ // Spill/adapt diagnostics (adapt, file-stub, sending N/M) must hit
1381
+ // ~/.openzoo/proxy.log when we are silent. `log` is a no-op then.
1382
+ cached = await maybeCacheCorpus(req, bodyBuf, say, spill);
1376
1383
  } catch (err) {
1377
1384
  log(`context cache skipped for this call: ${err.message}`);
1378
1385
  }
package/lib/spill.js CHANGED
@@ -492,9 +492,26 @@ export function looksLikeFileView(command) {
492
492
  return sawView;
493
493
  }
494
494
 
495
- export function fileBoundStub(paths) {
495
+ /** Tool result larger than this is "fat" — stub it in the forwarded tail. */
496
+ export const FAT_TOOL_CHARS = 400;
497
+
498
+ export function fileBoundStub(paths, n) {
496
499
  const list = [...new Set((paths || []).filter(Boolean))].join(' ');
497
- return list ? `FILE ${list} [bound]` : 'FILE [bound]';
500
+ const mark = Number.isFinite(n) && n > 0 ? `[bound, ${n} chars]` : '[bound]';
501
+ return list ? `FILE ${list} ${mark}` : `FILE ${mark}`;
502
+ }
503
+
504
+ /** Generic stub for WebSearch / Fetch / Bash / any fat tool_result. */
505
+ export function toolResultStub(n, paths) {
506
+ const list = [...new Set((paths || []).filter(Boolean))].join(' ');
507
+ const mark = `[bound, ${Number(n) || 0} chars]`;
508
+ return list ? `FILE ${list} ${mark}` : mark;
509
+ }
510
+
511
+ function isStubText(content) {
512
+ if (typeof content === 'string') return /\[bound(?:, \d+ chars)?\]/.test(content);
513
+ if (Array.isArray(content)) return content.some((b) => isStubText(typeof b === 'string' ? b : b?.text ?? b?.content));
514
+ return false;
498
515
  }
499
516
 
500
517
  function toolContentLength(content) {
@@ -524,10 +541,15 @@ function resolveBoundPath(raw, cwd, boundAbs) {
524
541
  * (live: 5MB filebind, lastSend 13/107, savingX 1.22 instead of ~7x).
525
542
  *
526
543
  * 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.
544
+ * non-file tool output stay verbatim UNLESS `aggressive` / `stubMore` or a
545
+ * tail `budget` is set. Those two are what beat a WebSearch/Fetch/Bash
546
+ * storm: the 800-byte floor cannot move `cut` inside a tool chain
547
+ * (`isSeverable` is false), so the byte budget has to win by stubbing
548
+ * bodies instead of deleting the tool_use / tool_result pairing.
528
549
  *
529
550
  * `fromIndex` limits the rewrite to the forwarded tail so the spilled prefix
530
- * that becomes the conversation corpus is unchanged.
551
+ * that becomes the conversation corpus is unchanged. The last user ask is
552
+ * never rewritten.
531
553
  */
532
554
  export function stubBoundFileResults(msgs, {
533
555
  boundFiles,
@@ -536,10 +558,16 @@ export function stubBoundFileResults(msgs, {
536
558
  fromIndex = 0,
537
559
  // When the live tuner is below target, stub file-view results even if
538
560
  // this turn has not yet recorded them in boundAbs (first-read bodies).
561
+ // Also stubs fat non-file tools (WebSearch / Fetch / Bash).
539
562
  aggressive = false,
563
+ // When the forwarded tail is over this many chars, stub older tool_result
564
+ // bodies (oldest first) until it fits. Pairing stays; the ask stays.
565
+ budget = null,
566
+ fatChars = FAT_TOOL_CHARS,
540
567
  } = {}) {
541
568
  const absSet = boundAbs || boundAbsFromKeys(boundFiles);
542
- if (!Array.isArray(msgs) || (!absSet.size && !aggressive)) {
569
+ const wantBudget = budget != null && Number.isFinite(Number(budget));
570
+ if (!Array.isArray(msgs) || (!absSet.size && !aggressive && !wantBudget)) {
543
571
  return { messages: msgs, stubbed: 0, dropped: 0 };
544
572
  }
545
573
 
@@ -585,32 +613,91 @@ export function stubBoundFileResults(msgs, {
585
613
  }
586
614
  }
587
615
 
588
- if (!stubIds.size) return { messages: msgs, stubbed: 0, dropped: 0 };
616
+ const lastUser = lastUserAskIndex(msgs, firstSpillableIndex(msgs));
617
+ const fatLimit = Number.isFinite(Number(fatChars)) ? Number(fatChars) : FAT_TOOL_CHARS;
618
+
619
+ const stubFor = (id, n) => {
620
+ const paths = idPaths.get(id);
621
+ return paths?.length ? fileBoundStub(paths, n) : toolResultStub(n);
622
+ };
623
+
624
+ const shouldStubBody = (id, n) => {
625
+ if (!n || isStubText(typeof n === 'number' ? '' : n)) return false;
626
+ if (stubIds.has(id)) return true;
627
+ if (aggressive && n >= fatLimit) return true;
628
+ return false;
629
+ };
589
630
 
590
631
  let stubbed = 0;
591
632
  let dropped = 0;
592
- const messages = msgs.map((m, i) => {
593
- if (i < fromIndex || !m) return m;
594
- if (m.role === 'tool' && stubIds.has(m.tool_call_id)) {
633
+ let messages = msgs.map((m, i) => {
634
+ if (i < fromIndex || !m || i === lastUser) return m;
635
+ if (m.role === 'tool') {
595
636
  const n = toolContentLength(m.content);
596
- if (!n) return m;
637
+ if (!shouldStubBody(m.tool_call_id, n) || isStubText(m.content)) return m;
597
638
  dropped += n;
598
639
  stubbed += 1;
599
- return { ...m, content: fileBoundStub(idPaths.get(m.tool_call_id)) };
640
+ return { ...m, content: stubFor(m.tool_call_id, n) };
600
641
  }
601
642
  if (!Array.isArray(m.content)) return m;
602
643
  let changed = false;
603
644
  const blocks = m.content.map((b) => {
604
- if (b?.type !== 'tool_result' || !stubIds.has(b.tool_use_id)) return b;
645
+ if (b?.type !== 'tool_result') return b;
605
646
  const n = toolContentLength(b.content);
606
- if (!n) return b;
647
+ if (!shouldStubBody(b.tool_use_id, n) || isStubText(b.content)) return b;
607
648
  dropped += n;
608
649
  stubbed += 1;
609
650
  changed = true;
610
- return { ...b, content: fileBoundStub(idPaths.get(b.tool_use_id)) };
651
+ return { ...b, content: stubFor(b.tool_use_id, n) };
611
652
  });
612
653
  return changed ? { ...m, content: blocks } : m;
613
654
  });
655
+
656
+ // Byte budget wins inside a tool chain. cutTranscript cannot move tailStart
657
+ // past assistant(tool_calls) / role:tool (pairing 400s the provider), so a
658
+ // 15-result storm used to ride in full at the 800-byte floor. Stub older
659
+ // bodies first; never drop the ask or the assistant tool_calls themselves.
660
+ if (wantBudget) {
661
+ const cap = Number(budget);
662
+ let used = sliceChars(messages, fromIndex);
663
+ if (used > cap) {
664
+ const next = messages.slice();
665
+ for (let i = fromIndex; i < next.length && used > cap; i++) {
666
+ if (i === lastUser) continue;
667
+ const m = next[i];
668
+ if (!m) continue;
669
+ if (m.role === 'tool') {
670
+ if (isStubText(m.content)) continue;
671
+ const n = toolContentLength(m.content);
672
+ if (!n) continue;
673
+ const stub = stubFor(m.tool_call_id, n);
674
+ if (stub.length >= n) continue;
675
+ used = used - n + stub.length;
676
+ next[i] = { ...m, content: stub };
677
+ stubbed += 1;
678
+ dropped += n;
679
+ continue;
680
+ }
681
+ if (!Array.isArray(m.content)) continue;
682
+ let changed = false;
683
+ const blocks = m.content.map((b) => {
684
+ if (b?.type !== 'tool_result' || used <= cap || isStubText(b.content)) return b;
685
+ const n = toolContentLength(b.content);
686
+ if (!n) return b;
687
+ const stub = stubFor(b.tool_use_id, n);
688
+ if (stub.length >= n) return b;
689
+ used = used - n + stub.length;
690
+ stubbed += 1;
691
+ dropped += n;
692
+ changed = true;
693
+ return { ...b, content: stub };
694
+ });
695
+ if (changed) next[i] = { ...m, content: blocks };
696
+ }
697
+ messages = next;
698
+ }
699
+ }
700
+
614
701
  return { messages, stubbed, dropped };
615
702
  }
616
703
 
@@ -940,6 +1027,11 @@ export function cutTranscript(msgs, knobs = {}) {
940
1027
  return { cut: -1, firstSpillable, lastUser: lastUserAskIndex(msgs, firstSpillable), knobs: k };
941
1028
  }
942
1029
 
1030
+ // Only moves the cut at a severable index. A current-turn tool storm
1031
+ // (assistant(tool_calls) + N tool results + user ask) has no severable
1032
+ // index inside the chain, so this walk is a no-op — the byte budget is
1033
+ // applied by stubbing bodies in stubBoundFileResults, not by orphaning
1034
+ // a tool_result.
943
1035
  let tailStart = cut;
944
1036
  {
945
1037
  let used = 0;
@@ -979,6 +1071,7 @@ function stubForCut(msgs, cut, opts) {
979
1071
  cwd: opts.cwd,
980
1072
  fromIndex: cut,
981
1073
  aggressive: Boolean(opts.aggressive),
1074
+ budget: opts.budget,
982
1075
  });
983
1076
  }
984
1077
 
@@ -1033,7 +1126,7 @@ export function applySpillCut(msgs, {
1033
1126
  };
1034
1127
  };
1035
1128
 
1036
- let stubbed = stubForCut(msgs, plan.cut, { boundFiles, boundAbs, cwd, aggressive: k.stubMore });
1129
+ let stubbed = stubForCut(msgs, plan.cut, { boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget });
1037
1130
  let stats = measure(plan.cut, stubbed, k);
1038
1131
  let action = 'hold';
1039
1132
 
@@ -1049,7 +1142,7 @@ export function applySpillCut(msgs, {
1049
1142
  if (decision.recut) {
1050
1143
  plan = cutTranscript(msgs, k);
1051
1144
  if (plan.cut > plan.firstSpillable) {
1052
- stubbed = stubForCut(msgs, plan.cut, { boundFiles, boundAbs, cwd, aggressive: k.stubMore });
1145
+ stubbed = stubForCut(msgs, plan.cut, { boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget });
1053
1146
  stats = measure(plan.cut, stubbed, k);
1054
1147
  }
1055
1148
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.48.74",
3
+ "version": "0.48.75",
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",