openzoo 0.48.75 → 0.48.76

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/models.js CHANGED
@@ -269,16 +269,35 @@ export function rewritablePath(method, url) {
269
269
  */
270
270
  export const REASONING_MODEL_RE = /(deepseek|grok|o[134](-|$)|reasoner|thinking|-pro\b|sol-pro|qwq)/i;
271
271
 
272
- /** Claude Code auto-mode classify is max_tokens=16; treat anything in (0, 64] as one. */
273
- export const CLASSIFY_MAX_TOKENS = 64;
272
+ /** Claude Code auto-mode classify is max_tokens=16. 0.48.75 used 64 and
273
+ * missed grok nubs at 128 / 2000 on a 1–2 message body (dead-steady ~3¢
274
+ * from the reasoning floor). Anything in (0, 256] on a short transcript
275
+ * is a nub; a fat grok chat still uses the 4000 floor. */
276
+ export const CLASSIFY_MAX_TOKENS = 256;
277
+ export const CLASSIFY_MAX_MSGS = 3;
278
+ export const CLASSIFY_MAX_BODY = 65_536;
274
279
 
275
280
  const CLASSIFIER_PREFS = ['google/gemini-3.7-flash', 'anthropic/claude-haiku-4.5'];
276
281
 
282
+ function messageHasToolCalls(m) {
283
+ return Boolean(
284
+ (Array.isArray(m?.tool_calls) && m.tool_calls.length)
285
+ || m?.function_call
286
+ || m?.role === 'tool',
287
+ );
288
+ }
289
+
277
290
  /**
278
- * Tiny yes/no classify: small body (same BIND_MIN threshold the spill/brief
279
- * already use) AND a short max_tokens. Detected from the ORIGINAL body,
280
- * before rewrite or the reasoning floor otherwise OPENZOO_DEFAULT_MODEL
281
- * can land the classify on Grok/DeepSeek and the floor turns 16 into 4000.
291
+ * Tiny classify / grok nub: pin to flash, never apply the reasoning floor.
292
+ *
293
+ * 0.48.75 required max_tokens 64 AND body < BIND_MIN (16k). Live 3¢
294
+ * asks missed that (max_tokens 128 or 2000, and/or body 16k from a
295
+ * tools schema) and then ate the 4000 grok floor. Widen:
296
+ * - max_tokens ≤ 256 on a short transcript (≤ 6 msgs, no tool_calls)
297
+ * - OR few messages, no tool_calls, body under a few tens of KB
298
+ * (even when the caller asked for 2000 tokens)
299
+ * A real grok chat (max_tokens 2000+ AND a long / tool-using transcript)
300
+ * still returns false so the floor can fire.
282
301
  *
283
302
  * `body` may be the raw Buffer/string or a parsed object. An optional
284
303
  * `bodyLen` overrides stringify length when the caller still has the wire
@@ -296,7 +315,11 @@ export function isTinyClassify(body, bodyLen) {
296
315
  try { len = Buffer.byteLength(JSON.stringify(body)); } catch { return false; }
297
316
  }
298
317
  const mt = Number(parsed?.max_tokens);
299
- return len < BIND_MIN_CHARS && Number.isFinite(mt) && mt > 0 && mt <= CLASSIFY_MAX_TOKENS;
318
+ if (!Number.isFinite(mt) || mt <= 0) return false;
319
+ const messages = Array.isArray(parsed?.messages) ? parsed.messages : [];
320
+ if (messages.some(messageHasToolCalls)) return false;
321
+ if (mt <= CLASSIFY_MAX_TOKENS && messages.length <= 6) return true;
322
+ return messages.length <= CLASSIFY_MAX_MSGS && len < CLASSIFY_MAX_BODY;
300
323
  }
301
324
 
302
325
  /**
package/lib/proxy.js CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  loadBoundChars, noteCorpusLedger, filesForCorpus, readFilesForCorpus, boundAbsFromKeys,
17
17
  createSpillStats, corpusCharsForSend, applySpillCut, msgText,
18
18
  } from './spill.js';
19
- import { rewritablePath, augmentModelList, ALIAS_IDS, rewriteChatModel, zooModelIds } from './models.js';
19
+ import { rewritablePath, augmentModelList, ALIAS_IDS, rewriteChatModel, zooModelIds, CLASSIFY_MAX_TOKENS } from './models.js';
20
20
  import { forgetContext } from './contexts.js';
21
21
  import { injectBrief } from './brief.js';
22
22
  import { withNamespace } from './namespace.js';
@@ -440,6 +440,18 @@ async function spillTranscript(body, log, req, stats) {
440
440
  });
441
441
  if (adapted.cut <= adapted.firstSpillable) {
442
442
  bindFilesInBackground('no severable cut, files only');
443
+ // Still forward stubbed/trimmed bodies so an un-severable storm is not
444
+ // shipped at full size just because cutTranscript could not move.
445
+ if (adapted.stubbed?.dropped || adapted.stubbed?.stubbed) {
446
+ return {
447
+ body: Buffer.from(JSON.stringify({ ...body, messages: adapted.stubbed.messages })),
448
+ corpus: '',
449
+ reused: false,
450
+ savedBytes: 0,
451
+ sent: adapted.stubbed.messages.length,
452
+ msgs: msgs.length,
453
+ };
454
+ }
443
455
  return null;
444
456
  }
445
457
  const { cut, firstSpillable } = adapted;
@@ -1182,9 +1194,11 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1182
1194
  // temporarily unavailable (timed out), so auto mode cannot determine
1183
1195
  // the safety of WebSearch". Even staying on sonnet-5 is too slow
1184
1196
  // (402 handshake behind a long Grok stream). Pin to a fast
1185
- // non-reasoning catalog id and leave max_tokens at 16. Real
1186
- // Grok/DeepSeek chats (max_tokens > 64 or a body over BIND_MIN)
1197
+ // non-reasoning catalog id and leave max_tokens alone. Real
1198
+ // Grok/DeepSeek chats (max_tokens 2000+ AND a long transcript)
1187
1199
  // still get the 4000 floor � those still go blank without it.
1200
+ // 0.48.75 missed grok nubs at max_tokens 128/2000 on a 1�2
1201
+ // message body (~3� from the floor, no classifier log).
1188
1202
  let ids = [];
1189
1203
  try { ids = await zooModelIds(); } catch { /* catalog miss: still skip the floor on a tiny classify */ }
1190
1204
  const policy = rewriteChatModel(parsed, ids, { bodyLen: bodyBuf.length });
@@ -1266,7 +1280,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1266
1280
  const tiny = policy.tiny
1267
1281
  || (bodyBuf.length < BIND_MIN_CHARS
1268
1282
  && Number(parsed?.max_tokens ?? 0) > 0
1269
- && Number(parsed?.max_tokens) <= 64);
1283
+ && Number(parsed?.max_tokens) <= CLASSIFY_MAX_TOKENS);
1270
1284
  const briefed = tiny ? null : injectBrief(parsed, selfUrl);
1271
1285
  if (briefed) parsed = briefed;
1272
1286
  // SYSTEM MESSAGES BELONG AT THE FRONT, OR GOOGLE 400s.
package/lib/spill.js CHANGED
@@ -508,6 +508,38 @@ export function toolResultStub(n, paths) {
508
508
  return list ? `FILE ${list} ${mark}` : mark;
509
509
  }
510
510
 
511
+ function toolCallArgLength(tc) {
512
+ const a = tc?.function?.arguments ?? tc?.arguments;
513
+ if (typeof a === 'string') return a.length;
514
+ if (a && typeof a === 'object') return JSON.stringify(a).length;
515
+ return 0;
516
+ }
517
+
518
+ /**
519
+ * Keep path/query/url/command so pairing still names the call; drop the
520
+ * fat payload (Write contents, Edit hunks, pasted Bash, tool JSON).
521
+ */
522
+ export function slimToolCallArgs(tc, n) {
523
+ const args = parseArgs(tc?.function?.arguments ?? tc?.arguments);
524
+ const slim = {};
525
+ for (const k of PATH_KEYS) {
526
+ if (typeof args[k] === 'string' && args[k]) slim[k] = args[k];
527
+ }
528
+ if (typeof args.query === 'string') {
529
+ slim.query = args.query.length > 240 ? `${args.query.slice(0, 240)}…` : args.query;
530
+ }
531
+ if (typeof args.url === 'string') {
532
+ slim.url = args.url.length > 300 ? `${args.url.slice(0, 300)}…` : args.url;
533
+ }
534
+ if (typeof args.command === 'string') {
535
+ slim.command = args.command.length > 240 ? `${args.command.slice(0, 240)}…` : args.command;
536
+ }
537
+ slim._stub = `[bound, ${Number(n) || 0} chars]`;
538
+ const encoded = JSON.stringify(slim);
539
+ if (tc?.function) return { ...tc, function: { ...tc.function, arguments: encoded } };
540
+ return { ...tc, arguments: encoded };
541
+ }
542
+
511
543
  function isStubText(content) {
512
544
  if (typeof content === 'string') return /\[bound(?:, \d+ chars)?\]/.test(content);
513
545
  if (Array.isArray(content)) return content.some((b) => isStubText(typeof b === 'string' ? b : b?.text ?? b?.content));
@@ -545,7 +577,12 @@ function resolveBoundPath(raw, cwd, boundAbs) {
545
577
  * tail `budget` is set. Those two are what beat a WebSearch/Fetch/Bash
546
578
  * storm: the 800-byte floor cannot move `cut` inside a tool chain
547
579
  * (`isSeverable` is false), so the byte budget has to win by stubbing
548
- * bodies instead of deleting the tool_use / tool_result pairing.
580
+ * bodies (and fat tool_call JSON) instead of orphaning a pairing.
581
+ *
582
+ * `keepTail` is the vote cutTranscript cannot cast on an un-severable
583
+ * chain: drop older complete tool_call + tool_result pairs (rewrite the
584
+ * assistant's tool_calls, do not leave orphans). Live 0.48.75: keep 16/8/6/2
585
+ * all cut at the same index and left ~728k after file-only stubs.
549
586
  *
550
587
  * `fromIndex` limits the rewrite to the forwarded tail so the spilled prefix
551
588
  * that becomes the conversation corpus is unchanged. The last user ask is
@@ -558,16 +595,22 @@ export function stubBoundFileResults(msgs, {
558
595
  fromIndex = 0,
559
596
  // When the live tuner is below target, stub file-view results even if
560
597
  // this turn has not yet recorded them in boundAbs (first-read bodies).
561
- // Also stubs fat non-file tools (WebSearch / Fetch / Bash).
598
+ // Also stubs fat non-file tools (WebSearch / Fetch / Bash) and fat
599
+ // tool_call argument JSON.
562
600
  aggressive = false,
563
601
  // 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.
602
+ // bodies and fat tool_call JSON (oldest first) until it fits. Pairing
603
+ // stays; the ask stays. If still over, drop older complete pairs.
565
604
  budget = null,
605
+ // How many complete tool pairs to keep at the end of an un-severable
606
+ // assistant(tool_calls)+results chain. Ignored when the tail is severable.
607
+ keepTail = null,
566
608
  fatChars = FAT_TOOL_CHARS,
567
609
  } = {}) {
568
610
  const absSet = boundAbs || boundAbsFromKeys(boundFiles);
569
611
  const wantBudget = budget != null && Number.isFinite(Number(budget));
570
- if (!Array.isArray(msgs) || (!absSet.size && !aggressive && !wantBudget)) {
612
+ const wantKeep = keepTail != null && Number.isFinite(Number(keepTail));
613
+ if (!Array.isArray(msgs) || (!absSet.size && !aggressive && !wantBudget && !wantKeep)) {
571
614
  return { messages: msgs, stubbed: 0, dropped: 0 };
572
615
  }
573
616
 
@@ -613,8 +656,10 @@ export function stubBoundFileResults(msgs, {
613
656
  }
614
657
  }
615
658
 
616
- const lastUser = lastUserAskIndex(msgs, firstSpillableIndex(msgs));
659
+ const firstSpillable = firstSpillableIndex(msgs);
660
+ let lastUser = lastUserAskIndex(msgs, firstSpillable);
617
661
  const fatLimit = Number.isFinite(Number(fatChars)) ? Number(fatChars) : FAT_TOOL_CHARS;
662
+ const slimArgs = aggressive || wantBudget;
618
663
 
619
664
  const stubFor = (id, n) => {
620
665
  const paths = idPaths.get(id);
@@ -624,13 +669,26 @@ export function stubBoundFileResults(msgs, {
624
669
  const shouldStubBody = (id, n) => {
625
670
  if (!n || isStubText(typeof n === 'number' ? '' : n)) return false;
626
671
  if (stubIds.has(id)) return true;
627
- if (aggressive && n >= fatLimit) return true;
672
+ if (slimArgs && n >= fatLimit) return true;
628
673
  return false;
629
674
  };
630
675
 
631
676
  let stubbed = 0;
632
677
  let dropped = 0;
633
- let messages = msgs.map((m, i) => {
678
+ let messages = msgs;
679
+
680
+ // keepTail votes here because cutTranscript cannot move inside the chain.
681
+ if (wantKeep) {
682
+ messages = trimUnseverablePairs(messages, {
683
+ fromIndex,
684
+ keepTail: Number(keepTail),
685
+ lastUser,
686
+ firstSpillable,
687
+ });
688
+ lastUser = lastUserAskIndex(messages, firstSpillableIndex(messages));
689
+ }
690
+
691
+ messages = messages.map((m, i) => {
634
692
  if (i < fromIndex || !m || i === lastUser) return m;
635
693
  if (m.role === 'tool') {
636
694
  const n = toolContentLength(m.content);
@@ -639,9 +697,32 @@ export function stubBoundFileResults(msgs, {
639
697
  stubbed += 1;
640
698
  return { ...m, content: stubFor(m.tool_call_id, n) };
641
699
  }
642
- if (!Array.isArray(m.content)) return m;
700
+ let next = m;
701
+ if (Array.isArray(m.tool_calls) && slimArgs) {
702
+ let changed = false;
703
+ const calls = m.tool_calls.map((tc) => {
704
+ const n = toolCallArgLength(tc);
705
+ const raw = tc?.function?.arguments ?? tc?.arguments;
706
+ if (n < fatLimit || isStubText(typeof raw === 'string' ? raw : '')) return tc;
707
+ dropped += n;
708
+ stubbed += 1;
709
+ changed = true;
710
+ return slimToolCallArgs(tc, n);
711
+ });
712
+ if (changed) next = { ...next, tool_calls: calls };
713
+ }
714
+ if (next.function_call && slimArgs) {
715
+ const n = toolCallArgLength(next.function_call);
716
+ const raw = next.function_call.arguments;
717
+ if (n >= fatLimit && !isStubText(typeof raw === 'string' ? raw : '')) {
718
+ dropped += n;
719
+ stubbed += 1;
720
+ next = { ...next, function_call: slimToolCallArgs(next.function_call, n) };
721
+ }
722
+ }
723
+ if (!Array.isArray(next.content)) return next;
643
724
  let changed = false;
644
- const blocks = m.content.map((b) => {
725
+ const blocks = next.content.map((b) => {
645
726
  if (b?.type !== 'tool_result') return b;
646
727
  const n = toolContentLength(b.content);
647
728
  if (!shouldStubBody(b.tool_use_id, n) || isStubText(b.content)) return b;
@@ -650,13 +731,14 @@ export function stubBoundFileResults(msgs, {
650
731
  changed = true;
651
732
  return { ...b, content: stubFor(b.tool_use_id, n) };
652
733
  });
653
- return changed ? { ...m, content: blocks } : m;
734
+ return changed ? { ...next, content: blocks } : next;
654
735
  });
655
736
 
656
737
  // Byte budget wins inside a tool chain. cutTranscript cannot move tailStart
657
738
  // 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.
739
+ // 300-result storm used to ride in at ~728k after file-only stubs. Stub
740
+ // bodies first, then fat tool_call JSON; if still over, drop older pairs.
741
+ // Never drop the ask. Never orphan a remaining tool_result.
660
742
  if (wantBudget) {
661
743
  const cap = Number(budget);
662
744
  let used = sliceChars(messages, fromIndex);
@@ -678,9 +760,28 @@ export function stubBoundFileResults(msgs, {
678
760
  dropped += n;
679
761
  continue;
680
762
  }
681
- if (!Array.isArray(m.content)) continue;
763
+ if (Array.isArray(m.tool_calls)) {
764
+ let changed = false;
765
+ const calls = m.tool_calls.map((tc) => {
766
+ if (used <= cap) return tc;
767
+ const n = toolCallArgLength(tc);
768
+ const raw = tc?.function?.arguments ?? tc?.arguments;
769
+ if (n < fatLimit || isStubText(typeof raw === 'string' ? raw : '')) return tc;
770
+ const slim = slimToolCallArgs(tc, n);
771
+ const after = toolCallArgLength(slim);
772
+ if (after >= n) return tc;
773
+ used = used - n + after;
774
+ stubbed += 1;
775
+ dropped += n;
776
+ changed = true;
777
+ return slim;
778
+ });
779
+ if (changed) next[i] = { ...m, tool_calls: calls };
780
+ }
781
+ if (!Array.isArray((next[i] || m).content)) continue;
782
+ const cur = next[i];
682
783
  let changed = false;
683
- const blocks = m.content.map((b) => {
784
+ const blocks = cur.content.map((b) => {
684
785
  if (b?.type !== 'tool_result' || used <= cap || isStubText(b.content)) return b;
685
786
  const n = toolContentLength(b.content);
686
787
  if (!n) return b;
@@ -692,9 +793,18 @@ export function stubBoundFileResults(msgs, {
692
793
  changed = true;
693
794
  return { ...b, content: stub };
694
795
  });
695
- if (changed) next[i] = { ...m, content: blocks };
796
+ if (changed) next[i] = { ...cur, content: blocks };
696
797
  }
697
798
  messages = next;
799
+ used = sliceChars(messages, fromIndex);
800
+ if (used > cap) {
801
+ messages = trimUnseverablePairs(messages, {
802
+ fromIndex,
803
+ keepTail: 1,
804
+ lastUser,
805
+ firstSpillable: firstSpillableIndex(messages),
806
+ });
807
+ }
698
808
  }
699
809
  }
700
810
 
@@ -744,15 +854,19 @@ export function messageChars(m) {
744
854
  else if (Array.isArray(c)) {
745
855
  for (const b of c) {
746
856
  if (typeof b === 'string') n += b.length;
747
- else n += String(b?.text ?? (typeof b?.content === 'string' ? b.content : '')).length;
857
+ else if (b && typeof b === 'object') {
858
+ if (typeof b.text === 'string') n += b.text.length;
859
+ else if (typeof b.content === 'string') n += b.content.length;
860
+ else if (b.content != null) n += toolContentLength(b.content);
861
+ else n += JSON.stringify(b).length;
862
+ }
748
863
  }
749
864
  } else if (c && typeof c === 'object') n += JSON.stringify(c).length;
750
865
  if (Array.isArray(m.tool_calls)) {
751
- for (const tc of m.tool_calls) {
752
- const a = tc?.function?.arguments ?? tc?.arguments;
753
- n += typeof a === 'string' ? a.length : (a ? JSON.stringify(a).length : 0);
754
- }
866
+ for (const tc of m.tool_calls) n += toolCallArgLength(tc);
755
867
  }
868
+ const legacy = m.function_call;
869
+ if (legacy) n += toolCallArgLength(legacy);
756
870
  return n;
757
871
  }
758
872
 
@@ -998,6 +1112,58 @@ function isSeverable(msgs, i, firstSpillable) {
998
1112
  return msgs[i].role !== 'tool';
999
1113
  }
1000
1114
 
1115
+ /**
1116
+ * On an un-severable assistant(tool_calls)+results chain, keepTail cannot
1117
+ * move `cut`. Drop older complete pairs (rewrite tool_calls, drop their
1118
+ * results) so keep 16 vs 2 actually differ and the byte budget can land.
1119
+ */
1120
+ export function trimUnseverablePairs(msgs, {
1121
+ fromIndex = 0,
1122
+ keepTail = 2,
1123
+ lastUser = -1,
1124
+ firstSpillable = 0,
1125
+ } = {}) {
1126
+ if (!Array.isArray(msgs) || !msgs.length) return msgs;
1127
+ const keep = Math.max(1, Math.round(Number(keepTail) || 2));
1128
+ const end = lastUser > fromIndex ? lastUser : msgs.length;
1129
+ let asstIdx = -1;
1130
+ for (let i = fromIndex; i < end; i++) {
1131
+ if (Array.isArray(msgs[i]?.tool_calls) && msgs[i].tool_calls.length) {
1132
+ asstIdx = i;
1133
+ break;
1134
+ }
1135
+ }
1136
+ if (asstIdx < 0) return msgs;
1137
+ for (let i = asstIdx + 1; i < end; i++) {
1138
+ if (isSeverable(msgs, i, firstSpillable)) return msgs;
1139
+ }
1140
+ const calls = msgs[asstIdx].tool_calls;
1141
+ if (calls.length <= keep) return msgs;
1142
+ const keptCalls = calls.slice(-keep);
1143
+ const keptIds = new Set(keptCalls.map((c) => c.id).filter(Boolean));
1144
+ const out = [];
1145
+ for (let i = 0; i < msgs.length; i++) {
1146
+ const m = msgs[i];
1147
+ if (i === asstIdx) {
1148
+ out.push({ ...m, tool_calls: keptCalls });
1149
+ continue;
1150
+ }
1151
+ if (i > asstIdx && i !== lastUser && m?.role === 'tool' && m.tool_call_id && !keptIds.has(m.tool_call_id)) {
1152
+ continue;
1153
+ }
1154
+ if (i > asstIdx && i !== lastUser && Array.isArray(m?.content)) {
1155
+ const blocks = m.content.filter((b) => b?.type !== 'tool_result' || keptIds.has(b.tool_use_id));
1156
+ if (blocks.length !== m.content.length) {
1157
+ if (!blocks.length && m.role !== 'assistant') continue;
1158
+ out.push({ ...m, content: blocks });
1159
+ continue;
1160
+ }
1161
+ }
1162
+ out.push(m);
1163
+ }
1164
+ return out;
1165
+ }
1166
+
1001
1167
  /**
1002
1168
  * Pick a severable cut: keep a recent tail, honour the byte budget, floor
1003
1169
  * at minTurns of user/assistant, and never drop the last user ask.
@@ -1069,9 +1235,10 @@ function stubForCut(msgs, cut, opts) {
1069
1235
  boundFiles: opts.boundFiles,
1070
1236
  boundAbs: opts.boundAbs,
1071
1237
  cwd: opts.cwd,
1072
- fromIndex: cut,
1238
+ fromIndex: Math.max(0, cut),
1073
1239
  aggressive: Boolean(opts.aggressive),
1074
1240
  budget: opts.budget,
1241
+ keepTail: opts.keepTail,
1075
1242
  });
1076
1243
  }
1077
1244
 
@@ -1108,9 +1275,15 @@ export function applySpillCut(msgs, {
1108
1275
  action: 'hold',
1109
1276
  };
1110
1277
  if (plan.cut <= plan.firstSpillable) {
1111
- const sentChars = sliceChars(msgs, 0);
1278
+ // No severable index — still stub/trim the un-severable tail so a
1279
+ // 300-result storm is not forwarded at full size.
1280
+ const stubFrom = plan.firstSpillable >= 0 ? plan.firstSpillable : 0;
1281
+ const stubbed = stubForCut(msgs, stubFrom, {
1282
+ boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail,
1283
+ });
1284
+ const sentChars = sliceChars(stubbed.messages, stubFrom);
1112
1285
  const corpus = Math.max(Number(corpusChars) || 0, sentChars);
1113
- return { ...empty, sentChars, ratio: spillRatio(corpus, sentChars) };
1286
+ return { ...empty, stubbed, sentChars, ratio: spillRatio(corpus, sentChars) };
1114
1287
  }
1115
1288
 
1116
1289
  const measure = (cut, stubbed, knobsNow) => {
@@ -1126,7 +1299,9 @@ export function applySpillCut(msgs, {
1126
1299
  };
1127
1300
  };
1128
1301
 
1129
- let stubbed = stubForCut(msgs, plan.cut, { boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget });
1302
+ let stubbed = stubForCut(msgs, plan.cut, {
1303
+ boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail,
1304
+ });
1130
1305
  let stats = measure(plan.cut, stubbed, k);
1131
1306
  let action = 'hold';
1132
1307
 
@@ -1142,7 +1317,9 @@ export function applySpillCut(msgs, {
1142
1317
  if (decision.recut) {
1143
1318
  plan = cutTranscript(msgs, k);
1144
1319
  if (plan.cut > plan.firstSpillable) {
1145
- stubbed = stubForCut(msgs, plan.cut, { boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget });
1320
+ stubbed = stubForCut(msgs, plan.cut, {
1321
+ boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail,
1322
+ });
1146
1323
  stats = measure(plan.cut, stubbed, k);
1147
1324
  }
1148
1325
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.48.75",
3
+ "version": "0.48.76",
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",