openzoo 0.50.63 → 0.50.65

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.
@@ -55,6 +55,7 @@ import {
55
55
  } from './grokbotDesktop.js';
56
56
  import { startHostMcps, hostMcpTools, hostMcpHas, callHostMcp, hostMcpServers, chromeStatus, reattachChrome } from './mcpbridge.js';
57
57
  import * as ship from './ship.js';
58
+ import * as xclaims from './xclaims.js';
58
59
 
59
60
  const TLS_DIR = path.join(os.homedir(), '.openzoo', 'cursor-tls');
60
61
  const CURSOR_HOSTS = ['api2.cursor.sh', 'api3.cursor.sh', 'api4.cursor.sh', 'repo42.cursor.sh'];
@@ -1852,9 +1853,11 @@ const MODEL_ALIASES = {
1852
1853
  'abliterated-large': 'abliterated-model-large-v2',
1853
1854
  'abliterated-small': 'abliterated-model',
1854
1855
  };
1855
- /** Default zoo model for every bot without a /model override. DeepSeek v4 pro:
1856
- * tool-capable, ~$1/M in · $2/M out grok-4.6 burned $82 in one afternoon. */
1857
- export const DEFAULT_ZOO_MODEL = 'deepseek/deepseek-v4-pro';
1856
+ /** Default zoo model for every bot without a /model override. BARE `grok-4.6`
1857
+ * the bazaar (x402 upstream) row, not OpenRouter's `x-ai/grok-4.6`. The
1858
+ * gateway serves the bare id off an x402 door with an on-chain cogs receipt,
1859
+ * so an OpenRouter credit outage cannot take it down. */
1860
+ export const DEFAULT_ZOO_MODEL = 'grok-4.6';
1858
1861
  async function resolveModelId(raw) {
1859
1862
  const s = String(raw || '').trim();
1860
1863
  if (!s) return null;
@@ -2285,6 +2288,38 @@ const LOCAL_TOOLS = [
2285
2288
  },
2286
2289
  },
2287
2290
  },
2291
+ {
2292
+ type: 'function',
2293
+ function: {
2294
+ name: 'x_claim',
2295
+ description: 'X reply lock: claim a tweet (status URL or id) before drafting a reply. ok:false means another bot has it or it was already answered — pick a different tweet. Lease 20m.',
2296
+ parameters: { type: 'object', properties: { tweet: { type: 'string' } }, required: ['tweet'] },
2297
+ },
2298
+ },
2299
+ {
2300
+ type: 'function',
2301
+ function: {
2302
+ name: 'x_done',
2303
+ description: 'X reply lock: mark a claimed tweet as replied, permanently, with the URL of our reply. Also appends to openzoobot-posted.json.',
2304
+ parameters: { type: 'object', properties: { tweet: { type: 'string' }, url: { type: 'string' }, lane: { type: 'string' } }, required: ['tweet', 'url'] },
2305
+ },
2306
+ },
2307
+ {
2308
+ type: 'function',
2309
+ function: {
2310
+ name: 'x_release',
2311
+ description: 'X reply lock: give a claimed tweet back (you decided not to reply).',
2312
+ parameters: { type: 'object', properties: { tweet: { type: 'string' } }, required: ['tweet'] },
2313
+ },
2314
+ },
2315
+ {
2316
+ type: 'function',
2317
+ function: {
2318
+ name: 'x_claims',
2319
+ description: 'X reply lock: list tweet ids already answered plus live claims by other bots, so you can skip them before reading.',
2320
+ parameters: { type: 'object', properties: {} },
2321
+ },
2322
+ },
2288
2323
  ];
2289
2324
 
2290
2325
  export const LOCAL_TOOL_NAMES = LOCAL_TOOLS.map((t) => t.function.name);
@@ -2484,6 +2519,13 @@ async function runLocalTool(name, args, log, ctx = {}) {
2484
2519
  if (name.startsWith('ship_')) {
2485
2520
  return await runShipTool(name, args, log, ctx);
2486
2521
  }
2522
+ if (name.startsWith('x_')) {
2523
+ const me = findAgent(ctx.agentId) || { id: String(ctx.agentId || ''), name: '' };
2524
+ if (name === 'x_claim') return JSON.stringify(xclaims.claimTweet({ tweet: args.tweet || args.url || args.id, by: me.id, name: me.name }));
2525
+ if (name === 'x_done') return JSON.stringify(xclaims.markDone({ tweet: args.tweet || args.id, by: me.id, name: me.name, url: args.url, lane: args.lane }));
2526
+ if (name === 'x_release') return JSON.stringify(xclaims.releaseTweet({ tweet: args.tweet || args.id, by: me.id }));
2527
+ if (name === 'x_claims') return JSON.stringify(xclaims.listClaims());
2528
+ }
2487
2529
  if (hostMcpHas(name)) {
2488
2530
  return await callHostMcp(name, args);
2489
2531
  }
@@ -2643,7 +2685,8 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
2643
2685
  return brief ? `${who} Standing brief (persisted): ${brief}` : who;
2644
2686
  })(),
2645
2687
  `You HAVE local tools on the user's computer via ${via}.`,
2646
- 'Tools: read_file, write_file, exec, list_dir, screenshot, click, type_text, key, ui_tree, focus_app, open_url, create_agent, set_brief, list_agents, message_agent, schedule_wakeup, cancel_wakeup, ship_crew, ship_forge, ship_launch_worker, ship_status, ship_review, ship_open_pr.',
2688
+ 'Tools: read_file, write_file, exec, list_dir, screenshot, click, type_text, key, ui_tree, focus_app, open_url, create_agent, set_brief, list_agents, message_agent, schedule_wakeup, cancel_wakeup, ship_crew, ship_forge, ship_launch_worker, ship_status, ship_review, ship_open_pr, x_claim, x_done, x_release, x_claims.',
2689
+ 'X reply bots: x_claims first (skip those ids), x_claim the tweet BEFORE drafting (ok:false -> pick another), x_done with our reply URL after posting, x_release if you back out. Never reply to a tweet you did not claim.',
2647
2690
  'If no sidebar bot is named Firstmate and the human brings code or repo work, ask ONE question: which repo path to set up Grok Ship for. Then call ship_crew with that cwd. Do not start coding outside the factory.',
2648
2691
  'Grok Ship (software factory): ship_crew sets up Firstmate + a crewmate per repo. A crewmate ships with ship_launch_worker -> ship_status until pushed -> ship_review (fresh, diff-only) -> ship_open_pr only when gate.clean. Never merge; the human does.',
2649
2692
  (() => {
@@ -2654,10 +2697,11 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
2654
2697
  return 'Host MCP servers (Claude/Grok chrome-devtools, brave, …) are connecting. When chrome-devtools__* tools appear, use them for web pages.';
2655
2698
  }
2656
2699
  const chrome = names.filter((n) => /chrome|devtools|browser/i.test(n));
2700
+ const brave = names.some((n) => n.startsWith('brave-devtools__'));
2657
2701
  return [
2658
2702
  `Host MCP tools from the user's local Claude/Grok config are attached (${servers.join(', ') || 'mcp'}): ${names.slice(0, 36).join(', ')}${names.length > 36 ? '…' : ''}.`,
2659
2703
  chrome.length
2660
- ? `For any web page or HTML form, use chrome-devtools tools (navigate_page / take_snapshot / fill / click). Do NOT use osascript, Quartz, Python Foundation, or AppleScript to read Brave. screenshot/click/type_text are for native Mac UI only. On heavy pages (x.com, gmail, big SPAs) take_snapshot can hang: prefer evaluate_script that returns only the text you need, or take_screenshot; a tool that times out says so — do not repeat it. Do not re-run schedule_wakeup or list_agents every turn; once is enough. Browser mode: ${chromeStatus().mode}.${chromeStatus().hint ? ` This Chrome is NOT the human's browser: a separate profile with no logins. If the human asks for their logged-in Chrome / Google / X / "the same chrome", do NOT open pages in this one and do NOT spawn anything — reply with exactly this and stop: ${chromeStatus().hint}` : ' This is the human\'s real logged-in browser; do not log out or change its settings.'}`
2704
+ ? `For any web page or HTML form, use chrome-devtools tools (navigate_page / take_snapshot / fill / click). Do NOT use osascript, Quartz, Python Foundation, or AppleScript to read Brave. screenshot/click/type_text are for native Mac UI only. On heavy pages (x.com, gmail, big SPAs) take_snapshot can hang: prefer evaluate_script that returns only the text you need, or take_screenshot; a tool that times out says so — do not repeat it. Do not re-run schedule_wakeup or list_agents every turn; once is enough. Browser mode: ${chromeStatus().mode}.${brave ? ' A SECOND real browser is attached: brave-devtools__* tools drive the human\'s logged-in Brave the same way; use whichever your brief names.' : ''}${chromeStatus().hint ? ` This Chrome is NOT the human's browser: a separate profile with no logins. If the human asks for their logged-in Chrome / Google / X / "the same chrome", do NOT open pages in this one and do NOT spawn anything — reply with exactly this and stop: ${chromeStatus().hint}` : ' This is the human\'s real logged-in browser; do not log out or change its settings.'}`
2661
2705
  : 'Use matching MCP tools instead of inventing shell one-liners.',
2662
2706
  ].join(' ');
2663
2707
  })(),
package/lib/mcpbridge.js CHANGED
@@ -22,6 +22,7 @@ let started = null;
22
22
  let serversUp = [];
23
23
  let chromeCfg = null;
24
24
  let lastReattachCheck = 0;
25
+ let lastBraveCheck = 0;
25
26
 
26
27
  export function hostMcpTools() {
27
28
  return openaiTools;
@@ -208,6 +209,19 @@ export function loadHostMcpConfigs(home = os.homedir()) {
208
209
  url: '',
209
210
  });
210
211
  }
212
+ // BRAVE AS A SECOND BROWSER. When Brave has flipped brave://inspect
213
+ // remote debugging it writes DevToolsActivePort like Chrome does; a second
214
+ // chrome-devtools-mcp attaches to it by --browserUrl and its tools land as
215
+ // brave-devtools__*. Half the X bots drive Brave, half drive Chrome.
216
+ const bravePort = readActivePort(path.join(home, 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser'));
217
+ if (bravePort && !seen.has('brave-devtools')) {
218
+ out.push({
219
+ name: 'brave-devtools',
220
+ command: 'npx',
221
+ args: ['-y', 'chrome-devtools-mcp@latest', '--browserUrl', `http://127.0.0.1:${bravePort}`],
222
+ url: '',
223
+ });
224
+ }
211
225
  return out;
212
226
  }
213
227
 
@@ -273,12 +287,12 @@ function readActivePort(dir) {
273
287
  }
274
288
 
275
289
  /** Pure: decide the chrome-devtools-mcp argv from what is observable. */
276
- export function chromeArgsFor(baseArgs, { chromePort = 0, bravePort = 0, openPorts = [] } = {}) {
290
+ export function chromeArgsFor(baseArgs, { chromePort = 0, bravePort = 0, openPorts = [], hasOwnBraveServer = false } = {}) {
277
291
  const args = [...(baseArgs || ['-y', 'chrome-devtools-mcp@latest'])];
278
292
  const has = (flag) => args.some((a) => String(a) === flag || String(a).startsWith(`${flag}=`));
279
293
  if (has('--browserUrl') || has('--autoConnect') || has('--wsEndpoint')) return { args, mode: 'explicit', detail: '' };
280
294
  if (chromePort) { args.push('--autoConnect'); return { args, mode: 'real-chrome', detail: `Chrome DevToolsActivePort ${chromePort}` }; }
281
- if (bravePort) { args.push('--browserUrl', `http://127.0.0.1:${bravePort}`); return { args, mode: 'real-brave', detail: `Brave DevToolsActivePort ${bravePort}` }; }
295
+ if (bravePort && !hasOwnBraveServer) { args.push('--browserUrl', `http://127.0.0.1:${bravePort}`); return { args, mode: 'real-brave', detail: `Brave DevToolsActivePort ${bravePort}` }; }
282
296
  const port = openPorts.find((n) => n === 9222 || n === 9333);
283
297
  if (port) { args.push('--browserUrl', `http://127.0.0.1:${port}`); return { args, mode: `attached:${port}`, detail: `browser listening on ${port}` }; }
284
298
  return { args, mode: 'own-profile', detail: '~/.cache/chrome-devtools-mcp/chrome-profile' };
@@ -293,7 +307,7 @@ async function chromeArgs(baseArgs, home = os.homedir()) {
293
307
  if (bravePort && !(await portOpen(bravePort))) bravePort = 0;
294
308
  const openPorts = [];
295
309
  for (const port of [9222, 9333]) if (await portOpen(port)) openPorts.push(port);
296
- const got = chromeArgsFor(baseArgs, { chromePort, bravePort, openPorts });
310
+ const got = chromeArgsFor(baseArgs, { chromePort, bravePort, openPorts, hasOwnBraveServer: true });
297
311
  chromeMode = { mode: got.mode, detail: got.detail };
298
312
  return got.args;
299
313
  }
@@ -345,8 +359,33 @@ async function connectOne(cfg, log) {
345
359
  * chrome-devtools for one attached to their real browser, no restart.
346
360
  * Cheap (two stats) — called at the top of every zoo turn, throttled 5s.
347
361
  */
362
+ async function ensureBraveServer(log, home) {
363
+ if (!started || serversUp.includes('brave-devtools')) return false;
364
+ const bravePort = readActivePort(path.join(home, 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser'));
365
+ if (!bravePort || !(await portOpen(bravePort))) return false;
366
+ const cfg = { name: 'brave-devtools', command: 'npx', args: ['-y', 'chrome-devtools-mcp@latest', '--browserUrl', `http://127.0.0.1:${bravePort}`], url: '' };
367
+ log(`cursor-backend: mcp brave-devtools Brave appeared on ${bravePort} — attaching`);
368
+ try {
369
+ const got = await connectOne(cfg, log);
370
+ serversUp.push(cfg.name);
371
+ for (const tool of got.tools) {
372
+ registry.set(toolOpenaiName(cfg.name, tool.name), {
373
+ client: got.client, server: cfg.name, tool: tool.name, description: tool.description || tool.name, schema: tool.inputSchema,
374
+ });
375
+ }
376
+ rebuildOpenai();
377
+ log(`cursor-backend: mcp brave-devtools attached tools=${got.tools.length}`);
378
+ return true;
379
+ } catch (e) {
380
+ log(`cursor-backend: mcp brave-devtools attach FAIL ${e.message}`);
381
+ return false;
382
+ }
383
+ }
384
+
348
385
  export async function reattachChrome({ log = () => {}, home = os.homedir() } = {}) {
349
386
  if (!chromeCfg || !started) return false;
387
+ const now0 = Date.now();
388
+ if (now0 - lastBraveCheck > 5000) { lastBraveCheck = now0; await ensureBraveServer(log, home); }
350
389
  if (/^(real-chrome|real-brave|explicit)$/.test(chromeMode.mode)) return false;
351
390
  const now = Date.now();
352
391
  if (now - lastReattachCheck < 5000) return false;
@@ -395,9 +434,22 @@ function rebuildOpenai() {
395
434
  * hangs must come back as an error string the model can route around.
396
435
  */
397
436
  export const MCP_CALL_TIMEOUT_MS = Math.max(10_000, Number(process.env.OZ_MCP_CALL_TIMEOUT_MS || 75_000));
437
+ /** One browser, many bots: chrome-devtools calls run one at a time so two
438
+ * turns cannot interleave select_page / click on the same Chrome. */
439
+ const browserChains = new Map(); // server name -> promise chain (one per browser)
398
440
  export async function callHostMcp(name, args, { timeoutMs = MCP_CALL_TIMEOUT_MS } = {}) {
399
441
  const rec = registry.get(String(name || ''));
400
442
  if (!rec) throw new Error(`no mcp tool ${name}`);
443
+ if (/chrome|devtools|browser/i.test(rec.server)) {
444
+ const run = () => callHostMcpNow(name, rec, args, timeoutMs);
445
+ const prev = browserChains.get(rec.server) || Promise.resolve();
446
+ const p = prev.then(run, run);
447
+ browserChains.set(rec.server, p.catch(() => {}));
448
+ return p;
449
+ }
450
+ return callHostMcpNow(name, rec, args, timeoutMs);
451
+ }
452
+ async function callHostMcpNow(name, rec, args, timeoutMs) {
401
453
  let timer;
402
454
  const ceiling = new Promise((_, rej) => {
403
455
  timer = setTimeout(() => rej(new Error(`MCP tool ${name} timed out after ${Math.round(timeoutMs / 1000)}s. The page may be too heavy for that action. Use a narrower one: evaluate_script to pull the text you need, take_screenshot, or navigate_page to a lighter URL. Do not retry the same call.`)), timeoutMs);
package/lib/xclaims.js ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Shared tweet-id locks for the X reply bots.
3
+ *
4
+ * Thirteen bots search thirteen lanes, and lanes overlap. Before a bot spends
5
+ * a turn drafting a reply it CLAIMS the tweet id here; the claim is atomic
6
+ * on the host (one process, synchronous file write), leased for CLAIM_TTL_MS
7
+ * so a bot that dies mid-reply frees the tweet, and `done` makes it permanent.
8
+ *
9
+ * File: ~/.openzoo/xclaims.json { [tweetId]: { by, name, at, until, done, url } }
10
+ * `done` entries are also appended to the human's ledger
11
+ * (~/openzoo-shim/openzoobot-posted.json) so the old "never reply twice" file
12
+ * keeps working for anything that reads it.
13
+ */
14
+ import fs from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+
18
+ export const CLAIM_TTL_MS = Number(process.env.OZ_XCLAIM_TTL_MS || 20 * 60_000);
19
+
20
+ export function claimsPath(home = os.homedir()) {
21
+ return path.join(home, '.openzoo', 'xclaims.json');
22
+ }
23
+
24
+ export function tweetIdFrom(input) {
25
+ const s = String(input || '').trim();
26
+ const m = s.match(/status(?:es)?\/(\d{8,25})/) || s.match(/^(\d{8,25})$/);
27
+ return m ? m[1] : '';
28
+ }
29
+
30
+ export function loadClaims(home = os.homedir()) {
31
+ try {
32
+ const j = JSON.parse(fs.readFileSync(claimsPath(home), 'utf8'));
33
+ return j && typeof j === 'object' && !Array.isArray(j) ? j : {};
34
+ } catch {
35
+ return {};
36
+ }
37
+ }
38
+
39
+ export function saveClaims(home, claims) {
40
+ const p = claimsPath(home);
41
+ fs.mkdirSync(path.dirname(p), { recursive: true });
42
+ const tmp = `${p}.tmp`;
43
+ fs.writeFileSync(tmp, JSON.stringify(claims, null, 2));
44
+ fs.renameSync(tmp, p);
45
+ return claims;
46
+ }
47
+
48
+ /** Atomic on the host: returns {ok:true} once per live lease, else who holds it. */
49
+ export function claimTweet({ tweet, by, name = '', home = os.homedir(), now = Date.now(), ttlMs = CLAIM_TTL_MS } = {}) {
50
+ const id = tweetIdFrom(tweet);
51
+ if (!id) return { ok: false, error: 'no tweet id (pass the status URL or numeric id)' };
52
+ const claims = loadClaims(home);
53
+ const cur = claims[id];
54
+ if (cur?.done) return { ok: false, id, reason: 'already replied', by: cur.name || cur.by, url: cur.url || '' };
55
+ if (cur && cur.by !== by && Number(cur.until) > now) {
56
+ return { ok: false, id, reason: 'claimed by another bot', by: cur.name || cur.by, until: cur.until };
57
+ }
58
+ claims[id] = { by: String(by || ''), name: String(name || ''), at: now, until: now + ttlMs, done: false, url: '' };
59
+ saveClaims(home, claims);
60
+ return { ok: true, id, until: claims[id].until, renewed: !!cur };
61
+ }
62
+
63
+ export function releaseTweet({ tweet, by, home = os.homedir() } = {}) {
64
+ const id = tweetIdFrom(tweet);
65
+ const claims = loadClaims(home);
66
+ const cur = claims[id];
67
+ if (!cur) return { ok: true, id, released: false };
68
+ if (cur.done) return { ok: false, id, reason: 'already replied' };
69
+ if (cur.by !== by) return { ok: false, id, reason: 'not your claim', by: cur.name || cur.by };
70
+ delete claims[id];
71
+ saveClaims(home, claims);
72
+ return { ok: true, id, released: true };
73
+ }
74
+
75
+ /** Permanent. Also appends to the human's ledger so old readers still see it. */
76
+ export function markDone({ tweet, by, name = '', url = '', lane = '', home = os.homedir(), ledgerPath, now = Date.now() } = {}) {
77
+ const id = tweetIdFrom(tweet);
78
+ if (!id) return { ok: false, error: 'no tweet id' };
79
+ const claims = loadClaims(home);
80
+ const cur = claims[id];
81
+ if (cur?.done && cur.by !== by) return { ok: false, id, reason: 'already replied by another bot', by: cur.name || cur.by };
82
+ claims[id] = { by: String(by || ''), name: String(name || cur?.name || ''), at: cur?.at || now, until: 0, done: true, url: String(url || ''), doneAt: now };
83
+ saveClaims(home, claims);
84
+ const ledger = ledgerPath || path.join(home, 'openzoo-shim', 'openzoobot-posted.json');
85
+ try {
86
+ let j = { posted: [] };
87
+ try { j = JSON.parse(fs.readFileSync(ledger, 'utf8')); } catch { /* fresh */ }
88
+ if (!Array.isArray(j.posted)) j.posted = [];
89
+ j.posted.push({ tweet: `https://x.com/i/status/${id}`, ours: String(url || ''), lane: String(lane || ''), by: String(name || by || ''), at: new Date(now).toISOString() });
90
+ fs.mkdirSync(path.dirname(ledger), { recursive: true });
91
+ fs.writeFileSync(ledger, JSON.stringify(j, null, 2));
92
+ } catch { /* the lock file is the source of truth; the ledger is a courtesy */ }
93
+ return { ok: true, id, url: String(url || '') };
94
+ }
95
+
96
+ /** Everything a bot needs to skip fast: done ids + live claims by others. */
97
+ export function listClaims({ home = os.homedir(), now = Date.now() } = {}) {
98
+ const claims = loadClaims(home);
99
+ const done = [];
100
+ const live = [];
101
+ for (const [id, c] of Object.entries(claims)) {
102
+ if (c.done) done.push(id);
103
+ else if (Number(c.until) > now) live.push({ id, by: c.name || c.by, until: c.until });
104
+ }
105
+ return { done, live, total: Object.keys(claims).length };
106
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.50.63",
3
+ "version": "0.50.65",
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",