openzoo 0.49.4 → 0.49.6

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/spill.js CHANGED
@@ -118,6 +118,31 @@ export function corpusCharsForSend(boundChars, contextId, thisTurn) {
118
118
  return Math.max(boundChars.get(contextId) || 0, thisTurn || 0);
119
119
  }
120
120
 
121
+ /**
122
+ * Pricing / unspilled basis: the unspilled size, never the shrunken sent
123
+ * (tokensAfter) size. Same unit on both args — tokens or chars, not mixed.
124
+ */
125
+ export function unspilledBasis({ tokensBefore, corpus } = {}) {
126
+ return Math.max(Number(tokensBefore) || 0, Number(corpus) || 0);
127
+ }
128
+
129
+ /**
130
+ * Gateway counterfactual the tell-line must print. Missing / non-finite /
131
+ * non-positive → null (`basis ?`). Never reads lecore.corpusTokens.
132
+ */
133
+ export function spillPricedTellBasis(x) {
134
+ const n = Number(x?.counterfactualTokensUsed);
135
+ return Number.isFinite(n) && n > 0 ? n : null;
136
+ }
137
+
138
+ /** Exact `spill priced:` / `spill priced (streamed):` line the proxy logs. */
139
+ export function spillPricedLine(x, { streamed = false } = {}) {
140
+ const lc = x?.lecore || {};
141
+ const basis = spillPricedTellBasis(x);
142
+ const prefix = streamed ? 'spill priced (streamed):' : 'spill priced:';
143
+ return `${prefix} ${x?.pricing} · basis ${basis ?? '?'} tok vs sent ${lc.tokensBefore ?? '?'} -> ${lc.tokensAfter ?? '?'} · billed ${(x?.billedUsd ?? 0).toFixed(5)} direct ${(x?.directUsd ?? 0).toFixed(5)}`;
144
+ }
145
+
121
146
  /** Expand ~ and resolve relative paths against cwd. Returns null if unusable. */
122
147
  export function resolveReadablePath(p, cwd = process.cwd()) {
123
148
  if (typeof p !== 'string') return null;
@@ -1256,9 +1281,9 @@ function lastUserAskIndex(msgs, firstSpillable) {
1256
1281
  return -1;
1257
1282
  }
1258
1283
 
1259
- function countRealTurns(msgs, from) {
1284
+ function countRealTurns(msgs, from, to = msgs.length) {
1260
1285
  let n = 0;
1261
- for (let i = from; i < msgs.length; i++) {
1286
+ for (let i = from; i < to; i++) {
1262
1287
  const r = msgs[i]?.role;
1263
1288
  if (r === 'user' || r === 'assistant') n += 1;
1264
1289
  }
@@ -1356,7 +1381,9 @@ export function trimUnseverablePairs(msgs, {
1356
1381
 
1357
1382
  /**
1358
1383
  * Pick a severable cut: keep a recent tail, honour the byte budget, floor
1359
- * at minTurns of user/assistant, and never drop the last user ask.
1384
+ * at minTurns of user/assistant on a LONG thread, and never drop the last
1385
+ * user ask. minTurns must not empty the bind prefix — a short AUTO thread
1386
+ * binds early turns and may forward a tail with fewer than minTurns.
1360
1387
  */
1361
1388
  export function cutTranscript(msgs, knobs = {}) {
1362
1389
  const k = sanitizeKnobs({ ...envKnobs(), ...knobs });
@@ -1398,21 +1425,38 @@ export function cutTranscript(msgs, knobs = {}) {
1398
1425
  }
1399
1426
  if (tailStart > cut) cut = tailStart;
1400
1427
 
1428
+ const lastUser = lastUserAskIndex(msgs, firstSpillable);
1429
+
1430
+ // minTurns may still size the tail on a long thread. If walking earlier
1431
+ // would empty the bind prefix (the old firstSpillable+1 fallback), keep a
1432
+ // non-empty prefix and allow fewer than minTurns in the tail. On a short
1433
+ // thread that cannot satisfy minTurns at all, pin the last ask so early
1434
+ // user/assistant turns bind instead of riding in the forwarded tail.
1401
1435
  if (countRealTurns(msgs, cut) < minTurns) {
1436
+ let moved = false;
1402
1437
  for (let i = cut - 1; i > firstSpillable; i--) {
1403
- if (isSeverable(msgs, i, firstSpillable) && countRealTurns(msgs, i) >= minTurns) { cut = i; break; }
1404
- if (i === firstSpillable + 1) { if (isSeverable(msgs, i, firstSpillable)) cut = i; break; }
1438
+ if (isSeverable(msgs, i, firstSpillable) && countRealTurns(msgs, i) >= minTurns) {
1439
+ cut = i;
1440
+ moved = true;
1441
+ break;
1442
+ }
1443
+ }
1444
+ if (!moved && lastUser > firstSpillable && isSeverable(msgs, lastUser, firstSpillable)) {
1445
+ cut = lastUser;
1405
1446
  }
1406
1447
  }
1407
1448
 
1408
- const lastUser = lastUserAskIndex(msgs, firstSpillable);
1409
1449
  if (lastUser > firstSpillable && cut > lastUser) cut = lastUser;
1410
1450
 
1411
- // Never shrink below 2 real turns when that would drop the ask — the ask
1412
- // always stays; expand earlier only if two turns exist and remain after it.
1451
+ // 2-real-turn floor protects the last user ask: never drop it, and expand
1452
+ // the tail to two turns on a LONG thread. Do not steal the first
1453
+ // user+assistant pair from the prefix just to pad a short tail.
1413
1454
  if (lastUser > firstSpillable && countRealTurns(msgs, cut) < 2) {
1414
1455
  for (let i = cut - 1; i > firstSpillable; i--) {
1415
- if (isSeverable(msgs, i, firstSpillable) && countRealTurns(msgs, i) >= 2) { cut = i; break; }
1456
+ if (!isSeverable(msgs, i, firstSpillable) || countRealTurns(msgs, i) < 2) continue;
1457
+ if (countRealTurns(msgs, firstSpillable, i) < 2) continue;
1458
+ cut = i;
1459
+ break;
1416
1460
  }
1417
1461
  if (cut > lastUser) cut = lastUser;
1418
1462
  }
@@ -1420,6 +1464,121 @@ export function cutTranscript(msgs, knobs = {}) {
1420
1464
  return { cut, firstSpillable, lastUser, knobs: k };
1421
1465
  }
1422
1466
 
1467
+ /** Opening slice used as a content-anchor when no session header is sent. */
1468
+ export const SPILL_CONTENT_ANCHOR_CHARS = 2048;
1469
+
1470
+ /**
1471
+ * Find a memoized / ledger bind for this request. Prefers an explicit
1472
+ * session key; otherwise matches a stored prefix so a growing content-anchor
1473
+ * (grokui AUTO sends no session header) still recalls the same contextId.
1474
+ */
1475
+ export function lookupSpillMemo(spillMemo, sessionLedger, { sessionKey, corpus } = {}) {
1476
+ if (sessionKey && spillMemo?.has(sessionKey)) {
1477
+ return { key: sessionKey, source: 'memo', ...spillMemo.get(sessionKey) };
1478
+ }
1479
+ if (sessionKey && sessionLedger?.has(sessionKey)) {
1480
+ const led = sessionLedger.get(sessionKey);
1481
+ if (led?.contextId) return { key: sessionKey, source: 'ledger', restored: !led.corpus, ...led };
1482
+ }
1483
+ if (corpus && spillMemo) {
1484
+ for (const [k, v] of spillMemo) {
1485
+ if (typeof v?.corpus === 'string' && v.corpus.length && corpus.startsWith(v.corpus)) {
1486
+ return { key: k, source: 'memo-prefix', ...v };
1487
+ }
1488
+ }
1489
+ const opening = corpus.slice(0, SPILL_CONTENT_ANCHOR_CHARS);
1490
+ for (const [k, v] of spillMemo) {
1491
+ if (typeof k !== 'string' || k.startsWith('sid:')) continue;
1492
+ if (opening.startsWith(k) || (k && k.startsWith(opening))) {
1493
+ return { key: k, source: 'memo-anchor', ...v };
1494
+ }
1495
+ }
1496
+ }
1497
+ return null;
1498
+ }
1499
+
1500
+ export function rememberSpillMemo(spillMemo, key, entry, { max = 32 } = {}) {
1501
+ if (!spillMemo || !key) return;
1502
+ spillMemo.set(key, entry);
1503
+ while (spillMemo.size > max) spillMemo.delete(spillMemo.keys().next().value);
1504
+ }
1505
+
1506
+ /**
1507
+ * Decide first-bind vs later-turn recall. No I/O — bindCorpus is injected
1508
+ * by the caller (or mocked in tests).
1509
+ *
1510
+ * cold-bind — fire-and-forget first bind; this turn may go unspilled
1511
+ * await-pending — later turn while that bind is in flight; await, then tail
1512
+ * recall — contextId known; send tail (+ optional delta append)
1513
+ */
1514
+ export function planConversationBind({ sessionKey, corpus, spillMemo, sessionLedger } = {}) {
1515
+ const key = sessionKey || (corpus ? corpus.slice(0, SPILL_CONTENT_ANCHOR_CHARS) : null);
1516
+ const prior = lookupSpillMemo(spillMemo, sessionLedger, { sessionKey: key, corpus });
1517
+ if (!prior) {
1518
+ return { action: 'cold-bind', send: 'full', key, corpus };
1519
+ }
1520
+ if (prior.pending && !prior.contextId) {
1521
+ return {
1522
+ action: 'await-pending',
1523
+ send: 'tail',
1524
+ key: prior.key || key,
1525
+ corpus,
1526
+ ready: prior.ready,
1527
+ };
1528
+ }
1529
+ if (prior.contextId) {
1530
+ let delta = '';
1531
+ let append = false;
1532
+ if (prior.restored) {
1533
+ append = true;
1534
+ } else if (typeof prior.corpus === 'string' && corpus.startsWith(prior.corpus) && corpus.length > prior.corpus.length) {
1535
+ delta = corpus.slice(prior.corpus.length);
1536
+ append = true;
1537
+ }
1538
+ return {
1539
+ action: 'recall',
1540
+ send: 'tail',
1541
+ key: prior.key || key,
1542
+ corpus,
1543
+ contextId: prior.contextId,
1544
+ hash: prior.hash,
1545
+ reused: true,
1546
+ append,
1547
+ delta,
1548
+ restored: Boolean(prior.restored),
1549
+ };
1550
+ }
1551
+ return { action: 'cold-bind', send: 'full', key, corpus };
1552
+ }
1553
+
1554
+ /**
1555
+ * Cut the transcript and plan the conversation bind. Tests use this instead
1556
+ * of standing up the proxy or a live gateway.
1557
+ */
1558
+ export function planTranscriptSpill(msgs, {
1559
+ knobs,
1560
+ sessionKey,
1561
+ spillMemo,
1562
+ sessionLedger,
1563
+ corpusChars = 0,
1564
+ adapt = false,
1565
+ persist = false,
1566
+ ...cutOpts
1567
+ } = {}) {
1568
+ const adapted = applySpillCut(msgs, {
1569
+ knobs,
1570
+ corpusChars,
1571
+ adapt,
1572
+ persist,
1573
+ ...cutOpts,
1574
+ });
1575
+ const empty = { ...adapted, corpus: '', bindPlan: null };
1576
+ if (adapted.cut <= adapted.firstSpillable) return empty;
1577
+ const corpus = msgs.slice(adapted.firstSpillable, adapted.cut).map(msgText).filter(Boolean).join('\n\n');
1578
+ const bindPlan = planConversationBind({ sessionKey, corpus, spillMemo, sessionLedger });
1579
+ return { ...adapted, corpus, bindPlan };
1580
+ }
1581
+
1423
1582
  function stubForCut(msgs, cut, opts) {
1424
1583
  return stubBoundFileResults(msgs, {
1425
1584
  boundFiles: opts.boundFiles,
package/lib/x402.js CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  * payTo: "<wallet>", resource, description, maxTimeoutSeconds,
23
23
  * extra: { facilitator, feePayer, symbol, billedUsd, tokenUsd,
24
24
  * pricedAt, pricing: "markup"|"counterfactual",
25
- * markup? , directUsd?, savesVsDirect?,
25
+ * billedUsd, directUsd?, savedUsd?, savesVsDirect?,
26
26
  * acquire?: { method: "spl-token-wrap", steps: WRAP_ACQUIRE_STEPS } } } ],
27
27
  * error: "payment required",
28
28
  * help: "Wrap ix has 9 accounts. Program CPIs the deposit — do not send a separate TransferChecked. 0x6a = NotEnoughAccounts (old 5-account wrap is dead)." }
@@ -232,12 +232,21 @@ export function receiptLine(accept, settle) {
232
232
  // reaches the spill threshold, so there is nothing to compress and nothing
233
233
  // to save — which is worth saying out loud, because the fix on the caller's
234
234
  // side is to BIND a corpus, not to change models.
235
- const ratio = x.savesVsDirect != null ? Number(x.savesVsDirect) : null;
235
+ const billed = Number(x.billedUsd);
236
+ const direct = x.directUsd != null ? Number(x.directUsd) : null;
237
+ const saved = x.savedUsd != null ? Number(x.savedUsd)
238
+ : (Number.isFinite(billed) && Number.isFinite(direct) ? Math.max(0, direct - billed) : null);
239
+ const ratio = x.savesVsDirect != null ? Number(x.savesVsDirect)
240
+ : (Number.isFinite(billed) && billed > 0 && Number.isFinite(direct) ? direct / billed : null);
241
+ // Wallet path: OpenRouter price, plus 33% of savings vs direct when any.
242
+ // Never print extra.markup — that field is leftover 3× and is not the quote.
236
243
  const saves = ratio != null
237
244
  ? (ratio >= 1.05
238
245
  ? ` (${ratio.toFixed(1)}× cheaper than direct)`
239
- : ' (at direct price — nothing to compress; bind a corpus to save)')
240
- : (x.markup != null ? ` (markup ${x.markup}×, short body)` : '');
246
+ : ' (at OpenRouter price — nothing to compress; bind a corpus to save)')
247
+ : (Number.isFinite(saved) && saved > 0
248
+ ? ` ($${saved.toFixed(4)} saved vs direct)`
249
+ : ' (at OpenRouter price — short body)');
241
250
  const tx = settle?.transaction || settle?.txHash || settle?.signature;
242
251
  const rail = railOf(accept);
243
252
  return `paid ${usd}${saves}${rail ? ` · rail ${rail}` : ''}${tx ? ` · tx ${tx}` : ''}`;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.49.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.",
3
+ "version": "0.49.6",
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",
7
7
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "node": ">=18"
17
17
  },
18
18
  "scripts": {
19
- "test": "node --test test/*.test.js"
19
+ "test": "node --test test/*.test.js && node scripts/assert-grokui-pin.mjs"
20
20
  },
21
21
  "dependencies": {
22
22
  "@modelcontextprotocol/sdk": "^1.12.0",