openzoo 0.50.64 → 0.50.66
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 +70 -2
- package/lib/mcpbridge.js +55 -3
- package/lib/xclaims.js +124 -0
- package/package.json +1 -1
package/lib/cursorbackend.js
CHANGED
|
@@ -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'];
|
|
@@ -2287,6 +2288,50 @@ const LOCAL_TOOLS = [
|
|
|
2287
2288
|
},
|
|
2288
2289
|
},
|
|
2289
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
|
+
},
|
|
2323
|
+
{
|
|
2324
|
+
type: 'function',
|
|
2325
|
+
function: {
|
|
2326
|
+
name: 'x_compose',
|
|
2327
|
+
description: 'Type reply text into the X composer on the current page of the given browser, LINE BY LINE with real Enter keys so newlines survive (fill flattens them). Click/focus the reply box first (or open the tweet). Then click the Reply button yourself. browser: chrome or brave.',
|
|
2328
|
+
parameters: {
|
|
2329
|
+
type: 'object',
|
|
2330
|
+
properties: { browser: { type: 'string', enum: ['chrome', 'brave'] }, text: { type: 'string' } },
|
|
2331
|
+
required: ['browser', 'text'],
|
|
2332
|
+
},
|
|
2333
|
+
},
|
|
2334
|
+
},
|
|
2290
2335
|
];
|
|
2291
2336
|
|
|
2292
2337
|
export const LOCAL_TOOL_NAMES = LOCAL_TOOLS.map((t) => t.function.name);
|
|
@@ -2486,6 +2531,26 @@ async function runLocalTool(name, args, log, ctx = {}) {
|
|
|
2486
2531
|
if (name.startsWith('ship_')) {
|
|
2487
2532
|
return await runShipTool(name, args, log, ctx);
|
|
2488
2533
|
}
|
|
2534
|
+
if (name.startsWith('x_')) {
|
|
2535
|
+
const me = findAgent(ctx.agentId) || { id: String(ctx.agentId || ''), name: '' };
|
|
2536
|
+
if (name === 'x_claim') return JSON.stringify(xclaims.claimTweet({ tweet: args.tweet || args.url || args.id, by: me.id, name: me.name }));
|
|
2537
|
+
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 }));
|
|
2538
|
+
if (name === 'x_release') return JSON.stringify(xclaims.releaseTweet({ tweet: args.tweet || args.id, by: me.id }));
|
|
2539
|
+
if (name === 'x_claims') return JSON.stringify(xclaims.listClaims());
|
|
2540
|
+
if (name === 'x_compose') {
|
|
2541
|
+
const server = String(args.browser || 'chrome').toLowerCase() === 'brave' ? 'brave-devtools' : 'chrome-devtools';
|
|
2542
|
+
const plan = xclaims.composePlan(args.text);
|
|
2543
|
+
let last = '';
|
|
2544
|
+
for (const op of plan.ops) {
|
|
2545
|
+
const tname = `${server}__${op.tool}`;
|
|
2546
|
+
if (!hostMcpHas(tname)) return JSON.stringify({ ok: false, error: `${tname} not attached` });
|
|
2547
|
+
last = String(await callHostMcp(tname, op.args));
|
|
2548
|
+
if (op.tool === 'evaluate_script' && /"ok":\s*false/.test(last)) return JSON.stringify({ ok: false, error: last.slice(0, 300) });
|
|
2549
|
+
}
|
|
2550
|
+
log(`cursor-backend: x_compose ${server} lines=${plan.lines}`);
|
|
2551
|
+
return JSON.stringify({ ok: true, browser: server, linesTyped: plan.lines, composer: last.slice(0, 700), next: 'verify the composer text above shows separate lines, then click the Reply button' });
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2489
2554
|
if (hostMcpHas(name)) {
|
|
2490
2555
|
return await callHostMcp(name, args);
|
|
2491
2556
|
}
|
|
@@ -2645,7 +2710,9 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
|
|
|
2645
2710
|
return brief ? `${who} Standing brief (persisted): ${brief}` : who;
|
|
2646
2711
|
})(),
|
|
2647
2712
|
`You HAVE local tools on the user's computer via ${via}.`,
|
|
2648
|
-
'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.',
|
|
2713
|
+
'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, x_compose.',
|
|
2714
|
+
'Posting on X: NEVER fill the reply box (newlines get flattened). Open the tweet, click the reply box, then x_compose {browser, text} which types line by line with Enter, then click Reply.',
|
|
2715
|
+
'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.',
|
|
2649
2716
|
'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.',
|
|
2650
2717
|
'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.',
|
|
2651
2718
|
(() => {
|
|
@@ -2656,10 +2723,11 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
|
|
|
2656
2723
|
return 'Host MCP servers (Claude/Grok chrome-devtools, brave, …) are connecting. When chrome-devtools__* tools appear, use them for web pages.';
|
|
2657
2724
|
}
|
|
2658
2725
|
const chrome = names.filter((n) => /chrome|devtools|browser/i.test(n));
|
|
2726
|
+
const brave = names.some((n) => n.startsWith('brave-devtools__'));
|
|
2659
2727
|
return [
|
|
2660
2728
|
`Host MCP tools from the user's local Claude/Grok config are attached (${servers.join(', ') || 'mcp'}): ${names.slice(0, 36).join(', ')}${names.length > 36 ? '…' : ''}.`,
|
|
2661
2729
|
chrome.length
|
|
2662
|
-
? `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.'}`
|
|
2730
|
+
? `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.'}`
|
|
2663
2731
|
: 'Use matching MCP tools instead of inventing shell one-liners.',
|
|
2664
2732
|
].join(' ');
|
|
2665
2733
|
})(),
|
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,124 @@
|
|
|
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
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* X's composer is a Draft-style contenteditable: one `fill` flattens every
|
|
110
|
+
* newline (measured: "openzoo.fun/core" + "x402 · PAID…" fused into
|
|
111
|
+
* "corex402"). Real keystrokes work: type a line, press Enter, type the next.
|
|
112
|
+
* Pure planner — the host executes these against chrome-devtools / brave-devtools.
|
|
113
|
+
*/
|
|
114
|
+
export const X_COMPOSER_SELECTOR = '[data-testid="tweetTextarea_0"], [data-testid="tweetTextarea_0RichTextInputContainer"] [contenteditable="true"], div[role="textbox"][contenteditable="true"]';
|
|
115
|
+
export function composePlan(text) {
|
|
116
|
+
const lines = String(text || '').replace(/\r\n?/g, '\n').split('\n');
|
|
117
|
+
const ops = [{ tool: 'evaluate_script', args: { function: `() => { const el = document.querySelector(${JSON.stringify(X_COMPOSER_SELECTOR)}); if (!el) return { ok: false, error: 'no composer on this page' }; el.focus(); return { ok: true }; }` } }];
|
|
118
|
+
lines.forEach((line, i) => {
|
|
119
|
+
if (i) ops.push({ tool: 'press_key', args: { key: 'Enter' } });
|
|
120
|
+
if (line) ops.push({ tool: 'type_text', args: { text: line } });
|
|
121
|
+
});
|
|
122
|
+
ops.push({ tool: 'evaluate_script', args: { function: `() => { const el = document.querySelector(${JSON.stringify(X_COMPOSER_SELECTOR)}); const t = el ? (el.innerText || el.textContent || '') : ''; return { ok: !!el, lines: t.split('\\n').filter(Boolean).length, text: t.slice(0, 600) }; }` } });
|
|
123
|
+
return { lines: lines.length, ops };
|
|
124
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.50.
|
|
3
|
+
"version": "0.50.66",
|
|
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",
|