openzoo 0.2.1 → 0.3.0

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
@@ -89,13 +89,42 @@ Tools:
89
89
  npx openzoo demo
90
90
  ```
91
91
 
92
- Builds a ~965k-token document with one planted fact, shows that buying direct refuses it (*"The input token count exceeds the maximum number of tokens allowed."* — recorded, see [benches.openzoo.fun](https://benches.openzoo.fun)), then fetches the live x402 quote for the same body the quote is free:
92
+ Builds a ~965k-token document with one planted fact, shows that buying direct refuses it (*"The input token count exceeds the maximum number of tokens allowed."* — recorded, see [benches.openzoo.fun](https://benches.openzoo.fun)), binds the body ONCE to the zoo's holographic memory, then pays for a question that ships alone:
93
93
 
94
94
  ```
95
- quote: $0.009747 · pricing=counterfactual · direct would be $0.097474 · savesVsDirect=10.0×
95
+ bound once in 14.8s: 3.7MB context ctx_01KZZY8YQE…
96
+ quote for the ask: $0.000480 · pricing=markup (3× a tiny body — the 3.7MB corpus is not re-priced)
96
97
  ```
97
98
 
98
- If the wallet is funded with USDC 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 multi-MB upload, pricing, payment, the answer) show a live progress line with stage + elapsed seconds.
99
+ If the wallet is funded with USDC 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.
100
+
101
+ **Run it twice.** The second run finds the corpus in the local manifest and never uploads it:
102
+
103
+ ```
104
+ ★ corpus already bound (context ctx_01KZZY8YQE…) — skipped the 3.7MB upload entirely.
105
+ ...
106
+ ★ corpus already bound — skipped 3.7MB upload; whole run took 4.3s and cost $0.000480
107
+ ```
108
+
109
+ ## Repeat calls are near-free (0.3.0: the body never ships twice)
110
+
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
+
113
+ - **nothing big is uploaded** — the ask ships alone with an `X-HRR-Context` header,
114
+ - **the 402 prices the tiny ask** (markup basis, honestly labeled `pricing=markup`), typically a few hundredths of a cent instead of re-pricing megabytes,
115
+ - **the answer still comes from your corpus** — the zoo recalls the relevant slices server-side.
116
+
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.
118
+
119
+ Manage it:
120
+
121
+ ```bash
122
+ npx openzoo contexts # list bound corpora (hash, context id, age, api base)
123
+ npx openzoo contexts --forget 60464d # drop one by hash prefix
124
+ npx openzoo contexts --forget all # drop everything
125
+ ```
126
+
127
+ Opt out with `OPENZOO_NO_CONTEXT_CACHE=1` (always ship the full body); tune the threshold with `OPENZOO_CONTEXT_MIN_CHARS` (default 16384 chars).
99
128
 
100
129
  ## The wallet model
101
130
 
@@ -110,6 +139,7 @@ If the wallet is funded with USDC it pays (capped at `OPENZOO_DEMO_MAX_USD`, def
110
139
  Two bases, reported per call in the 402 (`extra.pricing`):
111
140
  - **Short prompts price at a markup** (3× provider cost) — there's nothing to spill, you're paying for passthrough.
112
141
  - **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).
142
+ - **Asks against a bound corpus price on the markup basis** (3× a tiny body — the receipt says `pricing=markup`). That is not a discount trick: 3× of a few hundred tokens is normally far below even the counterfactual price of shipping the corpus, which is the whole point of binding once.
113
143
 
114
144
  The receipt names which base you got; `extra.directUsd` / `extra.savesVsDirect` let you check the math.
115
145
 
@@ -134,6 +164,8 @@ The rail is chosen from the 402's `accepts[]` itself (Solana first). Amounts are
134
164
  | `OPENZOO_WALLET` | `~/.openzoo/wallet.json` | wallet path |
135
165
  | `OPENZOO_MAX_USD_PER_CALL` | `0.5` | refuse quotes above this |
136
166
  | `OPENZOO_DEMO_MAX_USD` | `0.01` | demo spend cap |
167
+ | `OPENZOO_CONTEXT_MIN_CHARS` | `16384` | bodies bigger than this bind once + reuse |
168
+ | `OPENZOO_NO_CONTEXT_CACHE` | `0` | set `1` to always ship the full body |
137
169
  | `OPENZOO_ENABLE_RH` | `0` | allow the Robinhood rail |
138
170
 
139
171
  ## What's tested
package/bin/openzoo.js CHANGED
@@ -7,6 +7,9 @@ usage:
7
7
  npx openzoo start the proxy on http://localhost:8402/v1
8
8
  npx openzoo mcp stdio MCP server (tools: zoo_ask, zoo_models, zoo_wallet)
9
9
  npx openzoo demo ~1M-token needle demo: direct refuses, the zoo answers
10
+ (run it twice — the second run reuses the bound corpus and is near-free)
11
+ npx openzoo contexts list corpora bound to the zoo (never re-uploaded)
12
+ npx openzoo contexts --forget <hash|all> drop manifest entries
10
13
  npx openzoo balance wallet balance (USDC + SOL, with USD value)
11
14
  npx openzoo address print the funding address
12
15
  npx openzoo help this text
@@ -19,6 +22,8 @@ env:
19
22
  OPENZOO_PORT (8402) OPENZOO_API_BASE (https://x402-tokens.fly.dev)
20
23
  OPENZOO_RPC (mainnet-beta) OPENZOO_TOKEN (402 rail preference) OPENZOO_WALLET (~/.openzoo/wallet.json)
21
24
  OPENZOO_MAX_USD_PER_CALL (0.5) OPENZOO_DEMO_MAX_USD (0.01)
25
+ OPENZOO_CONTEXT_MIN_CHARS (16384 — bodies bigger than this bind once + reuse)
26
+ OPENZOO_NO_CONTEXT_CACHE (0 — set 1 to always ship the full body)
22
27
  OPENZOO_ENABLE_RH (0 — Robinhood Chain rail, experimental)`;
23
28
 
24
29
  async function main() {
@@ -33,6 +38,30 @@ async function main() {
33
38
  case 'demo':
34
39
  await (await import('../lib/demo.js')).runDemo();
35
40
  break;
41
+ case 'contexts': {
42
+ const { listContexts, forgetContexts } = await import('../lib/contexts.js');
43
+ const fi = process.argv.indexOf('--forget');
44
+ if (fi !== -1) {
45
+ const sel = process.argv[fi + 1];
46
+ if (!sel) throw new Error('usage: openzoo contexts --forget <hash-prefix|all>');
47
+ console.log(`forgot ${forgetContexts(sel)} context(s)`);
48
+ break;
49
+ }
50
+ const all = listContexts();
51
+ if (!all.length) {
52
+ console.log('no bound corpora yet — run `npx openzoo demo` or ask with a big body.');
53
+ break;
54
+ }
55
+ const age = (iso) => {
56
+ const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000);
57
+ return s < 3600 ? `${Math.round(s / 60)}m` : s < 86400 ? `${(s / 3600).toFixed(1)}h` : `${(s / 86400).toFixed(1)}d`;
58
+ };
59
+ for (const c of all) {
60
+ console.log(`${c.hash.slice(0, 12)} ${c.context_id} bound ${age(c.boundAt)} ago ${c.apiBase}`);
61
+ }
62
+ console.log('\nthese corpora never ship again — asks against them send only the question + X-HRR-Context.');
63
+ break;
64
+ }
36
65
  case 'balance':
37
66
  await (await import('../lib/info.js')).printBalance();
38
67
  break;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Context manifest — "the body never ships twice."
3
+ *
4
+ * ~/.openzoo/contexts.json (chmod 600) maps sha256(normalized corpus) →
5
+ * { context_id, boundAt } per API base. A hit means the corpus is already
6
+ * bound on the zoo's holographic sidecar, so an ask ships only the question
7
+ * plus an X-HRR-Context header instead of re-uploading megabytes.
8
+ *
9
+ * Scoped per API base: the same corpus bound on a different zoo is a
10
+ * different context id, and pointing OPENZOO_API_BASE elsewhere must never
11
+ * replay ids the new gateway has never seen.
12
+ */
13
+ import { createHash } from 'node:crypto';
14
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync } from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+
18
+ const FILE = path.join(os.homedir(), '.openzoo', 'contexts.json');
19
+
20
+ /** sha256 of the normalized corpus text. Normalization (CRLF→LF + trim) keeps
21
+ * the hash stable across OSes and trailing-whitespace edits — the corpus the
22
+ * sidecar chunks is semantically identical either way. */
23
+ export function corpusHash(text) {
24
+ return createHash('sha256').update(String(text).replace(/\r\n/g, '\n').trim()).digest('hex');
25
+ }
26
+
27
+ function load() {
28
+ try {
29
+ const j = JSON.parse(readFileSync(FILE, 'utf8'));
30
+ return j && typeof j === 'object' ? j : {};
31
+ } catch {
32
+ return {};
33
+ }
34
+ }
35
+
36
+ function save(map) {
37
+ mkdirSync(path.dirname(FILE), { recursive: true });
38
+ // write-then-rename so a crash mid-write never truncates the manifest
39
+ const tmp = FILE + '.tmp';
40
+ writeFileSync(tmp, JSON.stringify(map, null, 2));
41
+ chmodSync(tmp, 0o600);
42
+ renameSync(tmp, FILE);
43
+ }
44
+
45
+ /** manifest entry for (apiBase, hash) or null. */
46
+ export function lookupContext(apiBase, hash) {
47
+ const map = load();
48
+ return map[apiBase]?.[hash] ?? null;
49
+ }
50
+
51
+ export function rememberContext(apiBase, hash, contextId) {
52
+ const map = load();
53
+ map[apiBase] = map[apiBase] || {};
54
+ map[apiBase][hash] = { context_id: contextId, boundAt: new Date().toISOString() };
55
+ save(map);
56
+ }
57
+
58
+ export function forgetContext(apiBase, hash) {
59
+ const map = load();
60
+ if (map[apiBase]?.[hash]) {
61
+ delete map[apiBase][hash];
62
+ if (!Object.keys(map[apiBase]).length) delete map[apiBase];
63
+ save(map);
64
+ return 1;
65
+ }
66
+ return 0;
67
+ }
68
+
69
+ /** Flat list of every entry: { apiBase, hash, context_id, boundAt }. */
70
+ export function listContexts() {
71
+ const map = load();
72
+ const out = [];
73
+ for (const [apiBase, entries] of Object.entries(map)) {
74
+ for (const [hash, e] of Object.entries(entries)) {
75
+ out.push({ apiBase, hash, context_id: e.context_id, boundAt: e.boundAt });
76
+ }
77
+ }
78
+ out.sort((a, b) => String(b.boundAt).localeCompare(String(a.boundAt)));
79
+ return out;
80
+ }
81
+
82
+ /** Forget by hash prefix (any api base) or 'all'. Returns removed count. */
83
+ export function forgetContexts(selector) {
84
+ const map = load();
85
+ let removed = 0;
86
+ if (selector === 'all') {
87
+ for (const entries of Object.values(map)) removed += Object.keys(entries).length;
88
+ save({});
89
+ return removed;
90
+ }
91
+ for (const [apiBase, entries] of Object.entries(map)) {
92
+ for (const hash of Object.keys(entries)) {
93
+ if (hash.startsWith(selector)) {
94
+ delete entries[hash];
95
+ removed += 1;
96
+ }
97
+ }
98
+ if (!Object.keys(entries).length) delete map[apiBase];
99
+ }
100
+ if (removed) save(map);
101
+ return removed;
102
+ }
package/lib/demo.js CHANGED
@@ -3,6 +3,8 @@ import { PayClient, UnderfundedError } from './pay.js';
3
3
  import { parse402, pickAccept, tokenBalance } from './x402.js';
4
4
  import { startSpinner, postWithUploadSignal } from './spinner.js';
5
5
  import { resolvePool, poolState, depositForShares } from './wrap.js';
6
+ import { bindCorpus, askWithContext } from './hrr.js';
7
+ import { forgetContext } from './contexts.js';
6
8
 
7
9
  const DEMO_MODEL = process.env.OPENZOO_DEMO_MODEL || 'nvidia/nemotron-3.5-lightning';
8
10
  const NEEDLE = 'The zebra vault code is 7-ALPHA-9243.';
@@ -29,14 +31,16 @@ function bigBody(targetTokens) {
29
31
  return parts.join('\n\n');
30
32
  }
31
33
 
32
- async function quoteFor(bodyObj) {
34
+ async function quoteFor(bodyObj, headers = {}) {
33
35
  const body = JSON.stringify(bodyObj);
34
36
  const mb = (body.length / 1048576).toFixed(1);
35
37
  const approxTokens = Math.round(body.length / 4 / 1000);
36
- const spin = startSpinner(`uploading ${mb}MB body`);
38
+ const big = body.length > 262144;
39
+ const spin = startSpinner(big ? `uploading ${mb}MB body` : 'asking the zoo for a live quote');
37
40
  try {
38
41
  const r = await postWithUploadSignal(`${config.apiBase}/v1/chat/completions`, body, {
39
- onUploaded: () => spin.update(`zoo pricing ~${approxTokens}k tokens`),
42
+ headers,
43
+ onUploaded: () => { if (big) spin.update(`zoo pricing ~${approxTokens}k tokens`); },
40
44
  });
41
45
  if (r.status !== 402) throw new Error(`expected 402 quote, got HTTP ${r.status}: ${(await r.text()).slice(0, 300)}`);
42
46
  return parse402(await r.json());
@@ -71,66 +75,93 @@ export async function runDemo() {
71
75
  console.log(' (recorded against the same model class, 2026-08-13 — benches.openzoo.fun)');
72
76
  console.log('');
73
77
 
74
- console.log('2) same body through the zoo — asking for the live x402 quote (the quote is free)...');
78
+ console.log('2) same body through the zoo — but the body never ships twice (0.3.0):');
75
79
  const doc = bigBody(tokens);
76
- const bodyObj = {
80
+ const docMb = (Buffer.byteLength(doc) / 1048576).toFixed(1);
81
+ const askBody = {
77
82
  model: DEMO_MODEL,
78
- messages: [{ role: 'user', content: `${doc}\n\n${QUESTION}` }],
83
+ messages: [{ role: 'user', content: QUESTION }],
79
84
  // Reasoning models burn budget on thinking before visible output — 32 was
80
85
  // returning empty content with finish_reason=length on nemotron.
81
86
  max_tokens: 512,
82
87
  temperature: 0,
83
88
  };
84
- const quote = await quoteFor(bodyObj);
89
+
90
+ const tRun = Date.now();
91
+ let bindSpin = null;
92
+ const bindStages = {
93
+ onStage: (stage, info) => {
94
+ if (stage === 'binding') bindSpin = startSpinner(`uploading + binding ${docMb}MB corpus (one-time)`);
95
+ if (stage === 'bound-uploading-done') bindSpin?.update('zoo chunking + indexing the corpus');
96
+ },
97
+ };
98
+ let bind = await bindCorpus(doc, bindStages);
99
+ bindSpin?.stop();
100
+ if (bind.reused) {
101
+ console.log(` ★ corpus already bound (context ${bind.contextId}) — skipped the ${docMb}MB upload entirely.`);
102
+ } else {
103
+ console.log(` bound once in ${((Date.now() - tRun) / 1000).toFixed(1)}s: ${docMb}MB → context ${bind.contextId}.`);
104
+ console.log(' re-run `npx openzoo demo` — this upload never happens again.');
105
+ }
106
+
107
+ let quote;
108
+ try {
109
+ quote = await quoteFor(askBody, { 'X-HRR-Context': bind.contextId });
110
+ } catch (err) {
111
+ // Sidecar wiped since we bound (stale manifest): 404 pre-402, nothing
112
+ // paid. Never fail on it — re-bind once and re-quote.
113
+ if (!/context_not_found/.test(err.message)) throw err;
114
+ console.log(' bound context is gone on the zoo — re-binding once...');
115
+ forgetContext(config.apiBase, bind.hash);
116
+ bind = await bindCorpus(doc, { ...bindStages, force: true });
117
+ bindSpin?.stop();
118
+ console.log(` re-bound: ${docMb}MB → context ${bind.contextId}.`);
119
+ quote = await quoteFor(askBody, { 'X-HRR-Context': bind.contextId });
120
+ }
85
121
  const accept = pickAccept(quote, config.token);
86
122
  const x = accept.extra || {};
87
- console.log(` quote: $${Number(x.billedUsd).toFixed(6)} · pricing=${x.pricing}`
88
- + (x.directUsd != null ? ` · direct would be $${Number(x.directUsd).toFixed(6)} · savesVsDirect=${Number(x.savesVsDirect).toFixed(1)}×` : ''));
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)`);
89
124
  console.log('');
90
125
 
91
- // Decide what we can actually pay for under the demo cap.
92
- let payBody = bodyObj;
93
- let payAccept = accept;
94
126
  if (Number(x.billedUsd) > config.demoMaxUsd) {
95
- const fitTokens = Math.max(20000, Math.floor(tokens * (config.demoMaxUsd / Number(x.billedUsd)) * 0.8));
96
- console.log(` (full-size quote is over the demo cap $${config.demoMaxUsd} — paying for a ${Math.round(fitTokens / 1000)}k-token slice instead;`);
97
- console.log(' set OPENZOO_DEMO_MAX_USD higher to pay for the whole thing)');
98
- const doc2 = bigBody(fitTokens);
99
- payBody = { ...bodyObj, messages: [{ role: 'user', content: `${doc2}\n\n${QUESTION}` }] };
100
- const q2 = await quoteFor(payBody);
101
- payAccept = pickAccept(q2, config.token);
127
+ console.log(` quote is over the demo cap $${config.demoMaxUsd} set OPENZOO_DEMO_MAX_USD higher to pay it.`);
128
+ return;
102
129
  }
103
130
 
104
- const cov = await coverage(connection, keypair.publicKey, payAccept);
131
+ const cov = await coverage(connection, keypair.publicKey, accept);
105
132
  if (!cov.covered) {
106
133
  console.log('3) wallet not funded yet, so stopping before payment.');
107
- console.log(` quote : ≈ $${Number(payAccept.extra?.billedUsd).toFixed(6)}`);
134
+ console.log(` quote : ≈ $${Number(x.billedUsd).toFixed(6)}`);
108
135
  console.log(` wallet: $${(cov.usdcUi ?? 0).toFixed(2)} USDC`);
109
136
  console.log(` fund : send a few cents of USDC to ${pub}`);
110
137
  console.log(' then re-run `npx openzoo demo`.');
111
138
  return;
112
139
  }
113
140
 
114
- console.log('3) paying and asking...');
115
- const paidMb = (JSON.stringify(payBody).length / 1048576).toFixed(1);
116
- const spin = startSpinner(`re-sending ${paidMb}MB body for the paid call`);
141
+ console.log('3) paying and asking (the question rides alone — plus one header)...');
142
+ const spin = startSpinner('sending the ask with X-HRR-Context');
117
143
  const t0 = Date.now();
118
- let data; let receipt;
144
+ let out;
119
145
  try {
120
- ({ data, receipt } = await client.chat(payBody, {
146
+ out = await askWithContext(client, doc, askBody, {
121
147
  onStage: (stage) => {
148
+ if (stage === 'reused') spin.update('reusing bound corpus');
149
+ if (stage === 'rebinding') spin.update('context was wiped upstream — re-binding once');
150
+ if (stage === 'binding') spin.update(`re-uploading ${docMb}MB corpus`);
122
151
  if (stage === 'quoted') spin.update('building the payment');
123
152
  if (stage === 'funding') spin.update('preparing funds (one-time)');
124
153
  if (stage === 'paying') spin.update('paying + waiting for the answer');
125
154
  },
126
- }));
155
+ });
127
156
  } catch (err) {
128
157
  spin.stop();
129
158
  if (err instanceof UnderfundedError) { console.log(` ${err.message}`); return; }
130
159
  throw err;
131
160
  }
132
161
  spin.stop();
162
+ const { data, receipt } = out;
133
163
  const secs = ((Date.now() - t0) / 1000).toFixed(1);
164
+ const totalSecs = ((Date.now() - tRun) / 1000).toFixed(1);
134
165
  const answer = data?.choices?.[0]?.message?.content ?? '';
135
166
  const usage = data?.usage || {};
136
167
  console.log(` answer (${secs}s): ${answer.trim()}`);
@@ -139,4 +170,10 @@ export async function runDemo() {
139
170
  console.log(` tokens the model actually read: ${usage.gpu_tokens ?? usage.prompt_tokens}${usage.spill_tokens ? ` (spilled: ${usage.spill_tokens})` : ''}`);
140
171
  }
141
172
  if (receipt) console.log(` ${receipt.line}`);
173
+ if (bind.reused) {
174
+ const paidUsd = Number(receipt?.billedUsd);
175
+ console.log('');
176
+ console.log(` ★ corpus already bound — skipped ${docMb}MB upload; whole run took ${totalSecs}s`
177
+ + (Number.isFinite(paidUsd) ? ` and cost $${paidUsd.toFixed(6)}` : ''));
178
+ }
142
179
  }
package/lib/hrr.js ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Client side of "the body never ships twice."
3
+ *
4
+ * bindCorpus: hash the corpus; on a manifest hit return the recorded
5
+ * context_id without touching the network. On a miss, POST the corpus ONCE to
6
+ * the zoo's free bind endpoint (the gateway chunks it with leCore server-side)
7
+ * and record the id.
8
+ *
9
+ * askWithContext: send the SMALL ask with X-HRR-Context. If the gateway says
10
+ * the context is gone (sidecar wiped — HTTP 404 code context_not_found,
11
+ * returned BEFORE the 402 so nothing was paid), forget the stale entry,
12
+ * re-bind once, and retry. A stale manifest must never fail a call.
13
+ */
14
+ import { config } from './config.js';
15
+ import { corpusHash, lookupContext, rememberContext, forgetContext } from './contexts.js';
16
+ import { postWithUploadSignal } from './spinner.js';
17
+
18
+ /** Corpora under this many chars ride inline — binding has a round-trip cost
19
+ * and the reuse win only matters when the body is actually big. */
20
+ export const BIND_MIN_CHARS = Number(process.env.OPENZOO_CONTEXT_MIN_CHARS || 16384);
21
+
22
+ export const contextCacheDisabled = () => process.env.OPENZOO_NO_CONTEXT_CACHE === '1';
23
+
24
+ /**
25
+ * Ensure `corpus` is bound on the zoo. Returns
26
+ * { contextId, hash, reused, bytes } — reused=true means zero bytes shipped.
27
+ */
28
+ export async function bindCorpus(corpus, { onStage, force = false } = {}) {
29
+ const hash = corpusHash(corpus);
30
+ const bytes = Buffer.byteLength(corpus);
31
+ if (!force) {
32
+ const hit = lookupContext(config.apiBase, hash);
33
+ if (hit) {
34
+ onStage?.('reused', { contextId: hit.context_id, boundAt: hit.boundAt, bytes });
35
+ return { contextId: hit.context_id, hash, reused: true, bytes };
36
+ }
37
+ }
38
+ onStage?.('binding', { bytes });
39
+ const r = await postWithUploadSignal(`${config.apiBase}/v1/hrr/bind`, JSON.stringify({ corpus }), {
40
+ onUploaded: () => onStage?.('bound-uploading-done', { bytes }),
41
+ });
42
+ if (r.status !== 200) {
43
+ throw new Error(`bind failed: HTTP ${r.status}: ${(await r.text()).slice(0, 300)}`);
44
+ }
45
+ const j = await r.json();
46
+ if (!j?.context_id) throw new Error('bind returned no context_id');
47
+ rememberContext(config.apiBase, hash, j.context_id);
48
+ return { contextId: j.context_id, hash, reused: false, bytes, bound: j.bound };
49
+ }
50
+
51
+ const isContextGone = (status, text) =>
52
+ status === 404 && /context_not_found/.test(text);
53
+
54
+ /**
55
+ * One paid ask against a bound corpus. bodyObj should already be SMALL (the
56
+ * question, not the corpus). Retries exactly once through a fresh bind when
57
+ * the context is gone. Returns { data, receipt, contextId, reused }.
58
+ */
59
+ export async function askWithContext(client, corpus, bodyObj, { onStage } = {}) {
60
+ let bind = await bindCorpus(corpus, { onStage });
61
+ for (let attempt = 0; ; attempt++) {
62
+ const { response, receipt } = await client.fetch(`${config.apiBase}/v1/chat/completions`, {
63
+ method: 'POST',
64
+ headers: { 'content-type': 'application/json', 'X-HRR-Context': bind.contextId },
65
+ body: JSON.stringify(bodyObj),
66
+ }, { onStage });
67
+ if (response.ok) {
68
+ return { data: await response.json(), receipt, contextId: bind.contextId, reused: bind.reused };
69
+ }
70
+ const text = (await response.text()).slice(0, 500);
71
+ if (attempt === 0 && isContextGone(response.status, text)) {
72
+ onStage?.('rebinding', { contextId: bind.contextId });
73
+ forgetContext(config.apiBase, bind.hash);
74
+ bind = await bindCorpus(corpus, { onStage, force: true });
75
+ continue;
76
+ }
77
+ throw new Error(`zoo returned HTTP ${response.status}: ${text}`);
78
+ }
79
+ }
package/lib/mcp.js CHANGED
@@ -5,6 +5,8 @@ import { z } from 'zod';
5
5
  import { config } from './config.js';
6
6
  import { PayClient, QuoteTooHighError, UnderfundedError } from './pay.js';
7
7
  import { tokenBalance } from './x402.js';
8
+ import { askWithContext, contextCacheDisabled, BIND_MIN_CHARS } from './hrr.js';
9
+ import { listContexts } from './contexts.js';
8
10
 
9
11
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
10
12
  const USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
@@ -33,13 +35,26 @@ export async function startMcp() {
33
35
  max_tokens: z.number().int().positive().optional().describe('Completion cap. Default 1024.'),
34
36
  },
35
37
  }, async ({ prompt, corpus, model, max_tokens }) => {
36
- const content = corpus ? `${corpus}\n\n${prompt}` : prompt;
37
38
  try {
38
- const { data, receipt } = await client.chat({
39
- model: model || DEFAULT_MODEL,
40
- messages: [{ role: 'user', content }],
41
- max_tokens: max_tokens || 1024,
42
- });
39
+ const bodyBase = { model: model || DEFAULT_MODEL, max_tokens: max_tokens || 1024 };
40
+ let data; let receipt; let reuse = null;
41
+ // "The body never ships twice": a big corpus is bound ONCE on the zoo
42
+ // (sha256 manifest at ~/.openzoo/contexts.json) and later asks ship only
43
+ // the prompt + an X-HRR-Context header. Small corpora ride inline.
44
+ if (corpus && corpus.length > BIND_MIN_CHARS && !contextCacheDisabled()) {
45
+ const out = await askWithContext(client, corpus, {
46
+ ...bodyBase,
47
+ messages: [{ role: 'user', content: prompt }],
48
+ });
49
+ ({ data, receipt } = out);
50
+ reuse = { contextId: out.contextId, corpusReused: out.reused };
51
+ } else {
52
+ const content = corpus ? `${corpus}\n\n${prompt}` : prompt;
53
+ ({ data, receipt } = await client.chat({
54
+ ...bodyBase,
55
+ messages: [{ role: 'user', content }],
56
+ }));
57
+ }
43
58
  const usage = data?.usage || {};
44
59
  return text({
45
60
  answer: data?.choices?.[0]?.message?.content ?? '',
@@ -52,6 +67,7 @@ export async function startMcp() {
52
67
  } : 'free (no 402 issued)',
53
68
  tokensRead: usage.gpu_tokens ?? usage.prompt_tokens ?? null,
54
69
  spillTokens: usage.spill_tokens ?? null,
70
+ ...(reuse ? { context: reuse } : {}),
55
71
  });
56
72
  } catch (err) {
57
73
  if (err instanceof UnderfundedError || err instanceof QuoteTooHighError) {
@@ -85,6 +101,16 @@ export async function startMcp() {
85
101
  });
86
102
  });
87
103
 
104
+ server.registerTool('zoo_contexts', {
105
+ description: 'List corpora already bound to the zoo\'s holographic memory (local manifest). A listed corpus is never re-uploaded — asks against it are near-free.',
106
+ inputSchema: {},
107
+ }, async () => text({
108
+ manifest: '~/.openzoo/contexts.json',
109
+ contexts: listContexts().map((c) => ({
110
+ hash: c.hash.slice(0, 12), context_id: c.context_id, boundAt: c.boundAt, apiBase: c.apiBase,
111
+ })),
112
+ }));
113
+
88
114
  const transport = new StdioServerTransport();
89
115
  await server.connect(transport);
90
116
  console.error(`openzoo mcp on stdio — wallet ${client.address} — zoo ${config.apiBase}`);
package/lib/pay.js CHANGED
@@ -169,10 +169,10 @@ export class PayClient {
169
169
  }
170
170
 
171
171
  /** POST a chat completion, paying as needed. Returns { data, receipt }. */
172
- async chat(bodyObj, { onStage } = {}) {
172
+ async chat(bodyObj, { onStage, headers } = {}) {
173
173
  const { response, receipt } = await this.fetch(`${config.apiBase}/v1/chat/completions`, {
174
174
  method: 'POST',
175
- headers: { 'content-type': 'application/json' },
175
+ headers: { 'content-type': 'application/json', ...(headers || {}) },
176
176
  body: JSON.stringify(bodyObj),
177
177
  }, { onStage });
178
178
  if (!response.ok) {
package/lib/proxy.js CHANGED
@@ -3,6 +3,8 @@ import { Readable } from 'node:stream';
3
3
  import { config } from './config.js';
4
4
  import { PayClient, QuoteTooHighError, UnderfundedError } from './pay.js';
5
5
  import { tokenBalance } from './x402.js';
6
+ import { bindCorpus, contextCacheDisabled, BIND_MIN_CHARS } from './hrr.js';
7
+ import { forgetContext } from './contexts.js';
6
8
 
7
9
  const HOP_BY_HOP = new Set([
8
10
  'host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade',
@@ -48,6 +50,58 @@ function jsonErr(res, status, message, extraFields = {}) {
48
50
  res.end(JSON.stringify({ error: { message }, ...extraFields }));
49
51
  }
50
52
 
53
+ const mb = (n) => (n / 1048576).toFixed(1);
54
+
55
+ /**
56
+ * "The body never ships twice" at the proxy. A chat body whose LAST message
57
+ * carries a huge pasted corpus gets split at its last blank line — corpus vs
58
+ * ask — so the corpus can be bound ONCE on the zoo and every later call ships
59
+ * only the ask plus X-HRR-Context. The split point is deterministic, which is
60
+ * what makes the sha256 manifest hit on run 2 even when the question changed.
61
+ *
62
+ * Conservative on purpose: only a single big STRING content on the final
63
+ * message, only when a blank-line boundary exists, and any failure falls back
64
+ * to sending the original body untouched — caching must never break a call.
65
+ * Returns null (send as-is) or { body, contextId, hash, corpus, reused, savedBytes }.
66
+ */
67
+ async function maybeCacheCorpus(req, bodyBuf, log) {
68
+ if (contextCacheDisabled()) return null;
69
+ if (req.method !== 'POST' || !(req.url || '').includes('/chat/completions')) return null;
70
+ if (req.headers['x-hrr-context']) return null; // harness manages its own context
71
+ if (bodyBuf.length <= BIND_MIN_CHARS) return null;
72
+ let body;
73
+ try { body = JSON.parse(bodyBuf.toString('utf8')); } catch { return null; }
74
+ const msgs = Array.isArray(body?.messages) ? body.messages : null;
75
+ if (!msgs?.length) return null;
76
+ const last = msgs[msgs.length - 1];
77
+ if (typeof last?.content !== 'string' || last.content.length <= BIND_MIN_CHARS) return null;
78
+ const cut = last.content.lastIndexOf('\n\n');
79
+ if (cut < BIND_MIN_CHARS) return null;
80
+ const corpus = last.content.slice(0, cut);
81
+ const ask = last.content.slice(cut + 2).trim();
82
+ if (!ask || ask.length > 8000) return null;
83
+
84
+ const bind = await bindCorpus(corpus, {
85
+ onStage: (stage, info) => {
86
+ if (stage === 'binding') log(`binding ${mb(info.bytes)}MB corpus to holographic memory (one-time)...`);
87
+ },
88
+ });
89
+ if (bind.reused) {
90
+ log(`corpus already bound (${bind.hash.slice(0, 12)}… → ${bind.contextId}) — skipped ${mb(bind.bytes)}MB upload`);
91
+ } else {
92
+ log(`corpus bound once (${mb(bind.bytes)}MB → ${bind.contextId}) — repeats of this body are near-free`);
93
+ }
94
+ const rewritten = { ...body, messages: [...msgs.slice(0, -1), { ...last, content: ask }] };
95
+ return {
96
+ body: Buffer.from(JSON.stringify(rewritten)),
97
+ contextId: bind.contextId,
98
+ hash: bind.hash,
99
+ corpus,
100
+ reused: bind.reused,
101
+ savedBytes: bind.bytes,
102
+ };
103
+ }
104
+
51
105
  export async function startProxy({ silent = false } = {}) {
52
106
  const client = new PayClient();
53
107
  const log = silent ? () => {} : (...a) => console.log(...a);
@@ -65,7 +119,39 @@ export async function startProxy({ silent = false } = {}) {
65
119
  if (req.method !== 'GET' && req.method !== 'HEAD') init.body = bodyBuf;
66
120
 
67
121
  try {
68
- const { response, paid, receipt } = await client.fetch(url, init);
122
+ let cached = null;
123
+ try {
124
+ cached = await maybeCacheCorpus(req, bodyBuf, log);
125
+ } catch (err) {
126
+ log(`context cache skipped for this call: ${err.message}`);
127
+ }
128
+ const send = (buf, ctxId) => client.fetch(url, {
129
+ ...init,
130
+ body: buf,
131
+ headers: ctxId ? { ...init.headers, 'x-hrr-context': ctxId } : init.headers,
132
+ });
133
+ let result;
134
+ if (cached) {
135
+ result = await send(cached.body, cached.contextId);
136
+ // Sidecar wiped between runs: the gateway 404s BEFORE the 402 (nothing
137
+ // paid). Never fail on a stale manifest — re-bind once and retry.
138
+ if (result.response.status === 404) {
139
+ const text = await result.response.text();
140
+ if (/context_not_found/.test(text)) {
141
+ log('bound context is gone on the zoo — re-binding once...');
142
+ forgetContext(config.apiBase, cached.hash);
143
+ const rebound = await bindCorpus(cached.corpus, { force: true });
144
+ result = await send(cached.body, rebound.contextId);
145
+ } else {
146
+ res.writeHead(404, { 'content-type': 'application/json' });
147
+ res.end(text);
148
+ return;
149
+ }
150
+ }
151
+ } else {
152
+ result = await client.fetch(url, init);
153
+ }
154
+ const { response, paid, receipt } = result;
69
155
  if (paid && receipt) log(receipt.ok ? receipt.line : `paid retry -> HTTP ${receipt.status}`);
70
156
  await relay(res, response);
71
157
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Local x402-paying proxy + MCP server for openzoo.fun — point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana rail live; Base/Robinhood rails experimental.",
5
5
  "license": "MIT",
6
6
  "type": "module",