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.
- package/lib/cursorbackend.js +65 -14
- package/lib/mcp.js +45 -0
- package/package.json +1 -1
package/lib/cursorbackend.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
306
|
+
|
|
266
307
|
const h = {};
|
|
267
|
-
|
|
268
|
-
|
|
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 (${
|
|
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
|
-
|
|
364
|
-
|
|
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.
|
|
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",
|