openzoo 0.34.10 → 0.35.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.
@@ -27,6 +27,7 @@
27
27
  * + trailers for grpc-web. Both are handled below.
28
28
  */
29
29
  import http2 from 'node:http2';
30
+ import https from 'node:https';
30
31
  import fs from 'node:fs';
31
32
  import os from 'node:os';
32
33
  import path from 'node:path';
@@ -236,17 +237,36 @@ const HOP_BY_HOP = new Set([
236
237
  * over UDP (which ignores /etc/hosts entirely), cache it, and dial the address
237
238
  * with the Host/SNI still set to the real hostname so TLS and routing succeed.
238
239
  */
239
- let realIpCache = null;
240
+ // KEYED BY HOST. A single shared slot meant whichever of api/api-staging
241
+ // resolved first pinned its address for the other one too.
242
+ const realIpCache = new Map();
240
243
  async function resolveRealAnthropic(host) {
241
- if (realIpCache) return realIpCache;
244
+ const hit = realIpCache.get(host);
245
+ if (hit) return hit;
242
246
  const { Resolver } = await import('node:dns/promises');
243
247
  const r = new Resolver();
244
248
  r.setServers(['1.1.1.1', '8.8.8.8']);
245
249
  const [ip] = await r.resolve4(host);
246
- realIpCache = ip;
250
+ realIpCache.set(host, ip);
247
251
  return ip;
248
252
  }
249
253
 
254
+ /**
255
+ * Headers we must NOT copy back from the upstream response.
256
+ *
257
+ * DELIBERATELY NARROWER THAN HOP_BY_HOP: `content-encoding` is absent here on
258
+ * purpose. Unlike fetch(), node:https hands us the body EXACTLY as it arrived —
259
+ * still gzip/brotli compressed. Stripping the encoding header while forwarding
260
+ * compressed bytes tells the app "this is plain JSON" over binary garbage, and
261
+ * it fails to parse the bootstrap payload. Forward body and label together.
262
+ * `content-length` still goes, because we set it from the buffer we actually
263
+ * have, and the h2 layer rejects the connection-specific ones outright.
264
+ */
265
+ const RESP_DROP = new Set([
266
+ 'connection', 'keep-alive', 'upgrade', 'proxy-connection',
267
+ 'transfer-encoding', 'te', 'trailer', 'content-length',
268
+ ]);
269
+
250
270
  async function passthroughToRealAnthropic(req, res, body, host, full, log) {
251
271
  try {
252
272
  const ip = await resolveRealAnthropic(host);
@@ -256,18 +276,42 @@ async function passthroughToRealAnthropic(req, res, body, host, full, log) {
256
276
  headers[k] = Array.isArray(v) ? v.join(', ') : String(v);
257
277
  }
258
278
  headers.host = host;
259
- const up = await fetch(`https://${ip}${full}`, {
260
- method: req.method,
261
- headers,
262
- body: (req.method === 'GET' || req.method === 'HEAD') ? undefined : body,
263
- redirect: 'manual',
279
+
280
+ // DIAL THE ADDRESS, BUT SPEAK THE NAME. fetch(`https://${ip}/...`) cannot do
281
+ // this: undici derives SNI from the URL, so the server saw a bare IP, had no
282
+ // certificate to offer for it, and killed the handshake
283
+ // (ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE). node:https lets the two be set
284
+ // independently — a custom `lookup` pins the connection to the public-DNS
285
+ // address (bypassing our own /etc/hosts entry, which would otherwise loop
286
+ // this request straight back into this very server), while `servername` and
287
+ // the Host header keep presenting the real hostname so the cert validates.
288
+ const lookup = (h, o, cb) => (o && o.all ? cb(null, [{ address: ip, family: 4 }]) : cb(null, ip, 4));
289
+ const { status, respHeaders, buf } = await new Promise((resolve, reject) => {
290
+ const up = https.request({
291
+ hostname: host, servername: host, port: 443, path: full,
292
+ method: req.method, headers, lookup,
293
+ }, (r) => {
294
+ const chunks = [];
295
+ r.on('data', (d) => chunks.push(d));
296
+ r.on('end', () => resolve({
297
+ status: r.statusCode, respHeaders: r.headers, buf: Buffer.concat(chunks),
298
+ }));
299
+ r.on('error', reject);
300
+ });
301
+ up.on('error', reject);
302
+ up.setTimeout(20000, () => up.destroy(new Error('upstream timeout')));
303
+ if (req.method !== 'GET' && req.method !== 'HEAD' && body && body.length) up.write(body);
304
+ up.end();
264
305
  });
265
- const buf = Buffer.from(await up.arrayBuffer());
306
+
266
307
  const h = {};
267
- up.headers.forEach((v, k) => { if (!HOP_BY_HOP.has(k.toLowerCase())) h[k] = v; });
268
- res.writeHead(up.status, h);
308
+ for (const [k, v] of Object.entries(respHeaders)) {
309
+ if (RESP_DROP.has(k.toLowerCase())) continue;
310
+ h[k] = v;
311
+ }
312
+ res.writeHead(status, h);
269
313
  res.end(buf);
270
- log(`cursor-backend: ${req.method} ${full} -> REAL anthropic (${up.status}, ${buf.length}b)`);
314
+ log(`cursor-backend: ${req.method} ${full} -> REAL anthropic (${status}, ${buf.length}b)`);
271
315
  } catch (e) {
272
316
  res.writeHead(502, { 'content-type': 'application/json' });
273
317
  res.end(JSON.stringify({ type: 'error', error: { type: 'api_error', message: `passthrough failed: ${e.message}` } }));
@@ -360,8 +404,15 @@ export function startCursorBackend({ port = 8443, models, log = () => {} } = {})
360
404
  // verbatim to the local paying proxy and relay the response — reusing the
361
405
  // whole translate+x402 path. (SNI is checked because the Host header may be
362
406
  // absent on h2.)
363
- const host = String(req.headers[':authority'] || req.headers.host || req.socket?.servername || '');
364
- const isAnthropic = /anthropic\.com$/.test(host);
407
+ // STRIP THE PORT BEFORE MATCHING. :authority / Host carry `host:port`
408
+ // whenever the client dials a non-default port, so an anchored
409
+ // /anthropic\.com$/ silently failed to match and the request fell through
410
+ // to the gRPC branch, which answered empty-OK. The desktop app then saw a
411
+ // 200 with no body for its session bootstrap — indistinguishable from the
412
+ // 404 it used to get, and it wedged the same way.
413
+ const rawHost = String(req.headers[':authority'] || req.headers.host || req.socket?.servername || '');
414
+ const host = rawHost.replace(/:\d+$/, '');
415
+ const isAnthropic = /(^|\.)anthropic\.com$/.test(host);
365
416
  // INTERCEPT INFERENCE ONLY. Matching the whole HOST sent every request the
366
417
  // desktop app makes to the local proxy — including the session bootstrap
367
418
  // (/api/organizations, /api/bootstrap, /api/account), which the proxy does
package/lib/mcp.js CHANGED
@@ -7,6 +7,7 @@ import { PayClient, QuoteTooHighError, UnderfundedError } from './pay.js';
7
7
  import { tokenBalance } from './x402.js';
8
8
  import { askWithContext, contextCacheDisabled, BIND_MIN_CHARS } from './hrr.js';
9
9
  import { listContexts } from './contexts.js';
10
+ import { withNamespace } from './namespace.js';
10
11
 
11
12
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
12
13
  // The model zoo_ask uses when the caller does not name one. Opus 5 by default:
@@ -301,6 +302,50 @@ export function buildMcpServer() {
301
302
  })),
302
303
  }));
303
304
 
305
+ // OUROBOROS — the model's durable external memory, managed server-side by
306
+ // leCore (docs/ZOO.md §7-8). memory_write stores a fact into a per-wallet
307
+ // partition; memory_search recalls ranked, best first, across sessions. The
308
+ // namespace header keys the partition to THIS wallet. Free (infrastructure),
309
+ // and every response carries a deterministic lecore.receipt.
310
+ const memHeaders = () => withNamespace({ 'content-type': 'application/json' });
311
+
312
+ server.registerTool('zoo_remember', {
313
+ description: 'OUROBOROS: store a fact/decision into your durable external memory (leCore, server-side). Findable later by zoo_recall across sessions — you HAVE persistent memory; write to it instead of losing context.',
314
+ inputSchema: {
315
+ text: z.string().describe('the fact, decision, or note to remember'),
316
+ tags: z.array(z.string()).optional().describe('optional tags to group/filter later'),
317
+ },
318
+ }, async ({ text: t, tags }) => {
319
+ try {
320
+ const r = await fetch(`${config.apiBase}/v1/memory/write`, {
321
+ method: 'POST', headers: memHeaders(),
322
+ body: JSON.stringify({ text: t, tags: tags || [] }),
323
+ });
324
+ const d = await r.json();
325
+ if (!r.ok) return text({ error: d?.error || `memory_write HTTP ${r.status}` });
326
+ return text({ stored: d.stored, id: d.id, total_memories: d.entries, receipt: d._meta?.['lecore.receipt']?.output_sha256?.slice(0, 16) });
327
+ } catch (e) { return text({ error: `zoo_remember failed: ${e.message}` }); }
328
+ });
329
+
330
+ server.registerTool('zoo_recall', {
331
+ description: 'OUROBOROS: recall from your durable external memory — ranked results, best first, across all past sessions. Check here BEFORE claiming you do not remember something.',
332
+ inputSchema: {
333
+ query: z.string().describe('what to recall'),
334
+ top: z.number().int().min(1).max(50).optional().describe('how many results (default 4)'),
335
+ tags: z.array(z.string()).optional().describe('restrict to these tags'),
336
+ },
337
+ }, async ({ query, top, tags }) => {
338
+ try {
339
+ const r = await fetch(`${config.apiBase}/v1/memory/search`, {
340
+ method: 'POST', headers: memHeaders(),
341
+ body: JSON.stringify({ query, top: top || 4, ...(tags ? { tags } : {}) }),
342
+ });
343
+ const d = await r.json();
344
+ if (!r.ok) return text({ error: d?.error || `memory_search HTTP ${r.status}` });
345
+ return text({ hits: (d.hits || []).map((h) => ({ id: h.id, text: h.text, tags: h.tags, score: h.score })), searched: d.searched });
346
+ } catch (e) { return text({ error: `zoo_recall failed: ${e.message}` }); }
347
+ });
348
+
304
349
  return { server, client };
305
350
  }
306
351
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.34.10",
3
+ "version": "0.35.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",