openzoo 0.11.1 → 0.12.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/lib/bindpath.js CHANGED
@@ -23,6 +23,7 @@ import fs from 'node:fs';
23
23
  import path from 'node:path';
24
24
  import { config } from './config.js';
25
25
  import { corpusHash, rememberContext, lookupContext } from './contexts.js';
26
+ import { withNamespace } from './namespace.js';
26
27
 
27
28
  /** Bodies over this go in their own part. Under the ~8MB transport ceiling
28
29
  * with room for JSON escaping, which can inflate text substantially. */
@@ -38,6 +39,35 @@ const DEFAULT_EXTS = [
38
39
 
39
40
  const SKIP_DIRS = new Set(['node_modules', '.git', 'target', 'dist', 'build', '.next', '__pycache__', '.venv']);
40
41
 
42
+ /** Refuse rather than grind. A directory of exports can be hundreds of MB;
43
+ * binding that takes many minutes and mostly buys markup. Raise deliberately. */
44
+ export const MAX_TOTAL_BYTES = Number(process.env.OPENZOO_BIND_MAX_BYTES || 32 * 1024 * 1024);
45
+
46
+ /**
47
+ * HTML → readable text. Chat/forum exports are ~90% markup: binding the raw
48
+ * pages wastes the corpus on tags and makes retrieval rank div soup against
49
+ * the question. Deliberately crude (no parser dependency) but it keeps the
50
+ * text nodes in document order, which is all retrieval needs.
51
+ */
52
+ export function stripHtml(html) {
53
+ return html
54
+ .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ')
55
+ .replace(/<!--[\s\S]*?-->/g, ' ')
56
+ .replace(/<br\s*\/?>/gi, '\n')
57
+ .replace(/<\/(p|div|li|tr|h[1-6])>/gi, '\n')
58
+ .replace(/<[^>]+>/g, ' ')
59
+ .replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<')
60
+ .replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'")
61
+ .replace(/[ \t]+/g, ' ')
62
+ .replace(/\n\s*\n\s*\n+/g, '\n\n')
63
+ .trim();
64
+ }
65
+
66
+ const readAsText = (file) => {
67
+ const raw = fs.readFileSync(file, 'utf8');
68
+ return /\.html?$/i.test(file) ? stripHtml(raw) : raw;
69
+ };
70
+
41
71
  /** Every bindable file under `root` (or just `root` if it is a file). */
42
72
  export function collectFiles(root, { exts = DEFAULT_EXTS, maxFiles = 5000 } = {}) {
43
73
  const st = fs.statSync(root);
@@ -86,7 +116,7 @@ export function splitIntoParts(text, maxBytes = MAX_PART_BYTES) {
86
116
  async function postBind(payload) {
87
117
  const r = await fetch(`${config.apiBase}/v1/hrr/bind`, {
88
118
  method: 'POST',
89
- headers: { 'content-type': 'application/json' },
119
+ headers: withNamespace({ 'content-type': 'application/json' }),
90
120
  body: JSON.stringify(payload),
91
121
  });
92
122
  if (r.status !== 200) throw new Error(`bind failed: HTTP ${r.status}: ${(await r.text()).slice(0, 300)}`);
@@ -109,11 +139,27 @@ export async function bindPath(target, { exts, onProgress, force = false } = {})
109
139
  const files = collectFiles(resolved, exts ? { exts } : {});
110
140
  if (!files.length) throw new Error(`nothing bindable under ${resolved} (looked for text-like files)`);
111
141
 
142
+ // Size the job BEFORE reading anything. A 2GB export folder is 100MB of
143
+ // text-like files, and silently grinding through it for minutes reads as a
144
+ // hang — an actionable refusal is better than a spinner.
145
+ const onDisk = files.reduce((n, f) => n + fs.statSync(f).size, 0);
146
+ if (onDisk > MAX_TOTAL_BYTES) {
147
+ const biggest = files
148
+ .map((f) => ({ f, size: fs.statSync(f).size }))
149
+ .sort((a, b) => b.size - a.size).slice(0, 3)
150
+ .map(({ f, size }) => `${path.basename(f)} (${(size / 1048576).toFixed(1)}MB)`);
151
+ throw new Error(
152
+ `${(onDisk / 1048576).toFixed(0)}MB across ${files.length} files exceeds the ${(MAX_TOTAL_BYTES / 1048576).toFixed(0)}MB bind limit. `
153
+ + `Largest: ${biggest.join(', ')}. Point at one file, narrow with --ext/ext, `
154
+ + `or raise OPENZOO_BIND_MAX_BYTES if you really want all of it.`,
155
+ );
156
+ }
157
+
112
158
  // Each file is prefixed with its path so retrieval can cite where a passage
113
159
  // came from — a corpus of concatenated files with no provenance is much
114
- // less useful to answer from.
160
+ // less useful to answer from. HTML is reduced to its text (see readAsText).
115
161
  const text = files
116
- .map((f) => `===== ${path.relative(path.dirname(resolved), f) || path.basename(f)} =====\n${fs.readFileSync(f, 'utf8')}`)
162
+ .map((f) => `===== ${path.relative(path.dirname(resolved), f) || path.basename(f)} =====\n${readAsText(f)}`)
117
163
  .join('\n\n');
118
164
 
119
165
  const bytes = Buffer.byteLength(text);
package/lib/hrr.js CHANGED
@@ -14,6 +14,7 @@
14
14
  import { config } from './config.js';
15
15
  import { corpusHash, lookupContext, rememberContext, forgetContext } from './contexts.js';
16
16
  import { postWithUploadSignal } from './spinner.js';
17
+ import { withNamespace } from './namespace.js';
17
18
 
18
19
  /** Corpora under this many chars ride inline — binding has a round-trip cost
19
20
  * and the reuse win only matters when the body is actually big. */
@@ -37,6 +38,7 @@ export async function bindCorpus(corpus, { onStage, force = false } = {}) {
37
38
  }
38
39
  onStage?.('binding', { bytes });
39
40
  const r = await postWithUploadSignal(`${config.apiBase}/v1/hrr/bind`, JSON.stringify({ corpus }), {
41
+ headers: withNamespace(),
40
42
  onUploaded: () => onStage?.('bound-uploading-done', { bytes }),
41
43
  });
42
44
  if (r.status !== 200) {
package/lib/mcp.js CHANGED
@@ -16,7 +16,7 @@ function text(obj) {
16
16
  }
17
17
 
18
18
  /** `npx openzoo mcp` — stdio MCP server sharing the proxy's wallet + payment core. */
19
- export async function startMcp() {
19
+ export function buildMcpServer() {
20
20
  const client = new PayClient();
21
21
  // MCP's own channel for "what am I connected to" — clients surface this to
22
22
  // the model before any tool is called, which is exactly when it needs to
@@ -186,6 +186,12 @@ export async function startMcp() {
186
186
  })),
187
187
  }));
188
188
 
189
+ return { server, client };
190
+ }
191
+
192
+ /** `npx openzoo mcp` — stdio transport, for clients that spawn a process. */
193
+ export async function startMcp() {
194
+ const { server, client } = buildMcpServer();
189
195
  const transport = new StdioServerTransport();
190
196
  await server.connect(transport);
191
197
  console.error(`openzoo mcp on stdio — wallet ${client.address} — zoo ${config.apiBase}`);
package/lib/mcphttp.js ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * MCP over Streamable HTTP, mounted on the proxy's own port at /mcp.
3
+ *
4
+ * WHY: `npx openzoo` should give a harness BOTH surfaces without a second
5
+ * command — point base_url at /v1 for transparent context spilling, or add
6
+ * /mcp for the tools (zoo_bind, zoo_ask, zoo_wallet...). Clients that spawn a
7
+ * process still get stdio via `npx openzoo mcp`.
8
+ *
9
+ * Stateless on purpose: no session ids to track, no server-side state to
10
+ * expire, and any client that reconnects just works. The MCP server object
11
+ * itself is built once and reused across requests.
12
+ */
13
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
14
+ import { buildMcpServer } from './mcp.js';
15
+
16
+ let ready = null;
17
+
18
+ async function ensure() {
19
+ if (!ready) {
20
+ ready = (async () => {
21
+ const { server, client } = buildMcpServer();
22
+ const transport = new StreamableHTTPServerTransport({
23
+ sessionIdGenerator: undefined, // stateless
24
+ enableJsonResponse: true,
25
+ });
26
+ await server.connect(transport);
27
+ return { transport, client };
28
+ })();
29
+ }
30
+ return ready;
31
+ }
32
+
33
+ /** Read and JSON-parse a request body. MCP posts are small. */
34
+ function readJson(req) {
35
+ return new Promise((resolve) => {
36
+ const chunks = [];
37
+ req.on('data', (c) => chunks.push(c));
38
+ req.on('end', () => {
39
+ const raw = Buffer.concat(chunks).toString('utf8');
40
+ if (!raw) return resolve(undefined);
41
+ try { resolve(JSON.parse(raw)); } catch { resolve(undefined); }
42
+ });
43
+ req.on('error', () => resolve(undefined));
44
+ });
45
+ }
46
+
47
+ export async function handleMcpRequest(req, res) {
48
+ const { transport } = await ensure();
49
+ const body = req.method === 'POST' ? await readJson(req) : undefined;
50
+ await transport.handleRequest(req, res, body);
51
+ }
@@ -0,0 +1,37 @@
1
+ import crypto from 'node:crypto';
2
+ import { loadOrCreateWallet } from './wallet.js';
3
+
4
+ /**
5
+ * The namespace that isolates THIS wallet's bound corpora from everyone
6
+ * else's on the shared sidecar.
7
+ *
8
+ * Contexts used to live in one global tenant, so a context was protected only
9
+ * by its id being unguessable — and the bind endpoint is free and open, so
10
+ * anyone could write into that same space. The gateway now hashes this header
11
+ * into the sidecar tenant id, which means a leaked context id is useless to a
12
+ * different wallet.
13
+ *
14
+ * Derived from the PUBLIC key, never the secret: it must be stable across
15
+ * restarts and reveal nothing. It is hashed again server-side, so the value on
16
+ * the wire is not the address either.
17
+ */
18
+ let cached = null;
19
+
20
+ export function namespaceHeaderValue() {
21
+ if (cached) return cached;
22
+ try {
23
+ const w = loadOrCreateWallet();
24
+ cached = crypto.createHash('sha256')
25
+ .update(`openzoo-ns:${w.keypair.publicKey.toBase58()}`)
26
+ .digest('hex');
27
+ } catch {
28
+ cached = ''; // no wallet (read-only use): fall back to the shared tenant
29
+ }
30
+ return cached;
31
+ }
32
+
33
+ /** Merge the namespace header into any headers object. */
34
+ export function withNamespace(headers = {}) {
35
+ const ns = namespaceHeaderValue();
36
+ return ns ? { ...headers, 'x-openzoo-namespace': ns } : headers;
37
+ }
package/lib/pay.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  import { buildEvmPayment, evmTokenBalance } from './evm.js';
10
10
  import { acquireWrappedIfNeeded } from './evmwrap.js';
11
11
  import { privateKeyToAccount } from 'viem/accounts';
12
+ import { withNamespace } from './namespace.js';
12
13
  import {
13
14
  resolvePool, poolState, depositForShares, buildWrapInstructions, sendWrap,
14
15
  } from './wrap.js';
@@ -175,6 +176,9 @@ export class PayClient {
175
176
  */
176
177
  async fetch(url, init = {}, { onStage } = {}) {
177
178
  onStage?.('request');
179
+ // Contexts are tenanted by this namespace server-side — a request without
180
+ // it cannot see corpora this wallet bound.
181
+ init = { ...init, headers: withNamespace(init.headers || {}) };
178
182
  const first = await fetch(url, init);
179
183
  if (first.status !== 402) return { response: first, paid: false };
180
184
 
package/lib/proxy.js CHANGED
@@ -271,6 +271,20 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
271
271
  let tunnelGate = null;
272
272
 
273
273
  const server = http.createServer(async (req, res) => {
274
+ // MCP on the SAME port as the proxy. One `npx openzoo` gives a harness
275
+ // both surfaces: point base_url at /v1 for transparent context spilling,
276
+ // or add /mcp for tools (zoo_bind, zoo_ask...). Running two commands to
277
+ // get both was friction nobody should pay.
278
+ if ((req.url || '').split('?')[0] === '/mcp') {
279
+ try {
280
+ const { handleMcpRequest } = await import('./mcphttp.js');
281
+ await handleMcpRequest(req, res);
282
+ } catch (err) {
283
+ jsonErr(res, 500, `openzoo mcp error: ${err.message}`);
284
+ }
285
+ return;
286
+ }
287
+
274
288
  const normalized = normalizePath(req.url);
275
289
  if (normalized !== req.url) {
276
290
  log(`path ${req.url} -> ${normalized} (base_url already ends in /v1)`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.11.1",
3
+ "version": "0.12.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 and Base rails live; Robinhood experimental.",
5
5
  "license": "MIT",
6
6
  "type": "module",