openzoo 0.48.80 → 0.48.82

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 +24 -13
  2. package/lib/spill.js +137 -27
  3. package/package.json +1 -1
package/lib/proxy.js CHANGED
@@ -14,7 +14,7 @@ import { evmTokenBalance } from './evm.js';
14
14
  import { bindCorpus, contextCacheDisabled, BIND_MIN_CHARS } from './hrr.js';
15
15
  import {
16
16
  loadBoundChars, noteCorpusLedger, filesForCorpus, readFilesForCorpus, boundAbsFromKeys,
17
- createSpillStats, corpusCharsForSend, applySpillCut, msgText,
17
+ createSpillStats, corpusCharsForSend, applySpillCut, msgText, hudDollarX,
18
18
  } from './spill.js';
19
19
  import { rewritablePath, augmentModelList, ALIAS_IDS, rewriteChatModel, zooModelIds, CLASSIFY_MAX_TOKENS } from './models.js';
20
20
  import { forgetContext } from './contexts.js';
@@ -351,7 +351,7 @@ function replayPut(key, data, settle) {
351
351
  * severed at a plain `user` message — everything before one is self-contained.
352
352
  * A system message is never spilled: it is the operating contract, not history.
353
353
  */
354
- async function spillTranscript(body, log, req, stats) {
354
+ async function spillTranscript(body, log, req, stats, extra = {}) {
355
355
  const msgs = Array.isArray(body?.messages) ? body.messages : null;
356
356
  if (!msgs?.length) return null;
357
357
 
@@ -364,7 +364,8 @@ async function spillTranscript(body, log, req, stats) {
364
364
  //
365
365
  // Snapshot bound paths BEFORE collect so this turn's first-read files stay
366
366
  // verbatim in the tail (not yet in the corpus for recall). Previously
367
- // bound files get their tool_result bodies stubbed at return time.
367
+ // bound files before the last ask may stub; remaining post-ask Read/Edit/
368
+ // Write / Bash-file bodies stay real (drop older rounds instead).
368
369
  const previouslyBoundAbs = boundAbsFromKeys(boundFiles);
369
370
  const fileCollect = filesForCorpus(msgs, { boundFiles });
370
371
  const sessionId = req?.headers?.['x-claude-code-session-id']
@@ -422,8 +423,9 @@ async function spillTranscript(body, log, req, stats) {
422
423
  }
423
424
 
424
425
  // LIVE SELF-TUNER. Env knobs seed the first cut; after cut+stub the proxy
425
- // measures corpus/sent and retunes keep/min-turns/budget (and stubs more)
426
- // in process memory so the next request starts from the last good setting.
426
+ // scores the HUD dollar multiple (spill direct/billed) and retunes
427
+ // keep/min-turns/budget (stubMore for SEARCH, not live file bodies)
428
+ // in process memory so this request recuts when the green x is under 10.
427
429
  // No restart. OPENZOO_ADAPT=0 freezes the env defaults. The ask always
428
430
  // stays; we never drop below 2 real user/assistant turns to delete it.
429
431
  const knownLedger = (sessionKey && spillMemo.get(sessionKey))
@@ -437,6 +439,8 @@ async function spillTranscript(body, log, req, stats) {
437
439
  boundAbs: previouslyBoundAbs,
438
440
  log,
439
441
  persist: true,
442
+ dollarX: extra.dollarX ?? hudDollarX(stats || {}),
443
+ lastSend: extra.lastSend,
440
444
  });
441
445
  if (adapted.cut <= adapted.firstSpillable) {
442
446
  bindFilesInBackground('no severable cut, files only');
@@ -628,9 +632,8 @@ async function spillTranscript(body, log, req, stats) {
628
632
  ? `transcript prefix already bound (${bind.contextId}, ${keyKind}) — sending ${sent}/${msgs.length} turns`
629
633
  : `transcript prefix bound (${mb(bind.bytes)}MB → ${bind.contextId}, ${keyKind}) — sending ${sent}/${msgs.length} turns`);
630
634
 
631
- // Bound file bodies in the forwarded tail were stubbed inside applySpillCut
632
- // (and maybe stubbed more if the tuner was below 10x). The adapt line is
633
- // already logged there.
635
+ // applySpillCut cut/stubbed the tail (post-ask file bodies stay real;
636
+ // search may stub). The adapt line is already logged there.
634
637
  if (stubbed.dropped) {
635
638
  log(`file-stub stubbed=${stubbed.stubbed} dropped=${stubbed.dropped}`);
636
639
  }
@@ -674,7 +677,7 @@ async function spillTranscript(body, log, req, stats) {
674
677
  };
675
678
  }
676
679
 
677
- async function maybeCacheCorpus(req, bodyBuf, log, stats) {
680
+ async function maybeCacheCorpus(req, bodyBuf, log, stats, extra = {}) {
678
681
  if (contextCacheDisabled()) return null;
679
682
  if (req.method !== 'POST' || !(req.url || '').includes('/chat/completions')) return null;
680
683
  if (req.headers['x-hrr-context']) return null; // harness manages its own context
@@ -684,7 +687,7 @@ async function maybeCacheCorpus(req, bodyBuf, log, stats) {
684
687
  try {
685
688
  const body = JSON.parse(bodyBuf.toString('utf8'));
686
689
  if (Array.isArray(body?.messages) && body.messages.length) {
687
- return spillTranscript(body, log, req, stats);
690
+ return spillTranscript(body, log, req, stats, extra);
688
691
  }
689
692
  } catch { /* not json */ }
690
693
  return null;
@@ -701,11 +704,11 @@ async function maybeCacheCorpus(req, bodyBuf, log, stats) {
701
704
  const oneShot = typeof last?.content === 'string'
702
705
  && last.content.length > BIND_MIN_CHARS
703
706
  && last.content.lastIndexOf('\n\n') >= BIND_MIN_CHARS;
704
- if (!oneShot) return spillTranscript(body, log, req, stats);
707
+ if (!oneShot) return spillTranscript(body, log, req, stats, extra);
705
708
  const cut = last.content.lastIndexOf('\n\n');
706
709
  const corpus = last.content.slice(0, cut);
707
710
  const ask = last.content.slice(cut + 2).trim();
708
- if (!ask || ask.length > 8000) return spillTranscript(body, log, req, stats);
711
+ if (!ask || ask.length > 8000) return spillTranscript(body, log, req, stats, extra);
709
712
 
710
713
  const bind = await bindCorpus(corpus, {
711
714
  onStage: (stage, info) => {
@@ -1396,7 +1399,15 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1396
1399
  try {
1397
1400
  // Spill/adapt diagnostics (adapt, file-stub, sending N/M) must hit
1398
1401
  // ~/.openzoo/proxy.log when we are silent. `log` is a no-op then.
1399
- cached = await maybeCacheCorpus(req, bodyBuf, say, spill);
1402
+ cached = await maybeCacheCorpus(req, bodyBuf, say, spill, {
1403
+ lastSend: lastSpillSend?.sent,
1404
+ dollarX: hudDollarX({
1405
+ spillDirect: spill.spillDirect,
1406
+ spillSpend: spill.spillSpend,
1407
+ sessionDirect,
1408
+ sessionSpent,
1409
+ }),
1410
+ });
1400
1411
  } catch (err) {
1401
1412
  log(`context cache skipped for this call: ${err.message}`);
1402
1413
  }
package/lib/spill.js CHANGED
@@ -492,6 +492,38 @@ export function looksLikeFileView(command) {
492
492
  return sawView;
493
493
  }
494
494
 
495
+ function toolFnName(c) {
496
+ return c?.function?.name || c?.name || '';
497
+ }
498
+
499
+ /**
500
+ * Read / Edit / Write (and file-view Bash). Bodies for these that remain
501
+ * after the last user ask must stay real — a `[bound, N chars]` placeholder
502
+ * in the live tail makes the model think Read is broken and cat files.
503
+ */
504
+ export function isLiveFileTool(name, args = {}) {
505
+ const n = String(name || '');
506
+ if (/^(read|edit|write)/i.test(n)) return true;
507
+ if (/bash/i.test(n) && looksLikeFileView(args?.command)) return true;
508
+ return false;
509
+ }
510
+
511
+ /**
512
+ * Same number the HUD green `x` uses: this-call / running spill
513
+ * direct÷billed, else session-wide. Null when no dollar figure exists.
514
+ */
515
+ export function hudDollarX({
516
+ spillDirect, spillSpend, sessionDirect, sessionSpent,
517
+ } = {}) {
518
+ const billed = Number(spillSpend);
519
+ const direct = Number(spillDirect);
520
+ if (billed > 0 && Number.isFinite(direct)) return direct / billed;
521
+ const sessBilled = Number(sessionSpent);
522
+ const sessDirect = Number(sessionDirect);
523
+ if (sessBilled > 0 && Number.isFinite(sessDirect)) return sessDirect / sessBilled;
524
+ return null;
525
+ }
526
+
495
527
  /** Tool result larger than this is "fat" — stub it in the forwarded tail. */
496
528
  export const FAT_TOOL_CHARS = 400;
497
529
 
@@ -645,8 +677,13 @@ function resolveBoundPath(raw, cwd, boundAbs) {
645
677
  * tool_calls / tool_use in that tail, plus tool results for those ids — not
646
678
  * every Read/Bash/Search after the last user ask. In-flight latest bodies
647
679
  * stay even when large. KEEP_TOOL_CHARS applies only to that last round so
648
- * a 461-byte Read survives a follow-up ask. Older rounds after the same
649
- * ask stub (bound / fat / over budget) and trim (keepTail) like 0.48.76.
680
+ * a 461-byte Read survives a follow-up ask.
681
+ *
682
+ * After the last user ask, remaining Read/Edit/Write / Bash-file bodies
683
+ * stay verbatim. Drop older post-ask rounds (trimRoundsAfterAsk) instead
684
+ * of rewriting them to `[bound, N chars]` — a placeholder in the live tail
685
+ * is 76-style blindness (model cats files). Fat WebSearch/Fetch in older
686
+ * kept rounds may still stub. stubMore tightens SEARCH, not live files.
650
687
  */
651
688
  export function stubBoundFileResults(msgs, {
652
689
  boundFiles,
@@ -675,12 +712,15 @@ export function stubBoundFileResults(msgs, {
675
712
  }
676
713
 
677
714
  const stubIds = new Set();
715
+ const fileIds = new Set();
716
+ const afterAskIds = new Set();
678
717
  const idPaths = new Map();
679
718
  let currentCwd = cwd;
680
719
 
681
720
  const noteCall = (c) => {
682
721
  const id = c?.id || c?.tool_call_id;
683
722
  const args = parseArgs(c?.function?.arguments ?? c?.arguments);
723
+ if (id && isLiveFileTool(toolFnName(c), args)) fileIds.add(id);
684
724
  const raws = [];
685
725
  collectStructuredPaths(args, raws);
686
726
  if (typeof args.command === 'string' && looksLikeFileView(args.command)) {
@@ -723,6 +763,15 @@ export function stubBoundFileResults(msgs, {
723
763
  const slimArgs = aggressive || wantBudget;
724
764
  let round = currentToolRound(msgs, { lastUser, fromIndex });
725
765
 
766
+ const markAfterAsk = (list) => {
767
+ afterAskIds.clear();
768
+ if (lastUser < 0 || !Array.isArray(list)) return;
769
+ for (let i = lastUser + 1; i < list.length; i++) {
770
+ for (const id of toolCallIds(list[i])) afterAskIds.add(id);
771
+ }
772
+ };
773
+ markAfterAsk(msgs);
774
+
726
775
  const stubFor = (id, n) => {
727
776
  const paths = idPaths.get(id);
728
777
  return paths?.length ? fileBoundStub(paths, n) : toolResultStub(n);
@@ -734,6 +783,8 @@ export function stubBoundFileResults(msgs, {
734
783
  if (id && round.inFlight && round.ids.has(id)) return false;
735
784
  // Last completed batch (ask follows): keep small bodies only.
736
785
  if (id && round.ids.has(id) && n < keepFloor) return false;
786
+ // Remaining post-ask file bodies stay real. Drop the round instead.
787
+ if (id && afterAskIds.has(id) && fileIds.has(id)) return false;
737
788
  if (stubIds.has(id)) return true;
738
789
  if (slimArgs && n >= fatLimit) return true;
739
790
  if (overBudget && n > 0) return true;
@@ -762,6 +813,7 @@ export function stubBoundFileResults(msgs, {
762
813
  firstSpillable: firstSpillableIndex(messages),
763
814
  });
764
815
  lastUser = lastUserAskIndex(messages, firstSpillableIndex(messages));
816
+ markAfterAsk(messages);
765
817
  round = currentToolRound(messages, { lastUser, fromIndex });
766
818
  }
767
819
 
@@ -877,9 +929,15 @@ export function stubBoundFileResults(msgs, {
877
929
  messages = next;
878
930
  used = sliceChars(messages, fromIndex);
879
931
  if (used > cap) {
880
- // Only the un-severable last chain can still be huge (one assistant
881
- // with hundreds of calls). Do not drop already-stubbed older rounds
882
- // after the ask the overage is often the protected latest batch.
932
+ // Still over: drop older post-ask rounds rather than rewriting
933
+ // remaining file bodies to `[bound]`. Then trim an un-severable
934
+ // last chain. Latest batch stays.
935
+ messages = trimRoundsAfterAsk(messages, {
936
+ fromIndex,
937
+ keepTail: 1,
938
+ lastUser,
939
+ });
940
+ lastUser = lastUserAskIndex(messages, firstSpillableIndex(messages));
883
941
  messages = trimUnseverablePairs(messages, {
884
942
  fromIndex,
885
943
  keepTail: 1,
@@ -895,6 +953,8 @@ export function stubBoundFileResults(msgs, {
895
953
 
896
954
  export const ADAPT_TARGET = 10;
897
955
  export const ADAPT_LOOSEN_AT = 20;
956
+ /** lastSend growing past this while dollar x < target is a tighten signal. */
957
+ export const LAST_SEND_TIGHTEN = 24;
898
958
 
899
959
  export const KNOB_DEFAULTS = Object.freeze({
900
960
  keepTail: 8,
@@ -1129,18 +1189,28 @@ function fmtRatio(ratio) {
1129
1189
  return String(Number(ratio.toFixed(2)));
1130
1190
  }
1131
1191
 
1132
- function adaptLine({ action, ratio, knobs, target = ADAPT_TARGET }) {
1133
- if (action === 'hold') return `adapt hold ratio=${fmtRatio(ratio)}`;
1134
- return `adapt ratio=${fmtRatio(ratio)} target=${target} tail=${knobs.keepTail} budget=${knobs.budget}`;
1192
+ function adaptLine({ action, ratio, knobs, target = ADAPT_TARGET, dollarX } = {}) {
1193
+ const d = Number(dollarX);
1194
+ const hasD = Number.isFinite(d) && d > 0;
1195
+ const via = hasD
1196
+ ? `dollar=${fmtRatio(d)} chars=${fmtRatio(ratio)}`
1197
+ : `ratio=${fmtRatio(ratio)}`;
1198
+ if (action === 'hold') return `adapt hold ${via}`;
1199
+ return `adapt ${via} target=${target} tail=${knobs.keepTail} budget=${knobs.budget}`;
1135
1200
  }
1136
1201
 
1137
1202
  /**
1138
- * Decide whether to shrink, loosen, or hold. Tighten recuts this request;
1139
- * loosen only remembers a safer notch for the NEXT one so we do not
1140
- * flip-flop every call after an overshoot.
1203
+ * Decide whether to shrink, loosen, or hold.
1204
+ *
1205
+ * Score the HUD dollar multiple (direct/billed) when present. Char ratio
1206
+ * is the fallback only. Tighten recuts this request. Loosen only when the
1207
+ * scored metric is above loosenAt AND last action was hold — never loosen
1208
+ * off a char-only overshoot while dollar x is in hand and below loosenAt.
1141
1209
  */
1142
1210
  export function adaptTail({
1143
1211
  ratio,
1212
+ dollarX,
1213
+ lastSend,
1144
1214
  knobs,
1145
1215
  lastAction = 'hold',
1146
1216
  corpusChars,
@@ -1148,22 +1218,31 @@ export function adaptTail({
1148
1218
  loosenAt = ADAPT_LOOSEN_AT,
1149
1219
  } = {}) {
1150
1220
  const cur = sanitizeKnobs(knobs);
1151
- if (!Number.isFinite(ratio)) {
1152
- return { action: 'hold', knobs: cur, recut: false, ratio, log: adaptLine({ action: 'hold', ratio, knobs: cur, target }) };
1221
+ const dollar = Number(dollarX);
1222
+ const hasDollar = Number.isFinite(dollar) && dollar > 0;
1223
+ const score = hasDollar ? dollar : Number(ratio);
1224
+ const sendN = Number(lastSend);
1225
+ const sendGrowing = Number.isFinite(sendN) && sendN > LAST_SEND_TIGHTEN;
1226
+ const line = (action, knobsNow) => adaptLine({
1227
+ action, ratio, knobs: knobsNow, target, dollarX: hasDollar ? dollar : undefined,
1228
+ });
1229
+ if (!Number.isFinite(score)) {
1230
+ return { action: 'hold', knobs: cur, recut: false, ratio, dollarX: hasDollar ? dollar : null, score, log: line('hold', cur) };
1153
1231
  }
1154
- if (ratio < target) {
1155
- const next = tightenKnobs(cur, { ratio, corpusChars, target });
1232
+ // Dollar miss, or lastSend growing while dollar x is under target.
1233
+ if (score < target || (hasDollar && dollar < target && sendGrowing)) {
1234
+ const next = tightenKnobs(cur, { ratio: score, corpusChars, target });
1156
1235
  const changed = !sameKnobs(next, cur);
1157
1236
  const action = changed ? 'tighten' : 'hold';
1158
- return { action, knobs: next, recut: changed, ratio, log: adaptLine({ action, ratio, knobs: next, target }) };
1237
+ return { action, knobs: next, recut: changed, ratio, dollarX: hasDollar ? dollar : null, score, log: line(action, next) };
1159
1238
  }
1160
- if (ratio > loosenAt && lastAction === 'hold') {
1239
+ if (score > loosenAt && lastAction === 'hold') {
1161
1240
  const next = loosenKnobs(cur);
1162
1241
  const changed = !sameKnobs(next, cur);
1163
1242
  const action = changed ? 'loosen' : 'hold';
1164
- return { action, knobs: next, recut: false, ratio, log: adaptLine({ action, ratio, knobs: next, target }) };
1243
+ return { action, knobs: next, recut: false, ratio, dollarX: hasDollar ? dollar : null, score, log: line(action, next) };
1165
1244
  }
1166
- return { action: 'hold', knobs: cur, recut: false, ratio, log: adaptLine({ action: 'hold', ratio, knobs: cur, target }) };
1245
+ return { action: 'hold', knobs: cur, recut: false, ratio, dollarX: hasDollar ? dollar : null, score, log: line('hold', cur) };
1167
1246
  }
1168
1247
 
1169
1248
  function firstSpillableIndex(msgs) {
@@ -1354,10 +1433,11 @@ function stubForCut(msgs, cut, opts) {
1354
1433
  }
1355
1434
 
1356
1435
  /**
1357
- * Cut + stub, then retune knobs toward a >10x corpus/sent ratio in process
1358
- * memory. A miss recuts once this request. A huge overshoot loosens one
1359
- * notch for the next request only (no flip-flop). Env OPENZOO_ADAPT=0
1360
- * disables the tuner; env still seeds the initial knobs.
1436
+ * Cut + stub, then retune knobs toward the HUD dollar multiple (direct /
1437
+ * billed) when present, else corpus/sent. A miss recuts once this request
1438
+ * (smaller keepTail; stubMore for SEARCH, not live file bodies). A dollar
1439
+ * overshoot loosens one notch for the next request only (no flip-flop).
1440
+ * Env OPENZOO_ADAPT=0 disables the tuner; env still seeds the initial knobs.
1361
1441
  */
1362
1442
  export function applySpillCut(msgs, {
1363
1443
  knobs,
@@ -1370,6 +1450,8 @@ export function applySpillCut(msgs, {
1370
1450
  persist = false,
1371
1451
  file,
1372
1452
  home,
1453
+ dollarX,
1454
+ lastSend,
1373
1455
  } = {}) {
1374
1456
  const persistOpts = { persist, file, home };
1375
1457
  let k = sanitizeKnobs(knobs || getLiveKnobs(persistOpts));
@@ -1389,12 +1471,37 @@ export function applySpillCut(msgs, {
1389
1471
  // No severable index — still stub/trim the un-severable tail so a
1390
1472
  // 300-result storm is not forwarded at full size.
1391
1473
  const stubFrom = plan.firstSpillable >= 0 ? plan.firstSpillable : 0;
1392
- const stubbed = stubForCut(msgs, stubFrom, {
1474
+ let stubbed = stubForCut(msgs, stubFrom, {
1393
1475
  boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail,
1394
1476
  });
1395
- const sentChars = sliceChars(stubbed.messages, stubFrom);
1477
+ let sentChars = sliceChars(stubbed.messages, stubFrom);
1396
1478
  const corpus = Math.max(Number(corpusChars) || 0, sentChars);
1397
- return { ...empty, stubbed, sentChars, ratio: spillRatio(corpus, sentChars) };
1479
+ let ratio = spillRatio(corpus, sentChars);
1480
+ let action = 'hold';
1481
+ if (adapt) {
1482
+ const thisSend = Math.max(0, stubbed.messages.length - stubFrom);
1483
+ const decision = adaptTail({
1484
+ ratio,
1485
+ dollarX,
1486
+ lastSend: Math.max(Number(lastSend) || 0, thisSend),
1487
+ knobs: k,
1488
+ lastAction: lastAdaptAction,
1489
+ corpusChars: corpus,
1490
+ });
1491
+ k = decision.knobs;
1492
+ action = decision.action;
1493
+ if (decision.recut) {
1494
+ stubbed = stubForCut(msgs, stubFrom, {
1495
+ boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail,
1496
+ });
1497
+ sentChars = sliceChars(stubbed.messages, stubFrom);
1498
+ ratio = spillRatio(corpus, sentChars);
1499
+ }
1500
+ rememberKnobs(k, persistOpts);
1501
+ lastAdaptAction = action;
1502
+ log(adaptLine({ action, ratio, knobs: k, dollarX }));
1503
+ }
1504
+ return { ...empty, knobs: k, stubbed, sentChars, ratio, action };
1398
1505
  }
1399
1506
 
1400
1507
  const measure = (cut, stubbed, knobsNow) => {
@@ -1417,8 +1524,11 @@ export function applySpillCut(msgs, {
1417
1524
  let action = 'hold';
1418
1525
 
1419
1526
  if (adapt) {
1527
+ const thisSend = Math.max(0, stubbed.messages.length - plan.cut);
1420
1528
  const decision = adaptTail({
1421
1529
  ratio: stats.ratio,
1530
+ dollarX,
1531
+ lastSend: Math.max(Number(lastSend) || 0, thisSend),
1422
1532
  knobs: k,
1423
1533
  lastAction: lastAdaptAction,
1424
1534
  corpusChars: stats.corpusChars,
@@ -1436,7 +1546,7 @@ export function applySpillCut(msgs, {
1436
1546
  }
1437
1547
  rememberKnobs(k, persistOpts);
1438
1548
  lastAdaptAction = action;
1439
- log(adaptLine({ action, ratio: stats.ratio, knobs: k }));
1549
+ log(adaptLine({ action, ratio: stats.ratio, knobs: k, dollarX }));
1440
1550
  }
1441
1551
 
1442
1552
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.48.80",
3
+ "version": "0.48.82",
4
4
  "description": "Local x402-paying proxy + MCP server for openzoo.fun — 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",