openzoo 0.50.93 → 0.50.94

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/bin/claude-zoo.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * the local openzoo proxy on :8402 (x402 per-call payment, no Anthropic
7
7
  * subscription, no login):
8
8
  *
9
- * - starts the proxy if it is not already THIS version on :8402 (steals stale)
9
+ * - starts the proxy if it is not already listening
10
10
  * - applies claudeZooEnv(): ANTHROPIC_BASE_URL=localhost:PORT/v1,
11
11
  * AUTH_TOKEN=sk-openzoo, ANTHROPIC_API_KEY deleted, compaction disabled
12
12
  * (the proxy binds the prefix), 1M-token ceiling restored
@@ -16,8 +16,7 @@
16
16
  * `claude-code-cli` (same directory, symlinked to occ).
17
17
  */
18
18
  import { spawn } from 'node:child_process';
19
- import { existsSync, readdirSync as fsReaddir, readFileSync } from 'node:fs';
20
- import { execSync } from 'node:child_process';
19
+ import { existsSync, readdirSync as fsReaddir } from 'node:fs';
21
20
  import { homedir, platform } from 'node:os';
22
21
  import { dirname, join, sep } from 'node:path';
23
22
  import { fileURLToPath } from 'node:url';
@@ -51,34 +50,10 @@ if (!occ) {
51
50
  const PROXY_PORT = Number(process.env.OPENZOO_PROXY_PORT || 8402);
52
51
  const PROXY_URL = `http://localhost:${PROXY_PORT}/v1`;
53
52
 
54
- function mineVersion() {
55
- try {
56
- return JSON.parse(readFileSync(join(shimRoot, 'package.json'), 'utf8')).version;
57
- } catch { return ''; }
58
- }
59
-
60
- function stealPort(port) {
61
- const n = Number(port);
62
- for (const cmd of [
63
- `lsof -t -iTCP:${n} -sTCP:LISTEN | xargs kill -9`,
64
- `fuser -k ${n}/tcp`,
65
- ]) {
66
- try { execSync(cmd, { stdio: 'ignore', timeout: 2000, shell: true }); } catch { /* missing */ }
67
- }
68
- }
69
-
70
53
  async function proxyUp() {
71
54
  try {
72
55
  const r = await fetch(`http://localhost:${PROXY_PORT}/v1/info`, { signal: AbortSignal.timeout(1500) });
73
- if (!r.ok) return false;
74
- const j = await r.json().catch(() => ({}));
75
- const mine = mineVersion();
76
- if (mine && String(j.version || '') !== mine) {
77
- console.error(`claude: stale proxy v${j.version || '?'} on :${PROXY_PORT} — stealing for v${mine}`);
78
- stealPort(PROXY_PORT);
79
- return false;
80
- }
81
- return true;
56
+ return r.ok;
82
57
  } catch { return false; }
83
58
  }
84
59
 
package/bin/openzoo.js CHANGED
@@ -330,7 +330,7 @@ async function main() {
330
330
  }
331
331
  case 'ask': {
332
332
  const question = process.argv[3];
333
- if (!question) throw new Error('usage: openzoo ask "<question>" [--context <id>] [--model <id>] [--system <text>]');
333
+ if (!question) throw new Error('usage: openzoo ask "<question>" [--context <id>] [--model <id>] [--system <text>] [--web [--web-results N]]');
334
334
  const ci = process.argv.indexOf('--context');
335
335
  const mi = process.argv.indexOf('--model');
336
336
  // A BARE QUESTION IS A DIFFERENT PRODUCT FROM A BRIEFED ONE.
@@ -344,7 +344,20 @@ async function main() {
344
344
  // DHH, Hyprland and theming. Same gateway, same product, one had context.
345
345
  // A caller that knows where it is running can now say so.
346
346
  const si = process.argv.indexOf('--system');
347
- const system = si !== -1 ? process.argv[si + 1] : '';
347
+ let system = si !== -1 ? process.argv[si + 1] : '';
348
+ // --web: a keyless DuckDuckGo search, top results injected into THIS
349
+ // call's system prompt. The x402 rail strips OpenRouter's `plugins`
350
+ // field, so search-then-inject has to happen here, on the caller's
351
+ // side. EGRESS: the question text goes to duckduckgo.com. Also on with
352
+ // OPENZOO_ASK_WEB=1; --web-results N caps the count (default 5).
353
+ const wantWeb = process.argv.includes('--web') || process.env.OPENZOO_ASK_WEB === '1';
354
+ if (wantWeb) {
355
+ const wi = process.argv.indexOf('--web-results');
356
+ const n = wi !== -1 ? Number(process.argv[wi + 1]) || 5 : 5;
357
+ const { webSearch, formatWebResults } = await import('../lib/websearch.js');
358
+ const hits = await webSearch(question, n).catch((e) => { console.error(`web search failed: ${e.message}`); return []; });
359
+ if (hits.length) system = (system ? system + '\n\n' : '') + formatWebResults(question, hits);
360
+ }
348
361
  const { PayClient } = await import('../lib/pay.js');
349
362
  const { config } = await import('../lib/config.js');
350
363
  const client = new PayClient();
@@ -389,6 +402,17 @@ async function main() {
389
402
  case '-h':
390
403
  console.log(HELP);
391
404
  break;
405
+ case 'version':
406
+ case '--version':
407
+ case '-v':
408
+ case '-V': {
409
+ // Every installer, mise shim and shell script that probes a CLI asks
410
+ // this first; answering "unknown command" to it failed real installs.
411
+ const { readFileSync } = await import('fs');
412
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
413
+ console.log(pkg.version);
414
+ break;
415
+ }
392
416
  default:
393
417
  console.error(`unknown command: ${cmd}\n`);
394
418
  console.log(HELP);
package/lib/aoe.js CHANGED
@@ -287,15 +287,9 @@ export async function setupAoe(argv = []) {
287
287
 
288
288
  const base = `http://localhost:${config.port}/v1`;
289
289
  if (!flags.has('--no-proxy')) {
290
- const { oursOn, packageVersion, killListen } = await import('./proxy.js');
291
- if (await oursOn(config.port)) {
292
- console.error(`openzoo: proxy v${packageVersion()} already on ${base}`);
290
+ if (await proxyUp(base)) {
291
+ console.error(`openzoo: proxy already up on ${base}`);
293
292
  } else {
294
- if (await proxyUp(base)) {
295
- console.error(`openzoo: stale proxy on ${base} — stealing :${config.port}`);
296
- killListen(config.port);
297
- await new Promise((r) => setTimeout(r, 400));
298
- }
299
293
  const { pid, logPath } = startDetachedProxy({ tunnel: flags.has('--tunnel') });
300
294
  let up = false;
301
295
  for (let i = 0; i < 40 && !up; i++) {
@@ -3075,22 +3075,17 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
3075
3075
  if (r.status === 402) {
3076
3076
  const raw = zooTextFromMessage(data?.choices?.[0]?.message, data)
3077
3077
  || data?.error?.message
3078
- || 'openzoo payment required (HTTP 402).';
3079
- data.error = data.error || {};
3080
- data.error.message = raw;
3078
+ || 'openzoo wallet underfunded.';
3081
3079
  try {
3082
- const { withOnrampLink, isFundInstruction } = await import('./stripeOnramp.js');
3083
- // Settle/upstream 402s keep the real message. Whop copy-paste only
3084
- // when this is a genuine empty-wallet / fund-me instruction.
3085
- if (isFundInstruction(raw)) {
3086
- const { loadOrCreateWallet } = await import('./wallet.js');
3087
- const w = loadOrCreateWallet();
3088
- const usd = Number(String(raw).match(/≈\$([0-9.]+)/)?.[1]);
3089
- data.error.message = await withOnrampLink(raw, {
3090
- solana: w.keypair.publicKey.toBase58(),
3091
- usd,
3092
- });
3093
- }
3080
+ const { withOnrampLink } = await import('./stripeOnramp.js');
3081
+ const { loadOrCreateWallet } = await import('./wallet.js');
3082
+ const w = loadOrCreateWallet();
3083
+ const usd = Number(String(raw).match(/≈\$([0-9.]+)/)?.[1]);
3084
+ data.error = data.error || {};
3085
+ data.error.message = await withOnrampLink(raw, {
3086
+ solana: w.keypair.publicKey.toBase58(),
3087
+ usd,
3088
+ });
3094
3089
  } catch { /* keep proxy copy */ }
3095
3090
  }
3096
3091
  return { r, data };
package/lib/launch.js CHANGED
@@ -190,21 +190,18 @@ export function claudeZooEnv(baseEnv = process.env, { base, port } = {}) {
190
190
  * DEFAULT is the desktop app; `--terminal` (or `-t`) runs the Claude Code CLI.
191
191
  * Both get ANTHROPIC_BASE_URL so inference pays x402.
192
192
  */
193
-
194
-
195
193
  export async function launchClaude(argv) {
196
- // NEVER hop. :8402 is the product. startProxy steals a stale listener.
194
+ // let, not const: startProxy can heal onto a different port and every URL
195
+ // below must follow the port we actually bound.
197
196
  let base = `http://localhost:${config.port}/v1`;
198
- // AUTO-START THE PROXY. One command should just work — if nothing is listening
199
- // (or a leftover npx cache is), boot THIS version on :8402 in this process.
197
+ // AUTO-START THE PROXY. One command should just work — if nothing is listening,
198
+ // boot the proxy in THIS process (it stays alive because claude runs in the
199
+ // foreground below), rather than making the user run `npx openzoo` first.
200
200
  let up = false;
201
- const { startProxy, oursOn, packageVersion } = await import('./proxy.js');
202
- const mine = packageVersion();
203
- if (await oursOn(config.port)) {
204
- up = true;
205
- } else {
206
- process.stderr.write(`openzoo: claiming :${config.port} for v${mine}\n`);
207
- }
201
+ // /info, not /models see the poll below. "Is a proxy already listening" must
202
+ // not be answered by an endpoint that needs the gateway, or a user whose
203
+ // upstream is flaky gets told to start a proxy that is already running.
204
+ try { up = (await fetch(`${base}/info`, { signal: AbortSignal.timeout(3000) })).ok; } catch { up = false; }
208
205
  if (!up) {
209
206
  // NEVER GO SILENT DURING STARTUP. silent:true routes the proxy's own lines
210
207
  // to ~/.openzoo/proxy.log so payment receipts cannot corrupt Claude Code's
@@ -221,6 +218,7 @@ export async function launchClaude(argv) {
221
218
  tick.unref?.();
222
219
  const done = (msg) => { clearInterval(tick); process.stderr.write(`\r\x1b[2Kopenzoo: ${msg}\n`); };
223
220
  try {
221
+ const { startProxy } = await import('./proxy.js');
224
222
  await startProxy({ silent: true, autoTunnel: true });
225
223
  } catch (err) {
226
224
  // An exception here used to surface as an eternal spinner. Say what broke.
@@ -229,6 +227,9 @@ export async function launchClaude(argv) {
229
227
  console.error(' try: OPENZOO_NO_TUNNEL=1 npx openzoo claude (skips the cloudflared download)');
230
228
  process.exit(1);
231
229
  }
230
+ // The proxy may have healed onto a different port (8402 busy). config.port
231
+ // is the one it ACTUALLY bound, so re-derive every URL from it — the old
232
+ // code kept polling the port it wished for and timed out on a live proxy.
232
233
  base = `http://localhost:${config.port}/v1`;
233
234
  // PROBE /v1/info, NOT /v1/models. `models` is PROXIED UPSTREAM, so on a
234
235
  // network with a bad path to the gateway the local proxy is listening and
@@ -579,12 +580,17 @@ export async function launchClaude(argv) {
579
580
  }
580
581
 
581
582
  export async function launchHarness(cmd, args) {
582
- let base = `http://localhost:${config.port}/v1`;
583
- const { startProxy, oursOn, packageVersion } = await import('./proxy.js');
584
- if (!(await oursOn(config.port))) {
585
- process.stderr.write(`openzoo: claiming :${config.port} for v${packageVersion()}\n`);
586
- await startProxy({ silent: true, autoTunnel: true });
587
- base = `http://localhost:${config.port}/v1`;
583
+ const base = `http://localhost:${config.port}/v1`;
584
+ // Fail early with a clear message rather than letting the harness spew
585
+ // connection errors — the #1 support question would otherwise be "why won't
586
+ // claude connect" when the answer is "the proxy isn't up".
587
+ try {
588
+ const r = await fetch(`${base}/models`, { signal: AbortSignal.timeout(4000) });
589
+ if (!r.ok) throw new Error(String(r.status));
590
+ } catch {
591
+ console.error(`openzoo: no proxy reachable at ${base}`);
592
+ console.error('start it first in another terminal: npx openzoo');
593
+ process.exit(1);
588
594
  }
589
595
 
590
596
  const env = claudeZooEnv(process.env, { base });
package/lib/models.js CHANGED
@@ -168,9 +168,39 @@ const tokensOf = (id) => id.toLowerCase().split(/[^a-z0-9.]+/).filter((t) => t &
168
168
  * the id is already servable (no rewrite), otherwise the closest zoo id.
169
169
  * OPENZOO_DEFAULT_MODEL is an explicit user override, not a fallback tier.
170
170
  */
171
+ /**
172
+ * UNOPENROUTER. OpenRouter is not an upstream any more (gateway, 2026-09-02):
173
+ * every completion is bought from an x402 door, and doors publish BARE ids
174
+ * (`grok-4.3`, `claude-sonnet-5`, `gemini-2.5-flash`). A harness that still
175
+ * sends the OpenRouter spelling (`x-ai/grok-4.3`, `anthropic/claude-sonnet-5`)
176
+ * is rewritten to the bare id whenever the live catalog serves that bare id.
177
+ *
178
+ * OPENZOO_UNOPENROUTER=1 strip the vendor prefix ALWAYS, catalog or not
179
+ * OPENZOO_UNOPENROUTER=0 never strip
180
+ * unset strip when the bare id is in the catalog
181
+ *
182
+ * Router aliases and `openzoo-` twins are never touched: their slash is not a
183
+ * vendor.
184
+ */
185
+ export function unopenrouter(requested, ids) {
186
+ const mode = process.env.OPENZOO_UNOPENROUTER;
187
+ if (mode === '0') return null;
188
+ const s = String(requested || '');
189
+ if (!s || isAutoModel(s) || /^openzoo[-/]/i.test(s)) return null;
190
+ const slash = s.indexOf('/');
191
+ if (slash <= 0) return null;
192
+ const bare = s.slice(slash + 1);
193
+ if (!bare || bare.includes('/')) return null;
194
+ if (mode === '1') return bare;
195
+ return Array.isArray(ids) && ids.includes(bare) ? bare : null;
196
+ }
197
+
171
198
  export function resolveModel(requested, ids) {
172
199
  // Virtual router id — never family-match, never steal via OPENZOO_DEFAULT_MODEL.
173
200
  if (isAutoModel(requested)) return null;
201
+ // Vendor-prefixed OpenRouter spelling → the bare id the doors serve.
202
+ const bareVendor = unopenrouter(requested, ids);
203
+ if (bareVendor) return bareVendor;
174
204
  // Bare Anthropic / Claude Code ids are never live on Fly/OpenRouter
175
205
  // (`claude-opus-5` → 500 unknown model). Rewrite even on a catalog miss
176
206
  // or if a gateway row lists the bare name — the request must not leave
@@ -14,7 +14,7 @@ import { spendChipLabel } from './spendProof.js';
14
14
 
15
15
  export const GROKBOT_CDP_PORT = Number(process.env.OZ_GROKBOT_CDP_PORT || 9444);
16
16
 
17
- /** Session totals for the always-on HUD pill. Message chips still fold the footer. */
17
+ /** Session totals for a floating pill when the open canvas has no footer. */
18
18
  export function sessionSpendLabel(home = os.homedir()) {
19
19
  try {
20
20
  const s = JSON.parse(fs.readFileSync(path.join(home, '.openzoo', 'session.json'), 'utf8'));
@@ -159,9 +159,7 @@ function ozEnsureSpendCss() {
159
159
  }
160
160
  s.textContent = [
161
161
  '.oz-spend{margin:.55rem 0 0;font-size:12px;color:inherit;opacity:.82;max-width:36em}',
162
- '#oz-spend-hud{opacity:1}',
163
- '#oz-spend-hud>summary{cursor:grab;background:rgba(28,28,30,.94);color:#f4f4f5;',
164
- 'border-color:rgba(255,255,255,.28);box-shadow:0 1px 8px rgba(0,0,0,.35)}',
162
+ '#oz-spend-float>summary{cursor:grab}',
165
163
  '.oz-spend>summary{cursor:help;list-style:none;display:inline-flex;align-items:center;gap:.35rem;',
166
164
  'padding:3px 9px;border-radius:999px;border:1px solid rgba(255,255,255,.18);white-space:nowrap;',
167
165
  'max-width:100%;overflow:hidden;text-overflow:ellipsis}',
@@ -215,19 +213,11 @@ export function stripSpendFromText(s) {
215
213
  return t;
216
214
  }
217
215
 
218
- function ozInComposer(el) {
219
- if (!el) return false;
220
- if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT' || el.isContentEditable) return true;
221
- return !!(el.closest && el.closest('textarea, input, [contenteditable="true"]'));
222
- }
223
-
224
216
  function ozBlankSpendIn(root) {
225
- if (ozInComposer(root)) return;
226
217
  const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
227
218
  while (w.nextNode()) {
228
219
  const tn = w.currentNode;
229
220
  if (tn.parentElement && tn.parentElement.closest && tn.parentElement.closest('.oz-spend')) continue;
230
- if (ozInComposer(tn.parentElement)) continue;
231
221
  const next = stripSpendFromText(tn.nodeValue || '');
232
222
  if (next !== tn.nodeValue) tn.nodeValue = next;
233
223
  }
@@ -238,7 +228,6 @@ function ozHideSpendLeftovers() {
238
228
  for (let i = 0; i < nodes.length; i += 1) {
239
229
  const el = nodes[i];
240
230
  if (el.closest && el.closest('.oz-spend')) continue;
241
- if (ozInComposer(el)) continue;
242
231
  if (el.querySelector && el.querySelector('.oz-spend')) continue;
243
232
  const vis = ozVisibleSpendText(el);
244
233
  if (!spendOnlyText(vis)) continue;
@@ -301,11 +290,10 @@ function ozCollapseSpend() {
301
290
  for (let i = 0; i < nodes.length; i += 1) {
302
291
  const el = nodes[i];
303
292
  if (el.closest && el.closest('.oz-spend')) continue;
304
- if (ozInComposer(el)) continue;
305
293
  const t = ozVisibleSpendText(el);
306
294
  if (!/::oz-spend::|this call \$|spent \$/i.test(t)) continue;
307
295
  const host = ozSpendHost(el);
308
- if (!host || seen.has(host) || ozInComposer(host)) continue;
296
+ if (!host || seen.has(host)) continue;
309
297
  seen.add(host);
310
298
  hosts.push(host);
311
299
  }
@@ -314,11 +302,10 @@ function ozCollapseSpend() {
314
302
  const vis = ozVisibleSpendText(host);
315
303
  const split = splitSpendText(vis);
316
304
  if (!split || !split.body || split.body.length < 12) continue;
317
- if (split.summary) window.__OZ_SPEND_LAST__ = split.summary;
318
305
  ozEnsureSpendCss();
319
306
  if (spendOnlyText(vis)) {
320
307
  const prev = ozPreviousMessageCard(host);
321
- if (prev && prev !== host && !ozInComposer(prev)) {
308
+ if (prev && prev !== host) {
322
309
  ozAttachSpendChip(prev, split);
323
310
  host.setAttribute('data-oz-spend-hide', '1');
324
311
  continue;
@@ -331,46 +318,18 @@ function ozCollapseSpend() {
331
318
  ozEnsureFloatSpend();
332
319
  }
333
320
 
334
- /** Live HUD label: latest folded chip, then a read-only canvas scan, then inject snapshot. */
335
- function ozFloatLabel() {
336
- const pills = document.querySelectorAll('.oz-spend:not(#oz-spend-hud):not(#oz-spend-float) summary');
337
- if (pills.length) {
338
- const lab = String(pills[pills.length - 1].textContent || '').replace(/^ⓘ\s*/, '').trim();
339
- if (lab) {
340
- window.__OZ_SPEND_LAST__ = lab;
341
- return lab;
342
- }
343
- }
344
- try {
345
- const nodes = document.querySelectorAll('[class*="sand-message"]');
346
- for (let i = nodes.length - 1; i >= 0; i -= 1) {
347
- if (ozInComposer(nodes[i])) continue;
348
- const vis = ozVisibleSpendText(nodes[i]);
349
- if (!/::oz-spend::|this call \$|spent \$/i.test(vis)) continue;
350
- const split = splitSpendText(vis);
351
- if (split && split.summary) {
352
- window.__OZ_SPEND_LAST__ = split.summary;
353
- return split.summary;
354
- }
355
- }
356
- } catch (e) {}
357
- if (window.__OZ_SPEND_LAST__) return String(window.__OZ_SPEND_LAST__);
358
- return String(window.__OZ_SESSION_SPEND__ || '').trim();
359
- }
360
-
361
321
  function ozEnsureFloatSpend() {
362
- const label = ozFloatLabel();
363
- const leftover = document.getElementById('oz-spend-float');
364
- if (leftover) leftover.remove();
365
- let el = document.getElementById('oz-spend-hud');
366
- if (!label) {
322
+ const label = String(window.__OZ_SESSION_SPEND__ || '').trim();
323
+ const msgPills = document.querySelectorAll('.oz-spend:not(#oz-spend-float)').length;
324
+ let el = document.getElementById('oz-spend-float');
325
+ if (!label || msgPills) {
367
326
  if (el) el.remove();
368
327
  return;
369
328
  }
370
329
  ozEnsureSpendCss();
371
330
  if (!el) {
372
331
  el = document.createElement('details');
373
- el.id = 'oz-spend-hud';
332
+ el.id = 'oz-spend-float';
374
333
  el.className = 'oz-spend';
375
334
  const sum = document.createElement('summary');
376
335
  const body = document.createElement('div');
@@ -398,7 +357,7 @@ function ozPlaceFloat(el) {
398
357
  if (!el) return;
399
358
  el.style.position = 'fixed';
400
359
  el.style.zIndex = '2147483646';
401
- el.style.opacity = '1';
360
+ el.style.opacity = '0.95';
402
361
  const saved = ozSavedFloatPos();
403
362
  if (saved) {
404
363
  el.style.left = Math.max(8, saved.left) + 'px';
@@ -457,36 +416,29 @@ function ozDragFloat(el) {
457
416
 
458
417
  function ozWatchSpend() {
459
418
  let t = 0;
460
- const inComposer = () => ozInComposer(document.activeElement);
461
419
  const run = () => {
462
- try {
463
- if (inComposer()) {
464
- ozEnsureSpendCss();
465
- ozEnsureFloatSpend();
466
- } else {
467
- ozCollapseSpend();
468
- }
469
- } catch (e) {}
420
+ const a = document.activeElement;
421
+ if (a && (a.tagName === 'TEXTAREA' || a.tagName === 'INPUT' || a.isContentEditable)) return;
422
+ try { ozCollapseSpend(); } catch (e) {}
470
423
  };
471
- const debounced = () => { clearTimeout(t); t = setTimeout(run, 400); };
424
+ const debounced = () => { clearTimeout(t); t = setTimeout(run, 600); };
472
425
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run);
473
426
  else run();
474
427
  try {
475
428
  const mo = new MutationObserver(debounced);
476
- const start = () => { if (document.body) mo.observe(document.body, { childList: true, subtree: true }); };
429
+ const start = () => { if (document.body) mo.observe(document.body, { childList: true, subtree: false }); };
477
430
  if (document.body) start();
478
431
  else document.addEventListener('DOMContentLoaded', start);
479
432
  } catch (e) {}
480
433
  try { window.addEventListener('resize', debounced); } catch (e) {}
481
- try { setInterval(run, 2000); } catch (e) {}
482
434
  }
483
435
 
484
436
  export function spendChipSource() {
485
437
  return [
486
438
  '(function ozSpendChip(){',
487
439
  "'use strict';",
488
- 'if (window.__OZ_SPEND_CHIP__ === 13) return;',
489
- 'window.__OZ_SPEND_CHIP__ = 13;',
440
+ 'if (window.__OZ_SPEND_CHIP__ === 12) return;',
441
+ 'window.__OZ_SPEND_CHIP__ = 12;',
490
442
  chipUsd.toString(),
491
443
  labelFromSpendBody.toString(),
492
444
  spendLinesOnly.toString(),
@@ -496,13 +448,11 @@ export function spendChipSource() {
496
448
  ozSpendHost.toString(),
497
449
  ozVisibleSpendText.toString(),
498
450
  stripSpendFromText.toString(),
499
- ozInComposer.toString(),
500
451
  ozBlankSpendIn.toString(),
501
452
  ozHideSpendLeftovers.toString(),
502
453
  ozPreviousMessageCard.toString(),
503
454
  ozAttachSpendChip.toString(),
504
455
  ozCollapseSpend.toString(),
505
- ozFloatLabel.toString(),
506
456
  ozEnsureFloatSpend.toString(),
507
457
  ozSavedFloatPos.toString(),
508
458
  ozPlaceFloat.toString(),
package/lib/proxy.js CHANGED
@@ -9,11 +9,11 @@ import {
9
9
  } from './config.js';
10
10
  import { execSync } from 'node:child_process';
11
11
  import { PayClient, QuoteTooHighError, UnderfundedError } from './pay.js';
12
- import { withOnrampLink, settleFailCopy, isFundInstruction } from './stripeOnramp.js';
12
+ import { withOnrampLink } from './stripeOnramp.js';
13
13
  import { tokenBalance } from './x402.js';
14
14
  import { evmTokenBalance } from './evm.js';
15
15
  import { autoContext } from './autobind.js';
16
- import { modelsListForRequest, isHarnessAliasId, resolveModel, quoteableRows } from './models.js';
16
+ import { modelsListForRequest, isHarnessAliasId, resolveModel, quoteableRows, unopenrouter } from './models.js';
17
17
 
18
18
  /**
19
19
  * Quoteable catalog ids, cached 5 minutes, for the fuzzy /v1/models/<id> probe.
@@ -38,89 +38,19 @@ import { receiptUsedCogs, receiptDirectUsd, pairActualBilled } from './racesettl
38
38
  import { fetchHeaders } from './fetch.js';
39
39
  import { attachX402Proof } from './spendProof.js';
40
40
 
41
- /** Kill whatever is LISTEN on this port except this process.
42
- * lsof first (macOS), then fuser, then /proc (Omarchy/Arch with neither).
43
- * Every openzoo subcommand that binds :8402 goes through this. Hopping to
44
- * 8403 was the second-burner bug. */
41
+ /** Kill whatever is LISTEN on this port except this process. */
45
42
  export function killListen(port, run = execSync) {
46
- const n = Number(port);
47
- const seen = new Set();
48
- const addFrom = (out) => {
49
- for (const tok of String(out || '').split(/[^\d]+/)) {
50
- const pid = Number(tok);
51
- if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) seen.add(pid);
52
- }
53
- };
54
- const tryRun = (cmd, opts = {}) => {
55
- try {
56
- return run(cmd, { encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'], ...opts });
57
- } catch { return ''; }
58
- };
59
- addFrom(tryRun(`lsof -nP -iTCP:${n} -sTCP:LISTEN -t`));
60
- addFrom(tryRun(`fuser -n tcp ${n}`));
61
- if (process.platform === 'linux' && seen.size === 0) {
62
- addFrom(tryRun(`python3 - ${n}`, {
63
- timeout: 2500,
64
- input: `import os, glob, sys
65
- port=int(sys.argv[1]); hx=f'{port:04X}'
66
- inodes=set(); pids=set()
67
- for path in ('/proc/net/tcp','/proc/net/tcp6'):
68
- try:
69
- for line in open(path):
70
- p=line.split()
71
- if len(p)<10: continue
72
- if p[1].split(':')[-1].upper()==hx: inodes.add(p[9])
73
- except FileNotFoundError:
74
- pass
75
- for fd in glob.glob('/proc/[0-9]*/fd/[0-9]*'):
76
- try: t=os.readlink(fd)
77
- except OSError: continue
78
- if any(ino and ino!='0' and ino in t for ino in inodes):
79
- try: pids.add(int(fd.split('/')[2]))
80
- except (OSError, ValueError): pass
81
- print('\\n'.join(str(p) for p in pids if p != os.getpid()))
82
- `,
83
- }));
84
- }
85
- for (const pid of seen) {
86
- try { run(`kill ${pid}`, { stdio: 'ignore', timeout: 2000 }); } catch { /* already gone */ }
87
- try { run(`kill -9 ${pid}`, { stdio: 'ignore', timeout: 2000 }); } catch { /* already gone */ }
88
- }
89
- return [...seen];
90
- }
91
-
92
- /** This package.json version. Same one /v1/info and /v1/session publish. */
93
- export function packageVersion() {
94
- return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
95
- }
96
-
97
- /** True when the listener on this port is THIS openzoo version or newer.
98
- * Missing / unparseable version is stale (0.49.x answered with no version).
99
- * Older than us is stale. Newer we leave alone so we never downgrade. */
100
- export async function oursOn(port = config.port) {
101
- const mine = packageVersion();
102
- const parse = (v) => {
103
- const m = String(v || '').trim().match(/^(\d+)\.(\d+)\.(\d+)/);
104
- return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
105
- };
106
- const attachable = (theirs) => {
107
- const a = parse(theirs), b = parse(mine);
108
- if (!a || !b) return false;
109
- for (let i = 0; i < 3; i++) {
110
- if (a[i] > b[i]) return true;
111
- if (a[i] < b[i]) return false;
43
+ try {
44
+ const pids = run(`lsof -nP -iTCP:${Number(port)} -sTCP:LISTEN -t`, {
45
+ encoding: 'utf8', timeout: 2000,
46
+ }).trim().split('\n').map(Number).filter((n) => Number.isInteger(n) && n > 0 && n !== process.pid);
47
+ for (const pid of pids) {
48
+ try { run(`kill ${pid}`, { stdio: 'ignore', timeout: 2000 }); } catch { /* already gone */ }
112
49
  }
113
- return true;
114
- };
115
- for (const path of ['/v1/info', '/v1/session']) {
116
- try {
117
- const r = await fetch(`http://127.0.0.1:${Number(port)}${path}`, { signal: AbortSignal.timeout(1500) });
118
- if (!r.ok) continue;
119
- const j = await r.json().catch(() => ({}));
120
- if (attachable(j.version)) return true;
121
- } catch { /* try next */ }
50
+ return pids;
51
+ } catch {
52
+ return [];
122
53
  }
123
- return false;
124
54
  }
125
55
 
126
56
  // THE SHIM IS A FACILITATOR, NOT A MIDDLEBOX.
@@ -582,13 +512,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
582
512
  ? `${tunnelGate.publicUrl}/v1`
583
513
  : `http://localhost:${config.port}/v1`;
584
514
  res.writeHead(200, { 'content-type': 'application/json' });
585
- const { version: ozVersion } = JSON.parse(
586
- readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
587
- );
588
515
  res.end(JSON.stringify({
589
516
  youAreTalkingTo: 'openzoo proxy',
590
- version: ozVersion,
591
- solana: client.address,
592
517
  yourEndpoint: self,
593
518
  reachedVia: viaTunnel ? 'public tunnel' : 'localhost',
594
519
  publicTunnel: tunnelGate?.publicUrl ? `${tunnelGate.publicUrl}/v1` : null,
@@ -693,10 +618,43 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
693
618
  const isPaidPost = req.method === 'POST'
694
619
  && /\/(chat\/completions|completions|messages|responses)$/.test(rawPath);
695
620
  let wantsStream = false;
621
+ // RECEIPTS ARE OPT-OUT. `disableStats: true` in the body (or the
622
+ // x-openzoo-disable-stats header) means this caller does not want the x402
623
+ // block, so we neither attach our settle proof nor pass the gateway's
624
+ // receipt through. The field is deliberately FORWARDED, not stripped: the
625
+ // gateway reads it too and drops its own half. Absent/false keeps today's
626
+ // behaviour, because our own spend line reads that block.
627
+ let statsOff = false;
628
+ if (isPaidPost) {
629
+ try {
630
+ const b = JSON.parse(bodyBuf.toString('utf8'));
631
+ const h = req.headers['x-openzoo-disable-stats'];
632
+ const truthy = (v) => v === true || v === 'true' || v === '1' || v === 1;
633
+ statsOff = truthy(b?.disableStats) || truthy(Array.isArray(h) ? h[0] : h);
634
+ } catch { /* not JSON */ }
635
+ }
696
636
  if (isPaidPost) {
697
637
  servedRequests += 1;
698
638
  say(`\n<- request #${servedRequests} from ${(req.headers['user-agent'] || 'unknown').slice(0, 40)}`);
699
639
  try { wantsStream = JSON.parse(bodyBuf.toString('utf8'))?.stream === true; } catch { /* not JSON */ }
640
+ // UNOPENROUTER THE MODEL ID before anything downstream sees the body —
641
+ // the replay key, the outage gate, the wire. A vendor-prefixed
642
+ // OpenRouter spelling becomes the bare id the doors serve when the
643
+ // catalog lists it (OPENZOO_UNOPENROUTER=1 forces it, =0 disables).
644
+ // This is the one place the body is rewritten; see models.js.
645
+ try {
646
+ const parsed = JSON.parse(bodyBuf.toString('utf8'));
647
+ if (parsed && typeof parsed.model === 'string') {
648
+ let ids = [];
649
+ try { ids = await catalogIdsCached(`${config.apiBase}/v1/models`, upstreamHeaders(req)); } catch { /* catalog unreachable: only the forced mode rewrites */ }
650
+ const bare = unopenrouter(parsed.model, ids);
651
+ if (bare) {
652
+ say(` model ${parsed.model} -> ${bare} (bare id: doors, not OpenRouter)`);
653
+ parsed.model = bare;
654
+ bodyBuf = Buffer.from(JSON.stringify(parsed));
655
+ }
656
+ }
657
+ } catch { /* not JSON */ }
700
658
  }
701
659
 
702
660
  // Retry of a body we answered seconds ago? Serve the cached completion —
@@ -867,7 +825,11 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
867
825
  say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
868
826
  }
869
827
  if (data?.object === 'chat.completion') {
870
- if (paid && receipt) {
828
+ if (statsOff) {
829
+ // The gateway already dropped its half; drop ours, and any block an
830
+ // older gateway in front of us still attached.
831
+ delete data.x402;
832
+ } else if (paid && receipt) {
871
833
  attachX402Proof(data, {
872
834
  tx: receipt.tx,
873
835
  memo: receipt.memo || accept?.extra?.memo,
@@ -904,13 +866,14 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
904
866
  rememberSpend();
905
867
  say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
906
868
  };
907
- // A 402 AFTER we attempted payment is a SETTLEMENT failure, not a quote.
908
- // The client-side balance check is advisory, so a funded wallet can still
909
- // fail on-chain / at the facilitator, and the gateway answers the paid
910
- // retry with a fresh 402. Relaying that as "wallet underfunded" + Whop
911
- // copy-paste blamed burners that already paid. Surface the gateway's
912
- // real reason; only prepend fund-me copy on genuine insufficient_funds.
869
+ // A 402 AFTER we attempted payment is a SETTLEMENT failure, not a quote
870
+ // the client-side balance check is advisory, so a wallet that looks
871
+ // fundable can still fail on-chain, and the gateway answers the paid
872
+ // retry with a fresh 402. Relaying that raw meant Claude Code printed a
873
+ // half-kilobyte accepts[] blob at the user instead of the one thing they
874
+ // need: the price, what the wallet holds, and where to send funds.
913
875
  if (response.status === 402) {
876
+ let quoted = '';
914
877
  let usd;
915
878
  let q402 = null;
916
879
  try { q402 = await response.clone().json(); } catch { q402 = null; }
@@ -927,21 +890,18 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
927
890
  return;
928
891
  }
929
892
  try {
930
- usd = Number(q402?.accepts?.[0]?.extra?.billedUsd);
931
- if (!Number.isFinite(usd)) usd = undefined;
932
- } catch { usd = undefined; }
933
- const copy = settleFailCopy(q402);
934
- // paid:true never "wallet underfunded", never Whop unless the
935
- // gateway itself named insufficient_funds. UnderfundedError (empty
936
- // wallet preflight) is handled in the catch below.
937
- let msg = copy.message;
938
- const wantOnramp = copy.code === 'insufficient_funds'
939
- || (!paid && isFundInstruction(copy.reason, copy));
940
- if (wantOnramp) {
941
- msg = await withOnrampLink(msg, { solana: client.address, usd, code: copy.code });
942
- }
943
- log(/ties to your account/i.test(msg) ? 'onramp: whop + copy-paste solana' : `402: ${msg.slice(0, 140)}`);
944
- jsonErr(res, paid ? copy.status : 402, msg);
893
+ const q = q402;
894
+ usd = Number(q?.accepts?.[0]?.extra?.billedUsd);
895
+ if (Number.isFinite(usd)) quoted = ` This call needs ≈$${usd.toFixed(4)}.`;
896
+ } catch { /* body was not the quote after all */ }
897
+ const msg = await withOnrampLink(
898
+ `openzoo wallet underfunded payment did not settle.${quoted} `
899
+ + `Fund it and retry: send USDC (or TOKEN/LEOS for half price) to ${client.address} on Solana, `
900
+ + `or USDC to ${client.evmAddress} on Base. Check with: openzoo balance`,
901
+ { solana: client.address, usd },
902
+ );
903
+ log(/ties to your account/i.test(msg) ? 'onramp: whop + copy-paste solana' : 'onramp: no fund blurb');
904
+ jsonErr(res, 402, msg);
945
905
  return;
946
906
  }
947
907
  await relay(res, response, meterStreamed);
@@ -974,18 +934,11 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
974
934
  // OPENZOO_BIND=0.0.0.0 AND a tunnel token, so that port stays gated exactly
975
935
  // like the public tunnel path.
976
936
  const bindHost = process.env.OPENZOO_BIND || '127.0.0.1';
977
- // SELF-HEAL :8402. Kill whoever is on THIS port, then bind it. Never hop to
978
- // 8403 that was the second-burner. Every subcommand that calls startProxy
979
- // (openzoo, claude, bot, web, cursor, aoe, grok, tunnel) goes through here.
980
- // Reusing a leftover listener left a stale PayClient serving $0 after a
981
- // TOKEN top-up, and an old npx cache answering /v1 with no version.
982
- {
983
- const pids = killListen(config.port);
984
- if (pids.length) {
985
- say(`openzoo: killed proxy on :${config.port} (pids ${pids.join(',')})`);
986
- await new Promise((r) => setTimeout(r, 400));
987
- }
988
- }
937
+ // SELF-HEAL A TAKEN PORT. Walk up to the next free port instead of dying;
938
+ // the caller reads config.port back out, so every URL printed afterwards is
939
+ // the one we actually bound. A healthy proxy already on the port is KILLED
940
+ // reusing it left a stale PayClient serving $0 after a TOKEN top-up.
941
+ const wanted = config.port;
989
942
  for (let attempt = 0; ; attempt++) {
990
943
  try {
991
944
  await new Promise((resolve, reject) => {
@@ -995,12 +948,20 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
995
948
  });
996
949
  break;
997
950
  } catch (e) {
998
- if (e?.code !== 'EADDRINUSE' || attempt >= 8) throw e;
999
- const pids = killListen(config.port);
1000
- if (pids.length) say(`openzoo: killed proxy on :${config.port} (pids ${pids.join(',')})`);
1001
- await new Promise((r) => setTimeout(r, 300));
951
+ if (e?.code !== 'EADDRINUSE' || attempt >= 12) throw e;
952
+ if (attempt === 0) {
953
+ const pids = killListen(config.port);
954
+ if (pids.length) {
955
+ say(`openzoo: killed proxy on :${config.port} (pids ${pids.join(',')})`);
956
+ await new Promise((r) => setTimeout(r, 400));
957
+ continue;
958
+ }
959
+ }
960
+ config.port += 1;
961
+ say(`openzoo: :${config.port - 1} busy — trying :${config.port}`);
1002
962
  }
1003
963
  }
964
+ if (config.port !== wanted) say(`openzoo: listening on :${config.port} (:${wanted} was busy)`);
1004
965
 
1005
966
  // AUTO-PREPAY. Paying on-chain per call is where the latency lives: credit
1006
967
  // is applied automatically server-side whenever a balance covers the quote,
package/lib/setup.js CHANGED
@@ -214,7 +214,7 @@ async function printStartupDiagnostic(base, which) {
214
214
 
215
215
  console.log('openzoo diagnostic');
216
216
  console.log(` version : ${version} node ${process.version} ${process.platform}/${process.arch}`);
217
- console.log(` port ${config.port} : ${portBusy ? 'occupied (steal unless this exact version)' : 'free'}`);
217
+ console.log(` port ${config.port} : ${portBusy ? 'ALREADY SERVING (an older proxy may still be running — kill it if this build is newer)' : 'free'}`);
218
218
  console.log(` editor : ${picked ? `${picked.which} @ ${picked.cmd}` : 'NONE FOUND'}`);
219
219
  console.log(` running : ${picked ? (q(() => editorRunning(picked.which), false) ? 'yes — will be quit so settings stick' : 'no') : '-'}`);
220
220
  console.log(` backend : ${hosts}`);
@@ -245,17 +245,9 @@ export async function setupEditor(which, target) {
245
245
  // Declared out here: the tunnel-rebind hook is registered further down, well
246
246
  // outside the block that starts the proxy.
247
247
  let started = null;
248
- const { startProxy, oursOn, packageVersion } = await import('./proxy.js');
249
- if (await oursOn(config.port)) {
250
- console.log(`proxy v${packageVersion()} already on ${base} keeping it`);
251
- try {
252
- const info = await (await fetch(`${base}/info`)).json();
253
- publicUrl = (info?.publicTunnel || '').replace(/\/v1$/, '') || null;
254
- tunnelKey = info?.tunnelToken ?? tunnelKey;
255
- if (publicUrl) console.log(`tunnel: ${publicUrl}/v1 (from the running proxy)`);
256
- } catch { /* no /info */ }
257
- } else {
258
- console.log(`${(await proxyUp(base)) ? 'stale proxy — stealing' : 'starting proxy on'} ${base} (+ public tunnel)...`);
248
+ if (!(await proxyUp(base))) {
249
+ console.log(`starting proxy on ${base} (+ public tunnel)...`);
250
+ const { startProxy } = await import('./proxy.js');
259
251
  started = await startProxy({ silent: true, autoTunnel: true });
260
252
  publicUrl = started?.publicUrl ?? null;
261
253
  tunnelKey = started?.tunnelToken ?? null;
@@ -306,6 +298,15 @@ export async function setupEditor(which, target) {
306
298
  console.log(' most common cause here: no working IPv6 route (we already force');
307
299
  console.log(' --edge-ip-version 4). check: npx openzoo tunnel for the raw log.');
308
300
  }
301
+ } else {
302
+ console.log(`proxy already running on ${base}`);
303
+ // A proxy someone else started owns the tunnel; ask it for the public URL.
304
+ try {
305
+ const info = await (await fetch(`${base}/info`)).json();
306
+ publicUrl = (info?.publicTunnel || '').replace(/\/v1$/, '') || null;
307
+ tunnelKey = info?.tunnelToken ?? tunnelKey;
308
+ if (publicUrl) console.log(`tunnel: ${publicUrl}/v1 (from the running proxy)`);
309
+ } catch { /* no /info — fall through to the localhost warning below */ }
309
310
  }
310
311
  // What the EDITOR is configured with. Localhost only as a last resort, and
311
312
  // said out loud, because it will fail with the private-networks error.
@@ -103,58 +103,8 @@ export function whopFundBlurb(solana) {
103
103
  ].join('\n');
104
104
  }
105
105
 
106
- /**
107
- * Genuine empty-wallet / fund-me copy. A post-pay settle failure
108
- * ("payment did not settle" with a gateway reason and no underfunded
109
- * wording) is NOT this — those wallets are often funded; the 402 is
110
- * the facilitator or upstream.
111
- */
112
- export function isFundInstruction(text, extra = {}) {
113
- const code = extra.code ?? extra.advice?.code;
114
- if (String(code || '') === 'insufficient_funds') return true;
115
- const s = String(text || '');
116
- if (!s) return false;
117
- if (/\b(?:wallet underfunded|empty wallet|wallet is empty|needs more than the wallet holds|insufficient[_\s]funds)\b/i.test(s)) return true;
118
- if (/\bunderfunded\b/i.test(s)) return true;
119
- if (/\bsend (?:usdc|a few cents)\b/i.test(s)) return true;
120
- if (/\bno offered payment row is affordable/i.test(s)) return true;
121
- return false;
122
- }
123
-
124
- function gatewayReason(q402) {
125
- if (!q402 || typeof q402 !== 'object') return '';
126
- const err = q402.error;
127
- const advice = q402.advice;
128
- if (typeof err?.message === 'string' && err.message.trim()) return err.message.trim();
129
- if (typeof err === 'string' && err.trim()) return err.trim();
130
- if (typeof advice?.message === 'string' && advice.message.trim()) return advice.message.trim();
131
- if (typeof advice === 'string' && advice.trim()) return advice.trim();
132
- if (advice && typeof advice === 'object') {
133
- const bits = [advice.code, advice.reason, advice.detail].filter((x) => typeof x === 'string' && x.trim());
134
- if (bits.length) return bits.join(': ');
135
- }
136
- return '';
137
- }
138
-
139
- /**
140
- * Copy for a 402 AFTER PayClient already signed and retried (paid:true).
141
- * Never "wallet underfunded" — that string is reserved for preflight
142
- * empty-wallet errors. Prefix stays greppable as "payment did not settle".
143
- */
144
- export function settleFailCopy(q402) {
145
- const reason = gatewayReason(q402);
146
- const code = q402?.advice?.code || q402?.error?.code || '';
147
- const fund = isFundInstruction(reason, { code, advice: q402?.advice });
148
- const message = reason
149
- ? `openzoo payment did not settle: ${reason}`
150
- : 'openzoo payment did not settle';
151
- const upstreamish = /upstream|facilitator|internal(?: server)? error|settle(?:ment)? (?:failed|error)/i.test(reason) && !fund;
152
- return { message, status: upstreamish ? 502 : 402, fund, reason, code: String(code || '') };
153
- }
154
-
155
106
  export async function withOnrampLink(text, dest) {
156
107
  const body = String(text || '').trim();
157
- if (!isFundInstruction(body, dest)) return body;
158
108
  const blurb = whopFundBlurb(dest?.solana);
159
109
  if (!blurb) return body;
160
110
  if (/ties to your account/i.test(body) && body.includes(String(dest.solana))) return body;
@@ -0,0 +1,31 @@
1
+ // Keyless web search for `openzoo ask --web`: DuckDuckGo's HTML endpoint,
2
+ // scraped for title / url / snippet. No API key, no account, one GET. The
3
+ // only thing that leaves is the question text, to duckduckgo.com.
4
+ const strip = (s) => String(s || '')
5
+ .replace(/<[^>]+>/g, '')
6
+ .replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#x27;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>')
7
+ .replace(/\s+/g, ' ').trim();
8
+
9
+ export async function webSearch(query, max = 5) {
10
+ const res = await fetch('https://html.duckduckgo.com/html/?q=' + encodeURIComponent(query), {
11
+ headers: { 'user-agent': 'Mozilla/5.0 openzoo-ask/1.0' },
12
+ signal: AbortSignal.timeout(12_000),
13
+ });
14
+ if (!res.ok) throw new Error(`duckduckgo HTTP ${res.status}`);
15
+ const html = await res.text();
16
+ const out = [];
17
+ const re = /<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g;
18
+ let m;
19
+ while ((m = re.exec(html)) && out.length < Math.max(1, Math.min(10, max))) {
20
+ let url = m[1];
21
+ const redirected = url.match(/uddg=([^&]+)/);
22
+ if (redirected) url = decodeURIComponent(redirected[1]);
23
+ out.push({ title: strip(m[2]), url, snippet: strip(m[3]).slice(0, 400) });
24
+ }
25
+ return out;
26
+ }
27
+
28
+ export function formatWebResults(query, hits) {
29
+ const lines = hits.map((h, i) => `${i + 1}. ${h.title} — ${h.url}\n ${h.snippet}`);
30
+ return `Web search results for "${query}" (DuckDuckGo, fetched just now; cite the url when you rely on one):\n${lines.join('\n')}`;
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.50.93",
3
+ "version": "0.50.94",
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",