openzoo 0.48.51 → 0.48.53

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 +59 -6
  2. package/package.json +1 -1
package/lib/proxy.js CHANGED
@@ -311,7 +311,7 @@ function msgText(m) {
311
311
  * severed at a plain `user` message — everything before one is self-contained.
312
312
  * A system message is never spilled: it is the operating contract, not history.
313
313
  */
314
- async function spillTranscript(body, log) {
314
+ async function spillTranscript(body, log, req) {
315
315
  const msgs = Array.isArray(body?.messages) ? body.messages : null;
316
316
  if (!msgs || msgs.length < 6) return null;
317
317
 
@@ -391,6 +391,28 @@ async function spillTranscript(body, log) {
391
391
  }
392
392
  if (tailStart > cut) cut = tailStart;
393
393
 
394
+ // COHERENCE IS COUNTED IN TURNS, NOT BYTES.
395
+ //
396
+ // The byte budget alone produced windows of 2, 5 and 34 turns out of ~600 —
397
+ // and one fat tool result is enough to spend the whole 6,000 chars, so a busy
398
+ // agent turn collapses the window to almost nothing. OBSERVED live: an agent
399
+ // that had just run a command reported "I don't have the preceding turns of
400
+ // this conversation in view" and re-derived its own state from files, every
401
+ // turn. Retrieval brings back what is RELEVANT to the ask; it does not
402
+ // reliably bring back "what I just did", because the model does not know to
403
+ // query for it.
404
+ //
405
+ // So floor the window at a number of turns regardless of size. This costs
406
+ // saving — a bigger tail is a bigger `sent` — and that is the correct trade:
407
+ // measured 8.13x on the fleet leaves room to spend some of it on an agent
408
+ // that remembers its own last few moves.
409
+ const minTurns = Number(process.env.OPENZOO_TAIL_MIN_TURNS || 12);
410
+ if (msgs.length - cut < minTurns) {
411
+ for (let i = Math.max(firstSpillable + 1, msgs.length - minTurns); i > firstSpillable; i--) {
412
+ if (severable(i)) { cut = i; break; }
413
+ }
414
+ }
415
+
394
416
  // NEVER SPILL THE CURRENT ASK.
395
417
  //
396
418
  // The tail budget walks BACKWARD accumulating bytes, and in an agent loop the
@@ -430,7 +452,29 @@ async function spillTranscript(body, log) {
430
452
  // one. So when it does, send just the tail and keep the same context_id.
431
453
  // Anchored on the FIRST 2KB, which is stable for the life of a conversation
432
454
  // and distinguishes concurrent ones.
433
- const anchor = corpus.slice(0, 2048);
455
+ // KEY ON THE SESSION, NOT ON THE CONTENT.
456
+ //
457
+ // The anchor was the first 2KB of corpus, which works only because a
458
+ // transcript's opening never changes. It is fragile in exactly the cases that
459
+ // matter: two sessions that open identically (same system block, same first
460
+ // instruction — the norm for an agent) collide onto ONE bound context and
461
+ // interleave their histories, and any edit near the top of a transcript
462
+ // silently orphans the binding and re-uploads the whole thing.
463
+ //
464
+ // Claude Code identifies its session, so use that when it is offered and fall
465
+ // back to the content anchor when it is not. Same memo, better key.
466
+ // CAPTURED FROM A LIVE claude-cli/2.1.232 REQUEST, not guessed. The first
467
+ // version of this checked x-session-id / x-claude-session-id /
468
+ // metadata.user_id — none of which Claude Code sends, so it silently fell
469
+ // back to the content anchor on every request and the feature did nothing.
470
+ // The real header list is:
471
+ // anthropic-beta, anthropic-version, x-app, x-claude-code-session-id,
472
+ // x-stainless-*
473
+ const sessionId = req?.headers?.['x-claude-code-session-id']
474
+ || req?.headers?.['x-session-id']
475
+ || req?.headers?.['x-claude-session-id']
476
+ || (typeof body?.metadata?.user_id === 'string' ? body.metadata.user_id : null);
477
+ const anchor = sessionId ? `sid:${sessionId}` : corpus.slice(0, 2048);
434
478
  const prior = spillMemo.get(anchor);
435
479
  let bind;
436
480
  if (prior && corpus.startsWith(prior.corpus) && corpus.length > prior.corpus.length) {
@@ -463,9 +507,12 @@ async function spillTranscript(body, log) {
463
507
  spillMemo.set(anchor, { corpus, contextId: bind.contextId, hash: bind.hash });
464
508
  if (spillMemo.size > 32) spillMemo.delete(spillMemo.keys().next().value);
465
509
  const sent = msgs.length - cut;
510
+ // NAME THE KEY. A memo keyed on the wrong thing fails silently — it just
511
+ // re-binds forever and collides sessions — so the log says which key was used.
512
+ const keyKind = sessionId ? `sid ${String(sessionId).slice(0, 8)}` : 'content-anchor';
466
513
  log(bind.reused
467
- ? `transcript prefix already bound (${bind.contextId}) — sending ${sent}/${msgs.length} turns`
468
- : `transcript prefix bound (${mb(bind.bytes)}MB → ${bind.contextId}) — sending ${sent}/${msgs.length} turns`);
514
+ ? `transcript prefix already bound (${bind.contextId}, ${keyKind}) — sending ${sent}/${msgs.length} turns`
515
+ : `transcript prefix bound (${mb(bind.bytes)}MB → ${bind.contextId}, ${keyKind}) — sending ${sent}/${msgs.length} turns`);
469
516
 
470
517
  // ADAPTIVE TOP-K. A fixed 32 chunks is what was actually eating the saving:
471
518
  // MEASURED on a 56,265-token corpus, top_k 32 handed 9,990 tokens back and
@@ -515,11 +562,11 @@ async function maybeCacheCorpus(req, bodyBuf, log) {
515
562
  const oneShot = typeof last?.content === 'string'
516
563
  && last.content.length > BIND_MIN_CHARS
517
564
  && last.content.lastIndexOf('\n\n') >= BIND_MIN_CHARS;
518
- if (!oneShot) return spillTranscript(body, log);
565
+ if (!oneShot) return spillTranscript(body, log, req);
519
566
  const cut = last.content.lastIndexOf('\n\n');
520
567
  const corpus = last.content.slice(0, cut);
521
568
  const ask = last.content.slice(cut + 2).trim();
522
- if (!ask || ask.length > 8000) return spillTranscript(body, log);
569
+ if (!ask || ask.length > 8000) return spillTranscript(body, log, req);
523
570
 
524
571
  const bind = await bindCorpus(corpus, {
525
572
  onStage: (stage, info) => {
@@ -982,6 +1029,12 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
982
1029
  if ((req.url || '').includes('/chat/completions') && req.method === 'POST') {
983
1030
  servedRequests += 1;
984
1031
  say(`\n<- request #${servedRequests} from ${(req.headers['user-agent'] || 'unknown').slice(0, 40)}`);
1032
+ // TEMPORARY: name the headers (not values) so we can see whether the
1033
+ // client offers a session id at all. Values are never logged — several
1034
+ // of these carry auth.
1035
+ if (process.env.OPENZOO_LOG_HEADERS === '1') {
1036
+ log(` headers: ${Object.keys(req.headers).sort().join(', ')}`);
1037
+ }
985
1038
  }
986
1039
  if (rewritablePath(req.method, req.url)) {
987
1040
  const rw = await maybeRewriteModel(bodyBuf);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.48.51",
3
+ "version": "0.48.53",
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",