openzoo 0.49.5 → 0.49.7

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/boxes.js CHANGED
@@ -77,7 +77,7 @@ const ENTRYPOINT = [
77
77
  // its HTTP port mappings). The app is read-only at runtime; mutable state
78
78
  // lives in /root/.openzoo.
79
79
  + 'until OPENZOO_BIND=0.0.0.0 node /opt/openzoo/bin/openzoo.js >> /var/log/openzoo/proxy.log 2>&1; do echo "proxy exited, restarting" >> /var/log/openzoo/proxy.log; sleep 2; done & '
80
- + 'until OZ_GROKUI_BIND=0.0.0.0 OZ_GROKUI_PORT=4173 node /opt/grokui/grokui.mjs >> /var/log/openzoo/grokui.log 2>&1; do echo "grokui exited, restarting" >> /var/log/openzoo/grokui.log; sleep 2; done & '
80
+ + 'until OZ_GROKUI_BIND=0.0.0.0 OZ_GROKUI_PORT=4173 node /opt/openzoo/lib/grokui.mjs >> /var/log/openzoo/grokui.log 2>&1; do echo "grokui exited, restarting" >> /var/log/openzoo/grokui.log; sleep 2; done & '
81
81
  // the capture agent answers the ports Grok Bot expects a Cursor sandbox on,
82
82
  // logging the protocol we do not yet speak (see lib/podagent.mjs)
83
83
  + 'if [ -n "$OZ_PODAGENT_B64" ]; then printf %s "$OZ_PODAGENT_B64" | base64 -d > /opt/podagent.mjs; node /opt/podagent.mjs > /var/log/openzoo/agent.log 2>&1 & fi; '
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/models.js CHANGED
@@ -323,10 +323,24 @@ export function isTinyClassify(body, bodyLen) {
323
323
  return messages.length <= CLASSIFY_MAX_MSGS && len < CLASSIFY_MAX_BODY;
324
324
  }
325
325
 
326
+ /**
327
+ * Ids that must never serve Claude Code / grokui AUTO's tiny yes/no classify.
328
+ * REASONING_MODEL_RE is the thinking floor; HEAVY_RE is the flagship set
329
+ * (opus/pro/max/…). opus-5 is openzoo's default session model and does NOT
330
+ * match the reasoning regex, so a catalog that lists opus before flash — or
331
+ * lists only opus + grok — used to pick opus as "first non-reasoner". That
332
+ * classify is a 402 handshake on a big model; AUTO's timeout then hard-blocks
333
+ * Bash instead of prompting.
334
+ */
335
+ function isSlowClassifier(id) {
336
+ const s = String(id || '');
337
+ return REASONING_MODEL_RE.test(s) || HEAVY_RE.test(s);
338
+ }
339
+
326
340
  /**
327
341
  * Fast non-reasoning id that is actually on the zoo. Prefer an explicit
328
342
  * OPENZOO_CLASSIFIER_MODEL, then flash, then haiku, then the first catalog
329
- * id that does not match the reasoning regex.
343
+ * id that is neither a reasoner nor a heavy/flagship (opus/pro/max/…).
330
344
  */
331
345
  export function pickClassifierModel(ids, preferred = process.env.OPENZOO_CLASSIFIER_MODEL) {
332
346
  if (!Array.isArray(ids) || !ids.length) return null;
@@ -334,7 +348,7 @@ export function pickClassifierModel(ids, preferred = process.env.OPENZOO_CLASSIF
334
348
  for (const id of CLASSIFIER_PREFS) {
335
349
  if (ids.includes(id)) return id;
336
350
  }
337
- return ids.find((id) => !REASONING_MODEL_RE.test(id)) || null;
351
+ return ids.find((id) => !isSlowClassifier(id)) || null;
338
352
  }
339
353
 
340
354
  /**
@@ -362,14 +376,22 @@ export function raiseReasoningMaxTokens(parsed, env = process.env) {
362
376
  * Model + max_tokens policy for one chat body.
363
377
  *
364
378
  * Tiny classify: pin to a fast non-reasoning catalog id, leave max_tokens
365
- * alone, ignore OPENZOO_DEFAULT_MODEL. Everything else: resolveModel (which
366
- * honours the default) then the reasoning floor.
379
+ * alone, ignore OPENZOO_DEFAULT_MODEL. Never fall back to `from` when that
380
+ * id is a reasoner or a heavy/flagship (the zoo default is opus-5). A
381
+ * catalog miss or an opus-only list used to keep the classify on opus-5
382
+ * and AUTO hard-blocked Bash. Everything else: resolveModel (which honours
383
+ * the default) then the reasoning floor.
367
384
  */
368
385
  export function rewriteChatModel(parsed, ids, { bodyLen } = {}) {
369
386
  const from = parsed?.model;
370
387
  const len = bodyLen ?? (parsed == null ? 0 : Buffer.byteLength(JSON.stringify(parsed)));
371
388
  if (isTinyClassify(parsed, len)) {
372
- const to = (typeof from === 'string' && pickClassifierModel(ids)) || from;
389
+ const picked = pickClassifierModel(ids);
390
+ // pickClassifierModel returns null on an empty catalog or a zoo that
391
+ // only lists reasoners/heavies. `(picked) || from` left those on
392
+ // anthropic/claude-opus-5 (openzoo's default). Pin to flash instead —
393
+ // never ship a classify body AUTO would time out and hard-block on.
394
+ const to = picked || (typeof from === 'string' && !isSlowClassifier(from) ? from : CLASSIFIER_PREFS[0]);
373
395
  return {
374
396
  parsed: (to && to !== from) ? { ...parsed, model: to } : parsed,
375
397
  tiny: true,
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,