openzoo 0.48.60 → 0.48.62

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 (2) hide show
  1. package/lib/proxy.js +78 -10
  2. package/package.json +1 -1
package/lib/proxy.js CHANGED
@@ -119,6 +119,9 @@ const spillMemo = new Map();
119
119
  // path:mtime of every file already bound — a file is bound once per version,
120
120
  // never re-uploaded because the agent read it again.
121
121
  const boundFiles = new Set();
122
+ // context_id -> total chars BOUND (turns + files appended). The counterfactual
123
+ // basis must reflect what the corpus actually holds, not just this turn's slice.
124
+ const boundChars = new Map();
122
125
 
123
126
  /**
124
127
  * Every fundable balance across all three chains, for the startup line and
@@ -519,10 +522,56 @@ async function spillTranscript(body, log, req) {
519
522
  return out.join('\n\n');
520
523
  };
521
524
 
525
+ // FILES RIDE THE BACKGROUND, NEVER THE CRITICAL PATH.
526
+ //
527
+ // The FIRST bind of a session is necessarily synchronous — the request cannot
528
+ // go until the context_id exists, because it travels as x-hrr-context. Folding
529
+ // file bytes into that bind put a 400KB upload in front of the caller's turn,
530
+ // which is the cold-bind stall this whole exercise was meant to remove: the
531
+ // bind endpoint measures 0.34-0.48s on a 613KB corpus, and that is 0.34-0.48s
532
+ // the user waits before a single token appears.
533
+ //
534
+ // Nothing recalls a file during the turn that read it — the model already has
535
+ // the tool result in its window. Files are only worth having bound for the
536
+ // NEXT ask. So the conversation binds inline and the files are appended after
537
+ // the fact, off the clock.
522
538
  const turns = msgs.slice(firstSpillable, cut).map(msgText).filter(Boolean).join('\n\n');
523
539
  const files = filesForCorpus();
524
- const corpus = files ? `${turns}\n\n${files}` : turns;
525
- if (corpus.length <= BIND_MIN_CHARS) return null;
540
+ const corpus = turns;
541
+
542
+ // FILES BIND EVEN WHEN THE CONVERSATION IS TOO SMALL TO SPILL.
543
+ //
544
+ // The threshold exists to stop us binding a two-line chat — it was never
545
+ // meant to gate FILES. But bailing here skipped them entirely, so a fresh
546
+ // session that reads a 200KB file bound nothing and scored 1.00x forever:
547
+ // OBSERVED on a live session that read files all turn and never produced a
548
+ // corpus, because its conversation stayed under the threshold the whole time.
549
+ //
550
+ // A file is worth binding on its own merit. So when the turns are too small
551
+ // to spill but files exist, bind the files anyway — in the background,
552
+ // against this session's context — and let this turn go unspilled. The corpus
553
+ // is then waiting for the next ask.
554
+ const sessionId = req?.headers?.['x-claude-code-session-id']
555
+ || req?.headers?.['x-session-id']
556
+ || req?.headers?.['x-claude-session-id']
557
+ || (typeof body?.metadata?.user_id === 'string' ? body.metadata.user_id : null);
558
+ const sessionKey = sessionId ? `sid:${sessionId}` : corpus.slice(0, 2048);
559
+ if (corpus.length <= BIND_MIN_CHARS) {
560
+ if (files) {
561
+ const known = spillMemo.get(sessionKey);
562
+ void bindCorpus(files, {
563
+ appendTo: known?.contextId || null,
564
+ onStage: (stage, info) => {
565
+ if (stage === 'binding') log(`binding ${mb(info.bytes)}MB of FILES (conversation under spill threshold, background)`);
566
+ },
567
+ }).then((b) => {
568
+ if (!b?.contextId) return;
569
+ boundChars.set(b.contextId, (boundChars.get(b.contextId) || 0) + files.length);
570
+ if (!known) spillMemo.set(sessionKey, { corpus, contextId: b.contextId, hash: b.hash });
571
+ }).catch((e) => log(`file bind failed: ${e.message}`));
572
+ }
573
+ return null;
574
+ }
526
575
 
527
576
  // CONTINUE THE CONTEXT, BIND ONLY THE DELTA.
528
577
  //
@@ -554,11 +603,7 @@ async function spillTranscript(body, log, req) {
554
603
  // The real header list is:
555
604
  // anthropic-beta, anthropic-version, x-app, x-claude-code-session-id,
556
605
  // x-stainless-*
557
- const sessionId = req?.headers?.['x-claude-code-session-id']
558
- || req?.headers?.['x-session-id']
559
- || req?.headers?.['x-claude-session-id']
560
- || (typeof body?.metadata?.user_id === 'string' ? body.metadata.user_id : null);
561
- const anchor = sessionId ? `sid:${sessionId}` : corpus.slice(0, 2048);
606
+ const anchor = sessionKey;
562
607
  const prior = spillMemo.get(anchor);
563
608
  let bind;
564
609
  if (prior && corpus.startsWith(prior.corpus) && corpus.length > prior.corpus.length) {
@@ -588,6 +633,19 @@ async function spillTranscript(body, log, req) {
588
633
  },
589
634
  });
590
635
  }
636
+ // APPEND THE FILES AFTER, off the clock. Fire-and-forget against the context
637
+ // we just secured: this turn is already answerable without them, and the next
638
+ // ask gets them for free. `boundFiles` already deduped by path:mtime, so this
639
+ // uploads each version exactly once no matter how often the agent re-reads it.
640
+ if (files) {
641
+ boundChars.set(bind.contextId, (boundChars.get(bind.contextId) || corpus.length) + files.length);
642
+ void bindCorpus(files, {
643
+ appendTo: bind.contextId,
644
+ onStage: (stage, info) => {
645
+ if (stage === 'binding') log(`appending ${mb(info.bytes)}MB of FILES to ${bind.contextId} (background)`);
646
+ },
647
+ }).catch((e) => log(`file append failed (corpus lags one turn): ${e.message}`));
648
+ }
591
649
  spillMemo.set(anchor, { corpus, contextId: bind.contextId, hash: bind.hash });
592
650
  if (spillMemo.size > 32) spillMemo.delete(spillMemo.keys().next().value);
593
651
  const sent = msgs.length - cut;
@@ -1266,7 +1324,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1266
1324
  } catch (err) {
1267
1325
  log(`context cache skipped for this call: ${err.message}`);
1268
1326
  }
1269
- const send = (buf, ctxId, topK) => client.fetch(url, {
1327
+ const send = (buf, ctxId, topK, corpusChars) => client.fetch(url, {
1270
1328
  ...init,
1271
1329
  body: buf,
1272
1330
  headers: ctxId
@@ -1275,6 +1333,16 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1275
1333
  'x-hrr-context': ctxId,
1276
1334
  // Only on the spill path — a caller that set its own top-k keeps it.
1277
1335
  ...(topK ? { 'x-hrr-top-k': String(topK) } : {}),
1336
+ // TELL THE GATEWAY HOW BIG THE CORPUS IS. It has been guessing:
1337
+ // `contextChars` is populated only when a bind passes through the
1338
+ // gateway itself, so on an APPEND — and on every corpus that
1339
+ // includes files the agent read, which never appear in a request
1340
+ // body at all — it has no idea and falls back to the body size.
1341
+ // That is why the counterfactual logged `corpus ?` all day and why
1342
+ // a call whose corpus held 40,777 tokens priced as if it held
1343
+ // 4,303. The proxy assembled the corpus; it is the only party that
1344
+ // knows.
1345
+ ...(corpusChars ? { 'x-hrr-corpus-chars': String(corpusChars) } : {}),
1278
1346
  }
1279
1347
  : init.headers,
1280
1348
  });
@@ -1285,7 +1353,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1285
1353
  didSpill = true;
1286
1354
  spilledChars += cached.corpus?.length || 0;
1287
1355
  if (cached.reused) spillReuses += 1;
1288
- result = await send(cached.body, cached.contextId, cached.topK);
1356
+ result = await send(cached.body, cached.contextId, cached.topK, boundChars.get(cached.contextId) || cached.corpus?.length);
1289
1357
  // Sidecar wiped between runs: the gateway 404s BEFORE the 402 (nothing
1290
1358
  // paid). Never fail on a stale manifest — re-bind once and retry.
1291
1359
  if (result.response.status === 404) {
@@ -1294,7 +1362,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1294
1362
  log('bound context is gone on the zoo — re-binding once...');
1295
1363
  forgetContext(config.apiBase, cached.hash);
1296
1364
  const rebound = await bindCorpus(cached.corpus, { force: true });
1297
- result = await send(cached.body, rebound.contextId, cached.topK);
1365
+ result = await send(cached.body, rebound.contextId, cached.topK, boundChars.get(rebound.contextId) || cached.corpus?.length);
1298
1366
  } else {
1299
1367
  res.writeHead(404, { 'content-type': 'application/json' });
1300
1368
  res.end(text);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.48.60",
3
+ "version": "0.48.62",
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",