openzoo 0.49.5 → 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/README.md CHANGED
@@ -79,7 +79,7 @@ claude mcp add openzoo -- npx -y openzoo mcp
79
79
  **Windsurf / Cline / any MCP host** — same shape: command `npx`, args `["-y", "openzoo", "mcp"]`, stdio transport.
80
80
 
81
81
  Tools:
82
- - **`zoo_ask`** — `{prompt, corpus?, model?, max_tokens?}`. The flagship: hand it a *huge* `corpus` (hundreds of thousands to ~1M tokens — a body the model itself would refuse) and a question; the zoo's leCore memory spills the corpus so the model reads only a few thousand tokens. Returns the answer plus the receipt (`billedUsd`, `savesVsDirect`, tokens actually read). An MCP-capable agent can delegate a giant-context question without touching its own model config.
82
+ - **`zoo_ask`** — `{prompt, corpus?, model?, max_tokens?}`. The flagship: hand it a *huge* `corpus` (hundreds of thousands to ~1M tokens — a body the model itself would refuse) and a question; the zoo's leCore memory spills the corpus so the model reads only a few thousand tokens. Returns the answer plus the receipt (`billedUsd`, `directUsd`, `savedUsd`, tokens actually read). An MCP-capable agent can delegate a giant-context question without touching its own model config.
83
83
  - **`zoo_models`** — list the zoo's models and pricing (free).
84
84
  - **`zoo_wallet`** — funding address, balances, and this session's receipts.
85
85
 
@@ -93,7 +93,7 @@ Builds a ~965k-token document with one planted fact, shows that buying direct re
93
93
 
94
94
  ```
95
95
  bound once in 14.8s: 3.7MB → context ctx_01KZZY8YQE…
96
- quote for the ask: $0.000480 · pricing=markup (a tiny body the 3.7MB corpus is not re-priced)
96
+ quote for the ask: $0.000480 · pricing=markup · at OpenRouter price (the 3.7MB corpus is not re-priced)
97
97
  ```
98
98
 
99
99
  If the wallet is funded with USDC or TOKEN it pays (capped at `OPENZOO_DEMO_MAX_USD`, default $0.01) and prints the answer, the tokens the model actually read, and the receipt. If not, it prints exactly what to fund. Long waits (the one-time upload, pricing, payment, the answer) show a live progress line with stage + elapsed seconds.
@@ -111,7 +111,7 @@ If the wallet is funded with USDC or TOKEN it pays (capped at `OPENZOO_DEMO_MAX_
111
111
  The zoo keeps your corpus in leCore holographic memory; the shim keeps a manifest at `~/.openzoo/contexts.json` (chmod 600) mapping `sha256(corpus)` → the zoo's `context_id`, scoped per API base. When a request carries a corpus the manifest already knows:
112
112
 
113
113
  - **nothing big is uploaded** — the ask ships alone with an `X-HRR-Context` header,
114
- - **the 402 prices the tiny ask** (honestly labeled `pricing=markup`), typically a few hundredths of a cent instead of re-pricing megabytes,
114
+ - **the 402 prices the tiny ask** at OpenRouter rates (read `extra.billedUsd`), typically a few hundredths of a cent instead of re-pricing megabytes,
115
115
  - **the answer still comes from your corpus** — the zoo recalls the relevant slices server-side.
116
116
 
117
117
  This works in all three fronts: the **proxy** (a big pasted body in the last message is split at its last blank line, bound once, and reused on every later call — even with a different question), the **MCP** `zoo_ask` `corpus` parameter, and the **demo**. If the zoo ever forgets a context (sidecar wipe), the gateway answers 404 *before* any payment and the shim transparently re-binds once and retries — a stale manifest never fails a call.
@@ -175,11 +175,11 @@ Pin the key with `OPENZOO_TUNNEL_TOKEN` if your IDE stores it. Keys never leave
175
175
  ## Honest pricing note
176
176
 
177
177
  Two bases, reported per call in the 402 (`extra.pricing`):
178
- - **Short prompts price at cost.** There's nothing to spill, so there's no saving to share you pay what the call cost us, reconciled against the provider's own metered figure after it completes.
179
- - **Big bodies price at a counterfactual discount** (~10× cheaper than buying the same call direct) — the zoo's leCore memory means it never forwards your whole body upstream, and passes the savings on. Measured numbers at [benches.openzoo.fun](https://benches.openzoo.fun).
180
- - **Asks against a bound corpus price on the small forwarded body** (the receipt says `pricing=markup`). That is not a discount trick: a few hundred tokens at cost is far below the counterfactual price of shipping the corpus, which is the whole point of binding once.
178
+ - **Wallet path charges OpenRouter prices.** There is no 3× markup. If the caller saves vs sending the same body direct, zoo takes 33% of the savings. Short prompts have nothing to spill, so you pay the OpenRouter figure, reconciled against the provider's own metered cost after it completes.
179
+ - **Big bodies price at a counterfactual discount** (~10× cheaper than buying the same call direct) — the zoo's leCore memory means it never forwards your whole body upstream, and passes most of the savings on. Measured numbers at [benches.openzoo.fun](https://benches.openzoo.fun).
180
+ - **Asks against a bound corpus price on the small forwarded body.** That is not a discount trick: a few hundred tokens at OpenRouter rates is far below the counterfactual price of shipping the corpus, which is the whole point of binding once.
181
181
 
182
- The receipt names which base you got; `extra.directUsd` / `extra.savesVsDirect` let you check the math.
182
+ The receipt names which base you got; `extra.billedUsd` / `extra.directUsd` / `extra.savedUsd` let you check the math. (Card subscription pricing is a different path.)
183
183
 
184
184
  ## Payment rails
185
185
 
package/lib/demo.js CHANGED
@@ -120,7 +120,10 @@ export async function runDemo() {
120
120
  }
121
121
  const accept = pickAccept(quote, config.token);
122
122
  const x = accept.extra || {};
123
- console.log(` quote for the ask: $${Number(x.billedUsd).toFixed(6)} · pricing=${x.pricing} (3× a tiny body — the ${docMb}MB corpus is not re-priced)`);
123
+ const saved = x.savedUsd != null ? Number(x.savedUsd)
124
+ : (x.directUsd != null ? Math.max(0, Number(x.directUsd) - Number(x.billedUsd)) : 0);
125
+ const saveBit = saved > 0 ? ` · $${saved.toFixed(6)} saved vs direct` : ' · at OpenRouter price';
126
+ console.log(` quote for the ask: $${Number(x.billedUsd).toFixed(6)} · pricing=${x.pricing}${saveBit} (the ${docMb}MB corpus is not re-priced)`);
124
127
  console.log('');
125
128
 
126
129
  if (Number(x.billedUsd) > config.demoMaxUsd) {
package/lib/grokui.mjs CHANGED
@@ -24,6 +24,13 @@ import {
24
24
  prepareChildDir, finishChildDir, lockWorktree, unlockWorktree,
25
25
  parsePrRef, fetchSpecsForOrigin, agentSlug,
26
26
  } from './worktree.mjs';
27
+ import {
28
+ filesForCorpus as collectFilesForCorpus,
29
+ readFilesForCorpus,
30
+ looksLikeFileView,
31
+ extractBashPaths,
32
+ } from './spill.js';
33
+ import { BIND_MIN_CHARS } from './hrr.js';
27
34
 
28
35
  const PORT = Number(process.env.OZ_GROKUI_PORT || 4173);
29
36
  // BIND HOST. Default 127.0.0.1 so the desktop app never exposes a shell-capable
@@ -391,8 +398,9 @@ comes back with content:null and finish_reason:"length" (confirmed live) if you
391
398
  to append to the same bound context, rather than one giant request that silently fails partway.
392
399
 
393
400
  COST ACCOUNTING — do NOT compute this yourself from token counts. Every response carries an
394
- "x402" object; read the numbers off it: x402.billedUsd (what the user paid), x402.cogsUsd
395
- (our upstream cost), x402.directUsd (what answering WITHOUT the zoo would have cost), and
401
+ "x402" object; read the numbers off it: x402.billedUsd (what the user paid — OpenRouter price, plus 33% of savings vs
402
+ direct when any), x402.cogsUsd (our upstream cost), x402.directUsd (what answering
403
+ WITHOUT the zoo would have cost), x402.savedUsd (dollars saved), and
396
404
  x402.savesVsDirect (the multiple). Summing usage.cost or usage.prompt_tokens and comparing
397
405
  that to a provider's list price is WRONG and understates the saving enormously: against a
398
406
  bound context, prompt_tokens counts only the small slice leCore recalled, NOT the corpus
@@ -401,7 +409,7 @@ the zoo "cost more". MEASURED: a real 21-question run reported 202,238 prompt to
401
409
  each attach call stood in for a 5,356,546-token corpus — a 556x understatement, and that
402
410
  corpus is ~42x larger than the model's own context window, so the "direct" comparison it
403
411
  was measured against was not merely pricier but IMPOSSIBLE. When the user asks what they
404
- saved, quote x402.directUsd and x402.savesVsDirect. If savesVsDirect is below 1x, say so
412
+ saved, quote x402.billedUsd, x402.directUsd and x402.savedUsd. If savedUsd is 0 / savesVsDirect is below 1x, say so
405
413
  plainly — that happens on small inputs, where the corpus is too small to save anything.
406
414
  For normal questions just answer directly — do not use any of these unless the request
407
415
  actually calls for delegation or file work.`;
@@ -618,7 +626,7 @@ this is roleplay, and never fabricate command output or receipts. A real failure
618
626
  as real output and an exit code, not as silence.
619
627
 
620
628
  COST ACCOUNTING — read it off the response's "x402" object (billedUsd, cogsUsd, directUsd,
621
- savesVsDirect). Never derive it by summing usage.cost or usage.prompt_tokens against a
629
+ savedUsd, savesVsDirect). Never derive it by summing usage.cost or usage.prompt_tokens against a
622
630
  provider's list price: on a bound context prompt_tokens counts only the slice leCore
623
631
  recalled, not the corpus it stands in for, so that math prices the discount against itself
624
632
  and wrongly concludes the zoo cost more.
@@ -1123,6 +1131,159 @@ function condense(label, text) {
1123
1131
  return `${label}\n${head}\n\n…[${s.length - head.length - tail.length} chars elided from THIS message — the full output is bound to this thread's holographic memory. It is not lost: mention what you need in your next message and the relevant part is retrieved automatically. Do not invent a command to fetch it.]…\n\n${tail}`;
1124
1132
  }
1125
1133
 
1134
+ // Files the grokui agent READ/WRITE/EDIT/MULTIEDIT/NOTEBOOK'd (and RUN
1135
+ // cat/head/type) must land in the HRR corpus as path-prefixed file parts —
1136
+ // not as unlabeled chat text via bindThread, and not as the ~3k condense()
1137
+ // stub the next completion sends. Sidecar maybeCacheCorpus only sees that
1138
+ // stub; bindThread never labels them as files. filesForCorpus (spill.js)
1139
+ // already dedups path:mtime and caps at KEEP_MAX (400KB). We reuse it.
1140
+ const boundFiles = new Set();
1141
+
1142
+ function pathToReadMsgs(paths) {
1143
+ return (Array.isArray(paths) ? paths : [paths]).filter(Boolean).map((file_path) => ({
1144
+ role: 'assistant',
1145
+ tool_calls: [{
1146
+ function: { name: 'Read', arguments: JSON.stringify({ file_path }) },
1147
+ }],
1148
+ }));
1149
+ }
1150
+
1151
+ function commandToBashMsgs(command) {
1152
+ return [{
1153
+ role: 'assistant',
1154
+ tool_calls: [{
1155
+ function: { name: 'Bash', arguments: JSON.stringify({ command }) },
1156
+ }],
1157
+ }];
1158
+ }
1159
+
1160
+ /**
1161
+ * Collect grokui file reads/writes for the HRR corpus.
1162
+ *
1163
+ * Accepts an absolute/relative path, a list of paths, or already-shaped
1164
+ * chat messages (OpenAI tool_calls / Anthropic tool_use). Dedup and the
1165
+ * 400KB cap live in spill.js — this is the grokui entry point so READ
1166
+ * records the same way Claude Code Read does.
1167
+ */
1168
+ function filesForCorpus(target, opts = {}) {
1169
+ const keys = opts.boundFiles || boundFiles;
1170
+ const cwd = opts.cwd || process.cwd();
1171
+ const cap = opts.cap ?? KEEP_MAX;
1172
+ let msgs = target;
1173
+ if (typeof target === 'string') msgs = pathToReadMsgs(target);
1174
+ else if (Array.isArray(target) && (target.length === 0 || typeof target[0] === 'string')) {
1175
+ msgs = pathToReadMsgs(target);
1176
+ }
1177
+ return collectFilesForCorpus(msgs, { ...opts, boundFiles: keys, cwd, cap });
1178
+ }
1179
+
1180
+ function filesForCorpusKeys() {
1181
+ return boundFiles;
1182
+ }
1183
+
1184
+ function resetFilesForCorpus() {
1185
+ boundFiles.clear();
1186
+ }
1187
+
1188
+ function inFlightChars(t) {
1189
+ if (!t) return 0;
1190
+ if (Array.isArray(t.messages)) {
1191
+ let n = 0;
1192
+ for (const m of t.messages) {
1193
+ const c = m?.content;
1194
+ if (typeof c === 'string') n += c.length;
1195
+ else if (c != null) n += JSON.stringify(c).length;
1196
+ }
1197
+ return n;
1198
+ }
1199
+ let n = 0;
1200
+ for (const h of t.history || []) n += String(h?.text || '').length;
1201
+ return n;
1202
+ }
1203
+
1204
+ /**
1205
+ * POST file bytes to ${PROXY}/hrr/bind, appending to the project context.
1206
+ * Always fire-and-forget so a READ does not wait on the bind round-trip.
1207
+ * When the in-flight corpus/messages are below BIND_MIN_CHARS the bind
1208
+ * still happens — reuse only "wins" on a big body, but the file must be
1209
+ * recallable next turn. Completions keep omitting x-hrr-context.
1210
+ */
1211
+ function scheduleFilesForCorpus(t, collected, opts = {}) {
1212
+ if (!collected?.pending?.length) return null;
1213
+ const root = t ? (threads.get(rootOf(t).rootId) || t) : null;
1214
+ const ctx = opts.contextId || root?.contextId || t?.contextId || null;
1215
+ const chars = opts.sentChars ?? inFlightChars(t);
1216
+ const background = chars < BIND_MIN_CHARS;
1217
+ const fetchImpl = opts.fetchImpl || fetch;
1218
+ const run = () => {
1219
+ let read;
1220
+ try { read = readFilesForCorpus(collected, { boundFiles: opts.boundFiles || boundFiles, cap: opts.cap ?? KEEP_MAX }); }
1221
+ catch { return Promise.resolve(null); }
1222
+ if (!read?.text) return Promise.resolve(null);
1223
+ const payload = ctx ? { corpus: read.text, context_id: ctx } : { corpus: read.text };
1224
+ return fetchImpl(`${PROXY}/hrr/bind`, {
1225
+ method: 'POST',
1226
+ headers: { 'content-type': 'application/json' },
1227
+ body: JSON.stringify(payload),
1228
+ }).then(async (r) => {
1229
+ const j = await r.json().catch(() => ({}));
1230
+ if (j?.context_id && t) {
1231
+ const live = threads.get(rootOf(t).rootId) || t;
1232
+ live.contextId = j.context_id;
1233
+ t.contextId = j.context_id;
1234
+ if (Number(j.bound)) live.boundItems = (live.boundItems || 0) + Number(j.bound);
1235
+ }
1236
+ return j;
1237
+ }).catch(() => null);
1238
+ };
1239
+ const job = { pending: collected.pending, background, append: Boolean(ctx), run };
1240
+ const kick = () => { job.promise = run(); };
1241
+ if (opts.defer === false) kick();
1242
+ else setImmediate(kick);
1243
+ return job;
1244
+ }
1245
+
1246
+ function noteFileForCorpus(originId, relOrAbs, extra = {}) {
1247
+ const t = extra.thread || (originId ? threads.get(originId) : null);
1248
+ const cwd = extra.cwd || (originId ? dirFor(originId) : process.cwd());
1249
+ let abs = relOrAbs;
1250
+ if (typeof abs === 'string' && !path.isAbsolute(abs)) {
1251
+ try { abs = originId ? safeResolveIn(cwd, abs) : path.resolve(cwd, abs); }
1252
+ catch { abs = path.resolve(cwd, abs); }
1253
+ }
1254
+ const collected = filesForCorpus(abs, {
1255
+ cwd,
1256
+ boundFiles: extra.boundFiles || boundFiles,
1257
+ cap: extra.cap ?? KEEP_MAX,
1258
+ ...(extra.collectOpts || {}),
1259
+ });
1260
+ return scheduleFilesForCorpus(t, collected, extra);
1261
+ }
1262
+
1263
+ function noteRunForCorpus(originId, command, extra = {}) {
1264
+ if (!looksLikeFileView(command)) return null;
1265
+ const t = extra.thread || (originId ? threads.get(originId) : null);
1266
+ const cwd = extra.cwd || (originId ? dirFor(originId) : process.cwd());
1267
+ const collected = filesForCorpus(commandToBashMsgs(command), {
1268
+ cwd,
1269
+ boundFiles: extra.boundFiles || boundFiles,
1270
+ cap: extra.cap ?? KEEP_MAX,
1271
+ });
1272
+ if (!collected.pending.length) {
1273
+ const { paths } = extractBashPaths(command, cwd);
1274
+ const abs = paths.map((p) => {
1275
+ const raw = p?.raw;
1276
+ if (!raw) return null;
1277
+ if (path.isAbsolute(raw)) return raw;
1278
+ try { return originId ? safeResolveIn(p.cwd || cwd, raw) : path.resolve(p.cwd || cwd, raw); }
1279
+ catch { return path.resolve(p.cwd || cwd, raw); }
1280
+ }).filter(Boolean);
1281
+ if (!abs.length) return null;
1282
+ return noteFileForCorpus(originId, abs, extra);
1283
+ }
1284
+ return scheduleFilesForCorpus(t, collected, extra);
1285
+ }
1286
+
1126
1287
  // threadId -> open SSE responses. A Set because the same thread can be open in
1127
1288
  // two tabs, and both should see the same tokens.
1128
1289
  const streamListeners = new Map();
@@ -2095,6 +2256,7 @@ async function tryDirective(reply, originId, onEvent) {
2095
2256
  const full = safeResolveIn(dirFor(originId), rel);
2096
2257
  mkdirSync(path.dirname(full), { recursive: true });
2097
2258
  writeFileSync(full, content);
2259
+ noteFileForCorpus(originId, full);
2098
2260
  return `Wrote ${rel} (${Buffer.byteLength(content)} bytes) to ${dirFor(originId)}.${await previewAck(originId, rel)}`;
2099
2261
  } catch (e) { return `Couldn't write ${rel}: ${e.message}`; }
2100
2262
  }
@@ -2102,7 +2264,9 @@ async function tryDirective(reply, originId, onEvent) {
2102
2264
  if (readD) {
2103
2265
  const rel = readD[1].trim();
2104
2266
  try {
2105
- const data = readFileSync(safeResolveIn(dirFor(originId), rel), 'utf8');
2267
+ const full = safeResolveIn(dirFor(originId), rel);
2268
+ const data = readFileSync(full, 'utf8');
2269
+ noteFileForCorpus(originId, full);
2106
2270
  return `${rel}:\n${keepWhole(data)}`;
2107
2271
  } catch (e) { return `Couldn't read ${rel}: ${e.message}`; }
2108
2272
  }
@@ -2121,6 +2285,7 @@ async function tryDirective(reply, originId, onEvent) {
2121
2285
  if (hits === 0) return `EDIT ${rel}: that exact text isn't in the file — READ it first, the copy must match byte for byte.`;
2122
2286
  if (hits > 1) return `EDIT ${rel}: that text appears ${hits} times — include more surrounding context so it matches exactly once.`;
2123
2287
  writeFileSync(full, before.replace(oldStr, newStr));
2288
+ noteFileForCorpus(originId, full);
2124
2289
  return `Edited ${rel} (${before.length} -> ${before.replace(oldStr, newStr).length} bytes).${await previewAck(originId, rel)}`;
2125
2290
  } catch (e) { return `Couldn't edit ${rel}: ${e.message}`; }
2126
2291
  }
@@ -2147,6 +2312,7 @@ async function tryDirective(reply, originId, onEvent) {
2147
2312
  applied.push(o.slice(0, 40));
2148
2313
  }
2149
2314
  writeFileSync(full, next);
2315
+ noteFileForCorpus(originId, full);
2150
2316
  return `MULTIEDIT ${rel}: ${applied.length} edit(s) applied (${before.length} -> ${next.length} bytes).${await previewAck(originId, rel)}`;
2151
2317
  } catch (e) { return `Couldn't multiedit ${rel}: ${e.message}`; }
2152
2318
  }
@@ -2167,6 +2333,7 @@ async function tryDirective(reply, originId, onEvent) {
2167
2333
  // Stale outputs next to new code are worse than none.
2168
2334
  if (doc.cells[idx].cell_type === 'code') { doc.cells[idx].outputs = []; doc.cells[idx].execution_count = null; }
2169
2335
  writeFileSync(full, JSON.stringify(doc, null, 1));
2336
+ noteFileForCorpus(originId, full);
2170
2337
  return `NOTEBOOK ${rel}: replaced cell ${idx} (${doc.cells[idx].cell_type}); outputs cleared.`;
2171
2338
  } catch (e) { return `Couldn't edit ${rel}: ${e.message}`; }
2172
2339
  }
@@ -2520,6 +2687,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2520
2687
  if (t.runMode === 'auto') {
2521
2688
  emitLiveStatus(t.id, paint, peekDirectiveStatus('', command));
2522
2689
  const output = await execCommand(command, dirFor(t.id));
2690
+ noteRunForCorpus(t.id, command, { cwd: dirFor(t.id) });
2523
2691
  const shown = `$ ${command}\n${output}`;
2524
2692
  t.history.push({ who: 'bot', text: shown, name: m.name, color: m.color });
2525
2693
  paint({ type: 'final', name: m.name, color: m.color, text: shown });
@@ -2663,6 +2831,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2663
2831
  if (t.runMode === 'auto') {
2664
2832
  emitLiveStatus(t.id, paint, peekDirectiveStatus('', command));
2665
2833
  const output = await execCommand(command, dirFor(t.id));
2834
+ noteRunForCorpus(t.id, command, { cwd: dirFor(t.id) });
2666
2835
  if (!stillMine()) return;
2667
2836
  const shown = `$ ${command}\n${output}`;
2668
2837
  t.history.push({ who: 'bot', text: shown });
@@ -5614,6 +5783,7 @@ const server = http.createServer((req, res) => {
5614
5783
  res.writeHead(200, { 'content-type': 'application/json' });
5615
5784
  res.end('{"ok":true}');
5616
5785
  execCommand(command, cwd).then((output) => {
5786
+ noteRunForCorpus(t.id, command, { cwd });
5617
5787
  entry.runStatus = 'done';
5618
5788
  entry.runOutput = output;
5619
5789
  saveThreads();
@@ -5722,4 +5892,6 @@ export {
5722
5892
  isDoneReply, isTransientModelFail, isEmptyToolResult, enqueueAutoHop, childKickoff, findByName,
5723
5893
  attachChildDir, finishChildDir,
5724
5894
  lockWorktree, unlockWorktree, parsePrRef, fetchSpecsForOrigin, agentSlug,
5895
+ filesForCorpus, noteFileForCorpus, noteRunForCorpus, resetFilesForCorpus,
5896
+ filesForCorpusKeys, scheduleFilesForCorpus, inFlightChars, BIND_MIN_CHARS, KEEP_MAX,
5725
5897
  };
package/lib/gui.html CHANGED
@@ -194,7 +194,10 @@ async function send() {
194
194
  const x = d.x402 || {};
195
195
  state.calls += 1;
196
196
  if (typeof x.billedUsd === "number") state.spent += x.billedUsd;
197
- if (typeof x.savesVsDirect === "number" && x.savesVsDirect > 0) state.saved += x.savesVsDirect;
197
+ if (typeof x.savedUsd === "number" && x.savedUsd > 0) state.saved += x.savedUsd;
198
+ else if (typeof x.directUsd === "number" && typeof x.billedUsd === "number") {
199
+ state.saved += Math.max(0, x.directUsd - x.billedUsd);
200
+ }
198
201
  const bits = [];
199
202
  if (typeof x.billedUsd === "number") bits.push(`$${x.billedUsd.toFixed(4)}`);
200
203
  if (x.lecore?.engaged) bits.push(`🧠 ${x.lecore.recalled ?? "?"} slices`);
package/lib/mcp.js CHANGED
@@ -80,7 +80,7 @@ export function buildMcpServer() {
80
80
  + '(client-usable ceiling ~128M tokens; keep one call under ~9.8M) is bound once and the model reads only '
81
81
  + 'a few thousand tokens of it. Re-asking against an already-bound corpus is near-free and much faster, so '
82
82
  + 'send the corpus once and ask many questions. Returns the answer plus a payment receipt '
83
- + '(billedUsd, savesVsDirect, tokens actually read).',
83
+ + '(billedUsd, directUsd, savedUsd, tokens actually read).',
84
84
  inputSchema: {
85
85
  prompt: z.string().describe('The question or instruction.'),
86
86
  corpus: z.string().optional().describe('Optional big context body (document dump, logs, book...). Placed before the prompt.'),
@@ -128,6 +128,8 @@ export function buildMcpServer() {
128
128
  receipt: receipt ? {
129
129
  line: receipt.line,
130
130
  billedUsd: receipt.billedUsd,
131
+ directUsd: receipt.directUsd,
132
+ savedUsd: receipt.savedUsd,
131
133
  savesVsDirect: receipt.savesVsDirect,
132
134
  pricing: receipt.pricing,
133
135
  rail: receipt.rail,
package/lib/pay.js CHANGED
@@ -413,6 +413,9 @@ export class PayClient {
413
413
  line: receiptLine(accept, settle),
414
414
  rail: railOf(accept),
415
415
  billedUsd: accept.extra?.billedUsd,
416
+ directUsd: accept.extra?.directUsd,
417
+ savedUsd: accept.extra?.savedUsd,
418
+ cogsUsd: accept.extra?.cogsUsd,
416
419
  savesVsDirect: accept.extra?.savesVsDirect,
417
420
  pricing: accept.extra?.pricing,
418
421
  symbol: accept.extra?.symbol,
package/lib/proxy.js CHANGED
@@ -15,6 +15,8 @@ import { bindCorpus, contextCacheDisabled, BIND_MIN_CHARS } from './hrr.js';
15
15
  import {
16
16
  loadBoundChars, noteCorpusLedger, filesForCorpus, readFilesForCorpus, boundAbsFromKeys,
17
17
  createSpillStats, corpusCharsForSend, applySpillCut, msgText, hudDollarX,
18
+ spillPricedLine,
19
+ planConversationBind, rememberSpillMemo, SPILL_CONTENT_ANCHOR_CHARS,
18
20
  } from './spill.js';
19
21
  import { rewritablePath, augmentModelList, ALIAS_IDS, rewriteChatModel, zooModelIds, CLASSIFY_MAX_TOKENS } from './models.js';
20
22
  import { forgetContext } from './contexts.js';
@@ -26,7 +28,7 @@ import { loadSessionSpend, saveSessionSpend } from './session.js';
26
28
  import { creditBalance, quotedPrices } from './info.js';
27
29
  import { subscriptionPublicView } from './subscription.js';
28
30
  import { priceHoldings } from './livestatus.js';
29
- import { receiptUsedCogs } from './racesettle.js';
31
+ import { receiptUsedCogs, receiptDirectUsd } from './racesettle.js';
30
32
  import { rewriteWrapClientError } from './wrap.js';
31
33
 
32
34
  const HOP_BY_HOP = new Set([
@@ -423,11 +425,6 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
423
425
  });
424
426
  };
425
427
 
426
- if (msgs.length < 6) {
427
- bindFilesInBackground('conversation under 6 messages, background');
428
- return null;
429
- }
430
-
431
428
  // LIVE SELF-TUNER. Env knobs seed the first cut; after cut+stub the proxy
432
429
  // scores the HUD dollar multiple (spill direct/billed) and retunes
433
430
  // keep/min-turns/budget (stubMore for SEARCH, not live file bodies)
@@ -488,35 +485,23 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
488
485
  //
489
486
  // FILES RIDE THE BACKGROUND, NEVER THE CRITICAL PATH.
490
487
  //
491
- // The FIRST bind of a session is necessarily synchronous the request cannot
492
- // go until the context_id exists, because it travels as x-hrr-context. Folding
493
- // file bytes into that bind put a 400KB upload in front of the caller's turn,
494
- // which is the cold-bind stall this whole exercise was meant to remove: the
495
- // bind endpoint measures 0.34-0.48s on a 613KB corpus, and that is 0.34-0.48s
496
- // the user waits before a single token appears.
488
+ // The first conversation bind is also fire-and-forget: turn 1 may go out
489
+ // unspilled while the bind runs, then later turns recall a tail + contextId.
490
+ // Folding file bytes into a synchronous first bind used to put a 400KB
491
+ // upload in front of the caller's turn (0.34-0.48s on a 613KB corpus).
497
492
  //
498
- // Nothing recalls a file during the turn that read it � the model already has
493
+ // Nothing recalls a file during the turn that read it � the model already has
499
494
  // the tool result in its window. Files are only worth having bound for the
500
- // NEXT ask. So the conversation binds inline and the files are appended after
501
- // the fact, off the clock.
495
+ // NEXT ask, so they append after the conversation bind completes.
502
496
  const turns = msgs.slice(firstSpillable, cut).map(msgText).filter(Boolean).join('\n\n');
503
497
  const corpus = turns;
504
- if (!sessionKey) sessionKey = corpus.slice(0, 2048);
498
+ if (!sessionKey) sessionKey = corpus.slice(0, SPILL_CONTENT_ANCHOR_CHARS);
505
499
 
506
- // FILES BIND EVEN WHEN THE CONVERSATION IS TOO SMALL TO SPILL.
507
- //
508
- // The threshold exists to stop us binding a two-line chat � it was never
509
- // meant to gate FILES. But bailing here skipped them entirely, so a fresh
510
- // session that reads a 200KB file bound nothing and scored 1.00x forever:
511
- // OBSERVED on a live session that read files all turn and never produced a
512
- // corpus, because its conversation stayed under the threshold the whole time.
513
- //
514
- // A file is worth binding on its own merit. So when the turns are too small
515
- // to spill but files exist, bind the files anyway � in the background,
516
- // against this session's context � and let this turn go unspilled. The corpus
517
- // is then waiting for the next ask.
518
- if (corpus.length <= BIND_MIN_CHARS) {
519
- bindFilesInBackground('conversation under spill threshold, background');
500
+ // Conversation prefix binds at any size. BIND_MIN_CHARS only gates the
501
+ // one-shot corpus+question path in maybeCacheCorpus � a real but small
502
+ // early-turn prefix used to be discarded here ("only large ones").
503
+ if (!corpus) {
504
+ bindFilesInBackground('empty conversation prefix, files only');
520
505
  return null;
521
506
  }
522
507
 
@@ -524,7 +509,7 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
524
509
  //
525
510
  // A transcript grows by one message per turn, so the whole-corpus hash misses
526
511
  // every time and the old code re-uploaded the ENTIRE prefix on every single
527
- // turn OBSERVED live: 0.4MB bound three turns running, a fresh context id
512
+ // turn OBSERVED live: 0.4MB bound three turns running, a fresh context id
528
513
  // each time, while only a few KB was actually new. Bind cost grew with
529
514
  // conversation length and was re-paid per message.
530
515
  //
@@ -537,7 +522,7 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
537
522
  // The anchor was the first 2KB of corpus, which works only because a
538
523
  // transcript's opening never changes. It is fragile in exactly the cases that
539
524
  // matter: two sessions that open identically (same system block, same first
540
- // instruction the norm for an agent) collide onto ONE bound context and
525
+ // instruction the norm for an agent) collide onto ONE bound context and
541
526
  // interleave their histories, and any edit near the top of a transcript
542
527
  // silently orphans the binding and re-uploads the whole thing.
543
528
  //
@@ -545,60 +530,109 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
545
530
  // back to the content anchor when it is not. Same memo, better key.
546
531
  // CAPTURED FROM A LIVE claude-cli/2.1.232 REQUEST, not guessed. The first
547
532
  // version of this checked x-session-id / x-claude-session-id /
548
- // metadata.user_id none of which Claude Code sends, so it silently fell
533
+ // metadata.user_id none of which Claude Code sends, so it silently fell
549
534
  // back to the content anchor on every request and the feature did nothing.
550
535
  // The real header list is:
551
536
  // anthropic-beta, anthropic-version, x-app, x-claude-code-session-id,
552
537
  // x-stainless-*
553
- const anchor = sessionKey;
554
- const persisted = sessionKey ? sessionLedger.get(sessionKey) : null;
555
- const prior = spillMemo.get(anchor) || (persisted?.contextId
556
- ? { contextId: persisted.contextId, hash: persisted.hash || '', corpus: null, restored: true }
557
- : null);
538
+ //
539
+ // COLD-BIND. Turn 1 may go unspilled (full messages, no x-hrr-context) while
540
+ // the first bind runs in the background � same spirit as file-bind, so the
541
+ // opening ask is not stalled. The in-flight/completed bind is memoized on
542
+ // the session key; later turns recall a tail + contextId. Subsequent
543
+ // appends stay fire-and-forget deltas.
544
+ let bindPlan = planConversationBind({
545
+ sessionKey,
546
+ corpus,
547
+ spillMemo,
548
+ sessionLedger,
549
+ });
550
+ const anchor = bindPlan.key || sessionKey;
551
+
552
+ if (bindPlan.action === 'cold-bind') {
553
+ const ready = bindCorpus(corpus, {
554
+ onStage: (stage, info) => {
555
+ if (stage === 'binding') log(`binding ${mb(info.bytes)}MB of transcript to holographic memory (background)...`);
556
+ },
557
+ }).then((b) => {
558
+ if (!b?.contextId) return b;
559
+ noteCorpusLedger(boundChars, {
560
+ contextId: b.contextId,
561
+ reused: false,
562
+ corpusChars: corpus.length,
563
+ deltaChars: 0,
564
+ fileChars: 0,
565
+ ...ledgerOpts(),
566
+ });
567
+ rememberSpillMemo(spillMemo, anchor, { corpus, contextId: b.contextId, hash: b.hash });
568
+ bindFilesInBackground('background', { appendTo: b.contextId, asAppend: true });
569
+ return b;
570
+ }).catch((e) => {
571
+ log(`bind failed (turn went unspilled): ${e.message}`);
572
+ const cur = spillMemo.get(anchor);
573
+ if (cur?.pending) spillMemo.delete(anchor);
574
+ return null;
575
+ });
576
+ rememberSpillMemo(spillMemo, anchor, { corpus, pending: true, ready });
577
+ return null;
578
+ }
579
+
580
+ if (bindPlan.action === 'await-pending') {
581
+ const finished = await bindPlan.ready.catch((e) => {
582
+ log(`bind failed (history may lag one turn): ${e.message}`);
583
+ return null;
584
+ });
585
+ if (!finished?.contextId) {
586
+ bindFilesInBackground('first bind failed, files only');
587
+ return null;
588
+ }
589
+ bindPlan = planConversationBind({
590
+ sessionKey: anchor,
591
+ corpus,
592
+ spillMemo,
593
+ sessionLedger,
594
+ });
595
+ }
596
+
597
+ if (bindPlan.action !== 'recall' || !bindPlan.contextId) {
598
+ bindFilesInBackground('no context yet, files only');
599
+ return null;
600
+ }
601
+
558
602
  let bind;
559
603
  let deltaChars = 0;
560
604
  let appended = false;
561
- if (prior?.restored && prior.contextId) {
605
+ if (bindPlan.restored && bindPlan.contextId) {
562
606
  // Sidecar came back up: we still know the context_id and the accumulated
563
607
  // char count, but not the prior prefix string, so we cannot slice a delta.
564
608
  // Re-append the current prefix (some overlap is harmless) and keep the
565
- // restored ledger � do not add corpus.length again.
609
+ // restored ledger � do not add corpus.length again.
566
610
  void bindCorpus(corpus, {
567
- appendTo: prior.contextId,
611
+ appendTo: bindPlan.contextId,
568
612
  onStage: (stage, info) => {
569
- if (stage === 'binding') log(`appending ${mb(info.bytes)}MB to ${prior.contextId} (restored session, background)`);
613
+ if (stage === 'binding') log(`appending ${mb(info.bytes)}MB to ${bindPlan.contextId} (restored session, background)`);
570
614
  },
571
615
  }).catch((e) => log(`append failed (history may lag one turn): ${e.message}`));
572
616
  appended = true;
573
- bind = { contextId: prior.contextId, hash: prior.hash, reused: true, bytes: 0 };
574
- } else if (prior && typeof prior.corpus === 'string' && corpus.startsWith(prior.corpus) && corpus.length > prior.corpus.length) {
575
- const delta = corpus.slice(prior.corpus.length);
617
+ bind = { contextId: bindPlan.contextId, hash: bindPlan.hash, reused: true, bytes: 0 };
618
+ } else if (bindPlan.append && bindPlan.delta) {
619
+ const delta = bindPlan.delta;
576
620
  deltaChars = delta.length;
577
- // FIRE AND FORGET. This delta is history for FUTURE turns � the answer
621
+ // FIRE AND FORGET. This delta is history for FUTURE turns � the answer
578
622
  // being generated right now is served from the tail plus what is already
579
623
  // bound, so waiting on the upload buys nothing and costs the user the
580
624
  // round trip on every single turn. The context id is already known, so
581
625
  // nothing is lost by not waiting for it.
582
- //
583
- // The FIRST bind is deliberately NOT async: the request must carry
584
- // x-hrr-context, and that id does not exist until the bind returns. Firing
585
- // that one off would send the opening turn with no context at all � a
586
- // silently worse answer traded for a shorter pause, which is the wrong way
587
- // round.
588
626
  void bindCorpus(delta, {
589
- appendTo: prior.contextId,
627
+ appendTo: bindPlan.contextId,
590
628
  onStage: (stage, info) => {
591
- if (stage === 'binding') log(`appending ${mb(info.bytes)}MB to ${prior.contextId} (delta, background)`);
629
+ if (stage === 'binding') log(`appending ${mb(info.bytes)}MB to ${bindPlan.contextId} (delta, background)`);
592
630
  },
593
631
  }).catch((e) => log(`append failed (history may lag one turn): ${e.message}`));
594
632
  appended = true;
595
- bind = { contextId: prior.contextId, hash: prior.hash, reused: true, bytes: delta.length };
633
+ bind = { contextId: bindPlan.contextId, hash: bindPlan.hash, reused: true, bytes: delta.length };
596
634
  } else {
597
- bind = await bindCorpus(corpus, {
598
- onStage: (stage, info) => {
599
- if (stage === 'binding') log(`binding ${mb(info.bytes)}MB of transcript to holographic memory...`);
600
- },
601
- });
635
+ bind = { contextId: bindPlan.contextId, hash: bindPlan.hash, reused: true, bytes: 0 };
602
636
  }
603
637
  // CONVERSATION LEDGER � every successful bind AND append, not only when
604
638
  // files exist. First bind initializes to the bound corpus size; each append
@@ -618,8 +652,7 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
618
652
  // uploads each version exactly once no matter how often the agent re-reads it.
619
653
  // Read + readdir are inside setImmediate � they must not run before send().
620
654
  bindFilesInBackground('background', { appendTo: bind.contextId, asAppend: true });
621
- spillMemo.set(anchor, { corpus, contextId: bind.contextId, hash: bind.hash });
622
- if (spillMemo.size > 32) spillMemo.delete(spillMemo.keys().next().value);
655
+ rememberSpillMemo(spillMemo, anchor, { corpus, contextId: bind.contextId, hash: bind.hash });
623
656
  // Count the tail that is actually forwarded after stub/trim � older
624
657
  // continue-turn rounds after the ask may have been dropped, so
625
658
  // msgs.length - cut would keep lastSend growing with the raw pile.
@@ -793,7 +826,6 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
793
826
  say(`session restored: $${sessionSpent.toFixed(6)} � ${paidCalls} paid call${paidCalls === 1 ? '' : 's'}`);
794
827
  }
795
828
  process.on('exit', rememberSpend);
796
- const MARKUP = 3; // confirmed constant, see .claude/wiki.md "Margin needs a like-for-like denominator"
797
829
  const noteQuote = (x) => {
798
830
  const billed = Number(x?.billedUsd);
799
831
  if (!Number.isFinite(billed) || billed < 0) return;
@@ -1519,30 +1551,20 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1519
1551
  if (paid && receipt) {
1520
1552
  if (receipt.ok && typeof receipt.billedUsd === 'number') {
1521
1553
  sessionSpent += receipt.billedUsd;
1522
- // cogs: no per-call field for it, but MARKUP is a known constant
1523
- // (3x confirmed against the gateway's own margin math), and
1524
- // billedUsd = cogs * markup on a straight-markup call. Close enough
1525
- // on a counterfactual (leCore-discounted) call too since markup is
1526
- // still the ceiling those get capped against.
1527
- // Prefer the gateway's own cogsUsd. Deriving it as billedUsd/MARKUP
1528
- // is only correct on a straight-markup call: under counterfactual
1529
- // pricing billedUsd is min(direct×discount, markupUsd), so the
1530
- // division understates cost and overstates margin.
1531
- sessionCogs += receiptUsedCogs(receipt, MARKUP);
1554
+ // Wallet path: no 3x. Prefer extra.cogsUsd / billedUsd / directUsd /
1555
+ // savedUsd from the 402. billedUsd is the OpenRouter price (plus
1556
+ // zoo's 33% of savings when the caller beat direct).
1557
+ sessionCogs += receiptUsedCogs(receipt);
1532
1558
  noteQuote(receipt);
1533
1559
  // direct = what answering this WITHOUT the zoo would have cost. On an
1534
1560
  // attach call that is the whole bound corpus, which is why it can be
1535
- // orders of magnitude above what was billed. directUsd is exact and
1536
- // always present; savesVsDirect is the same number as a ratio.
1561
+ // orders of magnitude above what was billed. Read extra.directUsd /
1562
+ // extra.savedUsd; do not invent billed * 3.
1537
1563
  if (didSpill) {
1538
1564
  spill.spillSpend += receipt.billedUsd || 0;
1539
- spill.spillDirect += typeof receipt.directUsd === 'number' ? receipt.directUsd : (receipt.billedUsd || 0);
1565
+ spill.spillDirect += receiptDirectUsd(receipt);
1540
1566
  }
1541
- sessionDirect += typeof receipt.directUsd === 'number'
1542
- ? receipt.directUsd
1543
- : typeof receipt.savesVsDirect === 'number'
1544
- ? receipt.savesVsDirect * receipt.billedUsd
1545
- : receipt.billedUsd;
1567
+ sessionDirect += receiptDirectUsd(receipt);
1546
1568
  // The public-URL ceiling meters only public-origin spend — your own
1547
1569
  // local calls never eat into it.
1548
1570
  if (viaTunnel) tunnelSpent += receipt.billedUsd;
@@ -1599,23 +1621,19 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1599
1621
  if (!paid && data?.x402 && typeof data.x402.billedUsd === 'number') {
1600
1622
  const x = data.x402;
1601
1623
  sessionSpent += x.billedUsd;
1602
- sessionCogs += receiptUsedCogs(x, MARKUP);
1624
+ sessionCogs += receiptUsedCogs(x);
1603
1625
  noteQuote(x);
1604
- sessionDirect += typeof x.directUsd === 'number' ? x.directUsd : x.billedUsd;
1626
+ sessionDirect += receiptDirectUsd(x);
1605
1627
  if (didSpill) {
1606
1628
  // THE number that settles why a spilled call did or did not save:
1607
1629
  // the gateway only prices a counterfactual when corpusTokens >
1608
1630
  // promptTokens, so a tail that rivals the corpus silently falls
1609
- // back to markup and direct collapses onto billed.
1610
- const lc = x.lecore || {};
1611
- // counterfactualTokensUsed is the basis the gateway ACTUALLY priced
1612
- // on. lecore.corpusTokens is often absent and reading it printed
1613
- // 'corpus ?' on calls that were pricing fine — which sent a whole
1614
- // night's debugging after a number that was never the input.
1615
- const basis = x.counterfactualTokensUsed ?? lc.corpusTokens;
1616
- log(`spill priced: ${x.pricing} · basis ${basis ?? '?'} tok vs sent ${lc.tokensBefore ?? '?'} -> ${lc.tokensAfter ?? '?'} · billed ${(x.billedUsd ?? 0).toFixed(5)} direct ${(x.directUsd ?? 0).toFixed(5)}`);
1631
+ // back to at-cost and direct collapses onto billed.
1632
+ // Tell-line prints the gateway's actual counterfactual only.
1633
+ // Never fall back to lecore.corpusTokens (often == tokensBefore).
1634
+ log(spillPricedLine(x));
1617
1635
  spill.spillSpend += x.billedUsd;
1618
- spill.spillDirect += typeof x.directUsd === 'number' ? x.directUsd : x.billedUsd;
1636
+ spill.spillDirect += receiptDirectUsd(x);
1619
1637
  }
1620
1638
  paidCalls += 1;
1621
1639
  if (viaTunnel) tunnelSpent += x.billedUsd;
@@ -1665,15 +1683,14 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1665
1683
  const meterStreamed = (x) => {
1666
1684
  if (paid || typeof x?.billedUsd !== 'number') return;
1667
1685
  sessionSpent += x.billedUsd;
1668
- sessionCogs += receiptUsedCogs(x, MARKUP);
1686
+ sessionCogs += receiptUsedCogs(x);
1669
1687
  noteQuote(x);
1670
- sessionDirect += typeof x.directUsd === 'number' ? x.directUsd : x.billedUsd;
1688
+ sessionDirect += receiptDirectUsd(x);
1671
1689
  if (typeof x.actualUsd === 'number' && x.actualUsd >= 0) { sessionActual += x.actualUsd; actualCalls += 1; billedWithActual += x.billedUsd || 0; }
1672
1690
  if (didSpill) {
1673
- const lc = x.lecore || {};
1674
- log(`spill priced (streamed): ${x.pricing} · basis ${x.counterfactualTokensUsed ?? '?'} tok vs sent ${lc.tokensBefore ?? '?'} -> ${lc.tokensAfter ?? '?'} · billed ${(x.billedUsd ?? 0).toFixed(5)} direct ${(x.directUsd ?? 0).toFixed(5)}`);
1691
+ log(spillPricedLine(x, { streamed: true }));
1675
1692
  spill.spillSpend += x.billedUsd;
1676
- spill.spillDirect += typeof x.directUsd === 'number' ? x.directUsd : x.billedUsd;
1693
+ spill.spillDirect += receiptDirectUsd(x);
1677
1694
  }
1678
1695
  paidCalls += 1;
1679
1696
  if (viaTunnel) tunnelSpent += x.billedUsd;
package/lib/racesettle.js CHANGED
@@ -155,13 +155,29 @@ export function capRaceByCredit(n, { creditUsd, quoteUsd } = {}) {
155
155
  * House cost from the receipt. Do not subtract race_unused — unused
156
156
  * grant-back is not a user refund, and shrinking cogs would hide house loss.
157
157
  * Does not clamp to billed — HUD embers when cogs exceed what was paid.
158
+ *
159
+ * Wallet path: there is no 3× markup. Prefer the gateway's cogsUsd; otherwise
160
+ * billedUsd (OpenRouter price, plus zoo's 33% of savings when the 402 has any).
158
161
  */
159
- export function receiptUsedCogs(x, markup = 3) {
162
+ export function receiptUsedCogs(x) {
160
163
  if (!x || typeof x !== 'object') return 0;
161
- const billedRaw = Number(x.billedUsd);
162
- const billedOk = Number.isFinite(billedRaw) && billedRaw >= 0;
163
164
  if (typeof x.cogsUsd === 'number' && Number.isFinite(x.cogsUsd)) return x.cogsUsd;
164
- return billedOk ? billedRaw / markup : 0;
165
+ const billedRaw = Number(x.billedUsd);
166
+ return Number.isFinite(billedRaw) && billedRaw >= 0 ? billedRaw : 0;
167
+ }
168
+
169
+ /** Counterfactual from the 402: extra.directUsd, else billed + savedUsd. */
170
+ export function receiptDirectUsd(x) {
171
+ if (typeof x?.directUsd === 'number' && Number.isFinite(x.directUsd)) return x.directUsd;
172
+ const billed = Number(x?.billedUsd);
173
+ const billedOk = Number.isFinite(billed) && billed >= 0;
174
+ if (typeof x?.savedUsd === 'number' && Number.isFinite(x.savedUsd) && billedOk) {
175
+ return billed + x.savedUsd;
176
+ }
177
+ if (typeof x?.savesVsDirect === 'number' && Number.isFinite(x.savesVsDirect) && billedOk) {
178
+ return x.savesVsDirect * billed;
179
+ }
180
+ return billedOk ? billed : 0;
165
181
  }
166
182
 
167
183
  /**
@@ -169,12 +185,10 @@ export function receiptUsedCogs(x, markup = 3) {
169
185
  * first-call rewrite, never a race_unused user refund.
170
186
  * cogs is the house cost on that receipt (HUD embers when cogs > spent).
171
187
  */
172
- export function meterRaceReceipt(x, markup = 3) {
188
+ export function meterRaceReceipt(x) {
173
189
  const billed = Number(x?.billedUsd);
174
190
  const spentUsd = Number.isFinite(billed) ? billed : 0;
175
- const usedCogs = receiptUsedCogs(x, markup);
176
- const direct = typeof x?.directUsd === 'number' ? x.directUsd : spentUsd;
177
- return { spentUsd, cogsUsd: usedCogs, directUsd: direct };
191
+ return { spentUsd, cogsUsd: receiptUsedCogs(x), directUsd: receiptDirectUsd(x) };
178
192
  }
179
193
 
180
194
  export function inferRaceTier(models, fallback = 'medium') {
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.5",
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",