openzoo 0.50.77 → 0.50.79

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/README.md CHANGED
@@ -25,6 +25,10 @@ paid $0.002137 (9.5× cheaper than direct) · rail solana · tx 5Kd…
25
25
 
26
26
  **Claude Code / grokui Auto** — `openzoo claude` sets the Anthropic API key + base URL to the local OpenZoo proxy (x402 pay-per-call). You do **not** need to authenticate Claude first. grokui orange Auto is this harness, not a `RUN:` text parser. PATH `~/.local/bin` is required on Mac so `claude` is found.
27
27
 
28
+ **Agent of Empires** (tmux session manager for coding agents, [agent-of-empires](https://github.com/agent-of-empires/agent-of-empires)) — `openzoo aoe` registers an `openzoo` agent in aoe's `config.toml` (`[session.custom_agents] openzoo = "openzoo claude"`, detected as `claude` so status hooks, resume and fork all work), starts a background proxy if none is listening (no public tunnel unless `--tunnel`), then runs `aoe`. `openzoo aoe add . -l` creates and launches one session in the current directory; in the TUI pick **openzoo** from the agent picker. `Alt+z` opens a tool session with the wallet balance and the live receipts log. Terminal view only: aoe's structured view runs `claude-agent-acp` itself, which bills Anthropic, not the zoo. `--override-claude` additionally makes aoe's built-in `claude` agent launch through openzoo; `--no-launch` writes config only.
29
+
30
+ **Orca** ([stablyai/orca](https://github.com/stablyai/orca), the desktop orchestrator for parallel agents) — Orca launches any CLI agent in a worktree terminal, so type `openzoo claude` as the agent command (or pick **openzoo** from the agent picker once the upstream catalog entry lands). Orca treats it as Claude Code: same process, same hooks and status dots, same `--dangerously-skip-permissions` default. No account switching applies, because nothing is logged in; every turn pays x402.
31
+
28
32
  `GET /v1/models` is the live OpenRouter catalog **after** dropping `:batch`, `$0` / missing prices, and `openzoo-*` twins. Claude Code's `/model` picker (Anthropic-shaped GET) is a short list: current Opus/Sonnet/Haiku-class + a few real gateway models + `openzoo/auto`.
29
33
 
30
34
  Mac:
package/bin/openzoo.js CHANGED
@@ -142,6 +142,15 @@ usage:
142
142
  path hard-codes $0.00 and ignores /v1/models pricing)
143
143
  --all (whole catalog) --models a,b (exact ids)
144
144
  --default <id> (set the agents' primary model)
145
+ npx openzoo aoe [aoe args] Agent of Empires (tmux session manager for coding
146
+ agents): registers "openzoo" as an agent in aoe's
147
+ config.toml (= Claude Code on the zoo, detected as
148
+ claude so status/resume work), starts a background
149
+ proxy if none is up, then runs aoe.
150
+ aoe add . -l one session in this dir, launched
151
+ --no-launch (config only) --no-proxy --tunnel
152
+ --override-claude (aoe's built-in claude pays via
153
+ the zoo too) --no-receipts (skip the Alt+z tool)
145
154
  npx openzoo mcp stdio MCP server (tools: zoo_ask, zoo_bind, zoo_models, zoo_wallet, zoo_contexts)
146
155
  npx openzoo unblock restore the editor's own backend in the hosts file
147
156
  npx openzoo tunnel public-url-only mode (everything key-gated, no keyless localhost)
@@ -201,6 +210,9 @@ async function main() {
201
210
  case 'openclaw':
202
211
  await (await import('../lib/openclaw.js')).setupOpenClaw(process.argv.slice(3));
203
212
  break;
213
+ case 'aoe':
214
+ await (await import('../lib/aoe.js')).setupAoe(process.argv.slice(3));
215
+ break;
204
216
  case 'voice':
205
217
  await (await import('../lib/voice.js')).runVoice(process.argv.slice(3));
206
218
  break;
package/lib/aoe.js ADDED
@@ -0,0 +1,314 @@
1
+ /**
2
+ * `npx openzoo aoe [aoe args...]` — register openzoo as an agent in Agent of
3
+ * Empires (https://github.com/agent-of-empires/agent-of-empires), make sure a
4
+ * proxy is up for the sessions it will spawn, then hand off to `aoe`.
5
+ *
6
+ * AoE runs each coding agent in its own tmux session and picks the agent from
7
+ * a registry it ships (`aoe agents`). openzoo is not in that registry, but AoE
8
+ * has a first-class hook for exactly this shape: `[session.custom_agents]`
9
+ * names a command, and `[session.agent_detect_as]` tells AoE which built-in
10
+ * the command wraps so status hooks, resume flags and the structured-view
11
+ * adapter are inherited. `openzoo claude` IS Claude Code (it spawns the real
12
+ * CLI with ANTHROPIC_BASE_URL pointed at the local proxy and forwards every
13
+ * flag untouched), so `openzoo = "openzoo claude"` + `openzoo = "claude"` is
14
+ * the whole integration: `aoe add --tool openzoo` launches a Claude Code
15
+ * session that pays per turn over x402 with no API key and no account.
16
+ *
17
+ * Everything written is a surgical edit of the user's config.toml: keys are
18
+ * inserted into the tables they belong to (header form or inline form,
19
+ * whichever the file already uses), never a second `[session.custom_agents]`
20
+ * header, which TOML rejects as a duplicate table and would take the whole
21
+ * config down with it. Re-running is idempotent.
22
+ */
23
+ import { spawn, spawnSync } from 'node:child_process';
24
+ import fs from 'node:fs';
25
+ import os from 'node:os';
26
+ import path from 'node:path';
27
+ import { config } from './config.js';
28
+
29
+ export const AGENT_NAME = 'openzoo';
30
+ export const TOOL_NAME = 'openzoo';
31
+
32
+ /**
33
+ * Where AoE reads its global config.toml. Mirrors `get_app_dir_path` in
34
+ * agent-of-empires/src/session/mod.rs: Linux is always XDG; macOS prefers an
35
+ * existing XDG dir, then an existing legacy `~/.agent-of-empires`, then XDG
36
+ * only when `XDG_CONFIG_HOME` is set explicitly; everything else is legacy.
37
+ * OPENZOO_AOE_CONFIG overrides outright (tests, unusual layouts).
38
+ */
39
+ export function aoeConfigPath({
40
+ env = process.env, home = os.homedir(), platform = process.platform, exists = fs.existsSync,
41
+ } = {}) {
42
+ if (env.OPENZOO_AOE_CONFIG) return env.OPENZOO_AOE_CONFIG;
43
+ const xdg = path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'agent-of-empires');
44
+ const legacy = path.join(home, '.agent-of-empires');
45
+ let dir;
46
+ if (platform === 'linux') dir = xdg;
47
+ else if (platform === 'darwin') {
48
+ if (exists(xdg)) dir = xdg;
49
+ else if (exists(legacy)) dir = legacy;
50
+ else dir = env.XDG_CONFIG_HOME ? xdg : legacy;
51
+ } else dir = legacy;
52
+ return path.join(dir, 'config.toml');
53
+ }
54
+
55
+ /**
56
+ * The command AoE will put in a tmux pane. `openzoo` on PATH when it is
57
+ * (global install); otherwise the exact node + script that is running right
58
+ * now, so `npx openzoo aoe` users get a session that launches instead of a
59
+ * "command not found" pane. AoE verifies the first word is on PATH before it
60
+ * creates a session, which is also why an absolute path beats `npx openzoo`.
61
+ */
62
+ export function openzooLaunchCommand({ env = process.env, execPath = process.execPath, script = process.argv[1] } = {}) {
63
+ const name = 'openzoo' + (process.platform === 'win32' ? '.cmd' : '');
64
+ for (const dir of String(env.PATH || '').split(path.delimiter)) {
65
+ if (!dir) continue;
66
+ try { fs.accessSync(path.join(dir, name), fs.constants.X_OK); return 'openzoo'; } catch { /* next */ }
67
+ }
68
+ return `${shQuote(execPath)} ${shQuote(script)}`;
69
+ }
70
+
71
+ /** Quote one argv word for the shell AoE hands the command to. */
72
+ export function shQuote(s) {
73
+ return /^[A-Za-z0-9_/.:@%+=-]+$/.test(s) ? s : `'${String(s).replace(/'/g, "'\\''")}'`;
74
+ }
75
+
76
+ /** TOML basic string. JSON's escapes are a subset of TOML's for the characters
77
+ * a path or command can contain, so this is exact, not approximate. */
78
+ export function tomlString(s) {
79
+ return JSON.stringify(String(s));
80
+ }
81
+
82
+ function keyMatches(rawKey, key) {
83
+ const k = rawKey.trim();
84
+ return k === key || k === `"${key}"` || k === `'${key}'`;
85
+ }
86
+
87
+ /** Split an inline-table body on commas that are not inside a string. */
88
+ function splitInline(body) {
89
+ const parts = [];
90
+ let cur = '';
91
+ let quote = null;
92
+ for (let i = 0; i < body.length; i++) {
93
+ const ch = body[i];
94
+ if (quote) {
95
+ cur += ch;
96
+ if (ch === '\\' && quote === '"') { cur += body[++i] ?? ''; continue; }
97
+ if (ch === quote) quote = null;
98
+ continue;
99
+ }
100
+ if (ch === '"' || ch === "'") { quote = ch; cur += ch; continue; }
101
+ if (ch === ',') { parts.push(cur); cur = ''; continue; }
102
+ cur += ch;
103
+ }
104
+ if (cur.trim()) parts.push(cur);
105
+ return parts.map((p) => p.trim()).filter(Boolean);
106
+ }
107
+
108
+ /**
109
+ * Set `key = value` inside TOML table `table` (dotted, e.g.
110
+ * `session.custom_agents`), replacing any existing value for that key.
111
+ * Pure: string in, string out.
112
+ *
113
+ * Handles the two spellings a hand-edited config uses:
114
+ * [session.custom_agents] header form → key inserted under it
115
+ * [session]
116
+ * custom_agents = { a = "b" } inline form → entry injected into the braces
117
+ * and appends a fresh header only when neither exists. `value` must already be
118
+ * a TOML literal (see tomlString).
119
+ */
120
+ export function upsertTomlKey(toml, table, key, value) {
121
+ const lines = String(toml || '').split('\n');
122
+ const parent = table.includes('.') ? table.slice(0, table.lastIndexOf('.')) : '';
123
+ const child = table.slice(table.lastIndexOf('.') + 1);
124
+ const headerOf = (line) => {
125
+ const m = line.match(/^\s*\[([^[\]]+)\]\s*(#.*)?$/);
126
+ return m ? m[1].trim().replace(/\s+/g, '').replace(/"/g, '') : null;
127
+ };
128
+ let section = '';
129
+ let headerIdx = -1;
130
+ let inlineIdx = -1;
131
+ const out = [];
132
+ for (const line of lines) {
133
+ const h = headerOf(line);
134
+ if (h !== null) {
135
+ section = h;
136
+ if (h === table && headerIdx === -1) headerIdx = out.length;
137
+ out.push(line);
138
+ continue;
139
+ }
140
+ if (section === table) {
141
+ const m = line.match(/^\s*("[^"]*"|'[^']*'|[A-Za-z0-9_-]+)\s*=/);
142
+ if (m && keyMatches(m[1], key)) continue; // ours, rewritten below
143
+ }
144
+ if (section === parent && inlineIdx === -1) {
145
+ const m = line.match(/^\s*("[^"]*"|'[^']*'|[A-Za-z0-9_-]+)\s*=\s*\{/);
146
+ if (m && keyMatches(m[1], child)) inlineIdx = out.length;
147
+ }
148
+ out.push(line);
149
+ }
150
+ const entry = `${key} = ${value}`;
151
+ if (headerIdx !== -1) {
152
+ out.splice(headerIdx + 1, 0, entry);
153
+ } else if (inlineIdx !== -1) {
154
+ const line = out[inlineIdx];
155
+ const open = line.indexOf('{');
156
+ const close = line.lastIndexOf('}');
157
+ if (close > open) {
158
+ const body = splitInline(line.slice(open + 1, close))
159
+ .filter((p) => !keyMatches(p.split('=')[0], key));
160
+ out[inlineIdx] = `${line.slice(0, open + 1)} ${[entry, ...body].join(', ')} ${line.slice(close)}`;
161
+ } else {
162
+ // Multi-line inline table (TOML 1.1). Rare; a fresh header would clash
163
+ // with it, so refuse loudly rather than corrupt the file.
164
+ throw new Error(`cannot edit multi-line inline table \`${child}\` in [${parent}] — add ${entry} by hand`);
165
+ }
166
+ } else {
167
+ while (out.length && out[out.length - 1].trim() === '') out.pop();
168
+ if (out.length) out.push('');
169
+ out.push(`[${table}]`, entry);
170
+ }
171
+ return `${out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`;
172
+ }
173
+
174
+ /** Drop a whole `[table]` block (header through the next header). */
175
+ export function stripTomlTable(toml, table) {
176
+ const lines = String(toml || '').split('\n');
177
+ const out = [];
178
+ let skipping = false;
179
+ for (const line of lines) {
180
+ const m = line.match(/^\s*\[([^[\]]+)\]\s*(#.*)?$/);
181
+ if (m) skipping = m[1].trim().replace(/\s+/g, '').replace(/"/g, '') === table;
182
+ if (!skipping) out.push(line);
183
+ }
184
+ return out.join('\n').replace(/\n{3,}/g, '\n\n');
185
+ }
186
+
187
+ /**
188
+ * Merge openzoo into an AoE config. Pure.
189
+ *
190
+ * - session.custom_agents.openzoo = "<launch> claude" the agent
191
+ * - session.agent_detect_as.openzoo = "claude" inherit Claude's
192
+ * status hooks, resume/fork flags and structured-view adapter
193
+ * - tools.openzoo receipts tail on Alt+z (persistent tool
194
+ * session; the proxy's log is the spend ledger)
195
+ * - session.agent_command_override.claude (opt-in, `overrideClaude`): every
196
+ * built-in claude session in AoE launches through openzoo too
197
+ */
198
+ export function mergeAoeConfig(toml, {
199
+ launch = 'openzoo', logPath = '~/.openzoo/proxy.log', overrideClaude = false, receipts = true,
200
+ } = {}) {
201
+ let out = String(toml || '');
202
+ const changes = [];
203
+ const claudeCmd = tomlString(`${launch} claude`);
204
+ out = upsertTomlKey(out, 'session.custom_agents', AGENT_NAME, claudeCmd);
205
+ changes.push(`session.custom_agents.${AGENT_NAME} = ${claudeCmd}`);
206
+ out = upsertTomlKey(out, 'session.agent_detect_as', AGENT_NAME, '"claude"');
207
+ changes.push(`session.agent_detect_as.${AGENT_NAME} = "claude"`);
208
+ if (overrideClaude) {
209
+ out = upsertTomlKey(out, 'session.agent_command_override', 'claude', claudeCmd);
210
+ changes.push(`session.agent_command_override.claude = ${claudeCmd}`);
211
+ }
212
+ out = stripTomlTable(out, `tools.${TOOL_NAME}`);
213
+ if (receipts) {
214
+ const cmd = tomlString(`${launch} balance; tail -n 40 -f ${logPath}`);
215
+ // Comment INSIDE the block: stripTomlTable drops header-to-next-header, so
216
+ // a comment above the header would survive every re-run and pile up.
217
+ out = `${out.trimEnd()}\n\n[tools.${TOOL_NAME}]\n# openzoo receipts: wallet balance, then the proxy's live payment log.\ncommand = ${cmd}\nhotkey = "Alt+z"\n`;
218
+ changes.push(`tools.${TOOL_NAME} (Alt+z) = ${cmd}`);
219
+ }
220
+ return { toml: out, changes };
221
+ }
222
+
223
+ function aoeInstalled() {
224
+ const r = spawnSync('aoe', ['--version'], { encoding: 'utf8' });
225
+ return r.status === 0 ? (r.stdout || '').trim() : null;
226
+ }
227
+
228
+ async function proxyUp(base) {
229
+ try { return (await fetch(`${base}/info`, { signal: AbortSignal.timeout(2500) })).ok; } catch { return false; }
230
+ }
231
+
232
+ /**
233
+ * Start a proxy that outlives this command. AoE sessions are tmux sessions:
234
+ * they keep running after the TUI closes, so a proxy tied to this process
235
+ * would vanish under them. `openzoo claude` inside a pane heals a missing
236
+ * proxy at launch, but a proxy that dies mid-conversation is a wall of API
237
+ * errors in every other session, so the long-lived one is started here.
238
+ * No public tunnel unless asked: a detached proxy nobody is watching must not
239
+ * open a URL + key that spends the wallet with no session cap.
240
+ */
241
+ function startDetachedProxy({ tunnel }) {
242
+ const logPath = path.join(os.homedir(), '.openzoo', 'proxy.log');
243
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
244
+ const fd = fs.openSync(logPath, 'a');
245
+ const env = { ...process.env };
246
+ if (!tunnel && !env.OPENZOO_NO_TUNNEL) env.OPENZOO_NO_TUNNEL = '1';
247
+ const child = spawn(process.execPath, [process.argv[1], 'proxy'], {
248
+ detached: true, stdio: ['ignore', fd, fd], env,
249
+ });
250
+ child.unref();
251
+ fs.closeSync(fd);
252
+ return { pid: child.pid, logPath };
253
+ }
254
+
255
+ export async function setupAoe(argv = []) {
256
+ const flags = new Set(argv.filter((a) => a.startsWith('--')));
257
+ const rest = argv.filter((a) => !['--no-launch', '--no-proxy', '--tunnel', '--override-claude', '--no-receipts'].includes(a));
258
+ const cfgPath = aoeConfigPath();
259
+ const launch = openzooLaunchCommand();
260
+
261
+ let existing = '';
262
+ try { existing = fs.readFileSync(cfgPath, 'utf8'); } catch { existing = ''; }
263
+ const { toml, changes } = mergeAoeConfig(existing, {
264
+ launch,
265
+ logPath: path.join(os.homedir(), '.openzoo', 'proxy.log'),
266
+ overrideClaude: flags.has('--override-claude'),
267
+ receipts: !flags.has('--no-receipts'),
268
+ });
269
+ if (toml !== existing) {
270
+ fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
271
+ fs.writeFileSync(cfgPath, toml);
272
+ }
273
+ console.error(`openzoo: ${toml === existing ? 'already in' : 'written to'} ${cfgPath}`);
274
+ for (const c of changes) console.error(` ${c}`);
275
+ console.error(' agent "openzoo" = Claude Code on the zoo: pays x402 per turn, no API key, no account.');
276
+ console.error(' terminal view only — AoE\'s structured view runs claude-agent-acp itself, which bills Anthropic.');
277
+
278
+ const version = aoeInstalled();
279
+ if (!version) {
280
+ console.error('');
281
+ console.error('openzoo: `aoe` is not installed (Agent of Empires — tmux session manager for coding agents).');
282
+ console.error(' brew install aoe');
283
+ console.error(' curl -fsSL https://raw.githubusercontent.com/agent-of-empires/agent-of-empires/main/scripts/install.sh | bash');
284
+ console.error(' then: openzoo aoe (TUI) openzoo aoe add . -l (one session, launched)');
285
+ process.exit(flags.has('--no-launch') ? 0 : 1);
286
+ }
287
+
288
+ const base = `http://localhost:${config.port}/v1`;
289
+ if (!flags.has('--no-proxy')) {
290
+ if (await proxyUp(base)) {
291
+ console.error(`openzoo: proxy already up on ${base}`);
292
+ } else {
293
+ const { pid, logPath } = startDetachedProxy({ tunnel: flags.has('--tunnel') });
294
+ let up = false;
295
+ for (let i = 0; i < 40 && !up; i++) {
296
+ await new Promise((r) => setTimeout(r, 300));
297
+ up = await proxyUp(base);
298
+ }
299
+ if (up) console.error(`openzoo: proxy started in the background (pid ${pid}, receipts in ${logPath}; stop: kill ${pid})`);
300
+ else console.error(`openzoo: proxy not answering yet on ${base} — sessions will start one themselves; see ${logPath}`);
301
+ }
302
+ }
303
+ if (flags.has('--no-launch')) return;
304
+
305
+ // `openzoo aoe add …` → `aoe add --tool openzoo …` unless the caller chose.
306
+ const args = [...rest];
307
+ if (args[0] === 'add' && !args.some((a) => a === '--tool' || a === '--cmd' || a === '-c')) {
308
+ args.splice(1, 0, '--tool', AGENT_NAME);
309
+ }
310
+ console.error(`openzoo: aoe ${version} — running: aoe ${args.join(' ')}`.trimEnd());
311
+ const child = spawn('aoe', args, { stdio: 'inherit' });
312
+ child.on('exit', (code) => process.exit(code ?? 0));
313
+ child.on('error', (e) => { console.error(`openzoo: could not launch aoe: ${e.message}`); process.exit(1); });
314
+ }
package/lib/botlog.js CHANGED
@@ -42,7 +42,7 @@ const MILESTONE = [
42
42
  /ERROR|FAIL|uncaught|rejection|timed out/,
43
43
  /wakeups restored|wakeup fire/,
44
44
  /ozRevive|ship_|ship:/,
45
- /sendPrompt done/,
45
+ /sendPrompt done|>> sendPrompt/,
46
46
  /create_agent tool|deleteAgents n=/,
47
47
  /x_compose|chrome reattach/,
48
48
  ];
@@ -57,7 +57,7 @@ export function isBotMilestone(line) {
57
57
  /**
58
58
  * Terminal gets milestones (or everything with verbose); the FULL stream is
59
59
  * always appended to `file` so quiet mode never destroys evidence.
60
- * Default file: ~/.openzoo/bot.log (truncated at start of each run).
60
+ * Default file: ~/.openzoo/bot.log (appended; one header line per run).
61
61
  */
62
62
  export function makeBotLogger({ verbose = false, write = (m) => console.error(m), file = defaultBotLogPath(), fsMod = null } = {}) {
63
63
  let fd = null;
@@ -65,8 +65,10 @@ export function makeBotLogger({ verbose = false, write = (m) => console.error(m)
65
65
  try {
66
66
  const fsx = fsMod || fsSync;
67
67
  fsx.mkdirSync(pathMod.dirname(file), { recursive: true });
68
- fd = fsx.openSync(file, 'w');
69
- fsx.writeSync(fd, `# openzoo bot full log ${new Date().toISOString()}\n`);
68
+ // APPEND, never truncate: the minutely revive cron restarts a dead
69
+ // hijack, and a truncating open erased the very turn being debugged.
70
+ fd = fsx.openSync(file, 'a');
71
+ fsx.writeSync(fd, `# openzoo bot run ${new Date().toISOString()} pid=${process.pid}\n`);
70
72
  } catch { fd = null; }
71
73
  }
72
74
  return (m) => {
@@ -45,7 +45,7 @@ import {
45
45
  DEFAULT_WAKEUP_PROMPT, addDeletedIds, filterDeleted,
46
46
  } from './grokbotAccount.js';
47
47
  import { formatSpendFooter, mergeTurnProof } from './spendProof.js';
48
- import { zooModelIds } from './models.js';
48
+ import { zooModelIds, zooModelRow, mediaKindOf } from './models.js';
49
49
  import { prefixVisitorRichText } from './grokbotweb.js';
50
50
  import {
51
51
  ingestUpload, lookupUpload, readUploadChunk, readUploadImage, readUploadText,
@@ -1626,7 +1626,38 @@ function entryPlainText(v) {
1626
1626
  /** Prior turns for zooComplete. sendPrompt used to POST only the latest user
1627
1627
  * line, so the model said each thread starts blank while the UI still showed
1628
1628
  * the canvas. Skip the last user echo of `currentPrompt` (already appended). */
1629
- function historyMessages(agentId, currentPrompt) {
1629
+ /**
1630
+ * Strict user/assistant alternation. The abliteration lane 400s
1631
+ * "Invalid model provider request" on `auauauuuau` (wakeup + "continue" +
1632
+ * nudge = three user turns; error text + reply = two assistant turns).
1633
+ * Lossless fold: texts joined with a blank line; tool turns never crossed.
1634
+ */
1635
+ export function foldSameRole(messages) {
1636
+ const out = [];
1637
+ const foldable = (m) => m && (m.role === 'user' || m.role === 'assistant') && !m.tool_calls && !m.tool_call_id;
1638
+ for (const m of messages || []) {
1639
+ const last = out[out.length - 1];
1640
+ if (last && foldable(last) && foldable(m) && last.role === m.role) {
1641
+ const a = last.content; const b = m.content;
1642
+ let content;
1643
+ if (Array.isArray(a) || Array.isArray(b)) {
1644
+ const arr = (v) => (Array.isArray(v) ? v : (v == null || v === '' ? [] : [{ type: 'text', text: String(v) }]));
1645
+ content = [...arr(a), ...arr(b)];
1646
+ } else {
1647
+ const sa = a == null ? '' : String(a); const sb = b == null ? '' : String(b);
1648
+ content = sa && sb ? `${sa}\n\n${sb}` : (sa || sb);
1649
+ }
1650
+ out[out.length - 1] = { ...last, content };
1651
+ continue;
1652
+ }
1653
+ out.push(m);
1654
+ }
1655
+ return out;
1656
+ }
1657
+ function historyMessages(...args) {
1658
+ return foldSameRole(historyMessagesRaw(...args));
1659
+ }
1660
+ function historyMessagesRaw(agentId, currentPrompt) {
1630
1661
  const entries = agentTranscript(agentId).entries || [];
1631
1662
  const cur = String(currentPrompt || '').trim();
1632
1663
  const out = [];
@@ -2393,9 +2424,24 @@ const LOCAL_TOOLS = [
2393
2424
 
2394
2425
  export const LOCAL_TOOL_NAMES = LOCAL_TOOLS.map((t) => t.function.name);
2395
2426
 
2427
+ /**
2428
+ * OpenAI-compatible upstreams cap `tools` at 128. MEASURED 2026-09-01:
2429
+ * surplusintelligence `400 Invalid 'tools': array too long … got 130` — every
2430
+ * bot turn with both browsers attached died at the door. Locals first, then
2431
+ * the browser MCPs (the bots' real work), then whatever fits.
2432
+ */
2433
+ export const MAX_TOOLS = 128;
2434
+ export function capTools(list, max = MAX_TOOLS) {
2435
+ if (!Array.isArray(list) || list.length <= max) return list;
2436
+ const name = (t) => String(t?.function?.name || '');
2437
+ const local = list.filter((t) => LOCAL_TOOL_NAMES.includes(name(t)));
2438
+ const browser = list.filter((t) => !LOCAL_TOOL_NAMES.includes(name(t)) && /^(chrome|brave)-devtools__/.test(name(t)));
2439
+ const rest = list.filter((t) => !local.includes(t) && !browser.includes(t));
2440
+ return [...local, ...browser, ...rest].slice(0, max);
2441
+ }
2396
2442
  function liveTools() {
2397
2443
  const extra = hostMcpTools();
2398
- return extra.length ? [...LOCAL_TOOLS, ...extra] : LOCAL_TOOLS;
2444
+ return capTools(extra.length ? [...LOCAL_TOOLS, ...extra] : LOCAL_TOOLS);
2399
2445
  }
2400
2446
 
2401
2447
  async function captureScreenshot(log) {
@@ -2744,6 +2790,58 @@ function zooTextFromMessage(msg, data) {
2744
2790
  return '';
2745
2791
  }
2746
2792
 
2793
+ let mediaClient = null;
2794
+ function receiptToX402(r) {
2795
+ if (!r) return {};
2796
+ return { billedUsd: r.billedUsd, directUsd: r.directUsd ?? r.billedUsd, rail: r.rail, tx: r.tx, line: r.line, paid: r.asset };
2797
+ }
2798
+ /** Pay the media endpoint, poll the job, paint the URL. Same PayClient path as zoo_video. */
2799
+ async function mediaTurn({ model, kind, prompt, log, onProgress, signal }) {
2800
+ const { PayClient } = await import('./pay.js');
2801
+ const { config } = await import('./config.js');
2802
+ const { withNamespace } = await import('./namespace.js');
2803
+ mediaClient ||= new PayClient();
2804
+ const say = (m) => { try { onProgress?.(m); } catch { /* */ } };
2805
+ say(`Rendering ${kind} with ${model}…`);
2806
+ log(`cursor-backend: media ${kind} POST model=${model} ${JSON.stringify(String(prompt || '').slice(0, 60))}`);
2807
+ let response; let receipt; let data = {};
2808
+ try {
2809
+ ({ response, receipt } = await mediaClient.fetch(`${config.apiBase}/v1/${kind}s/generations`, {
2810
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ model, prompt }),
2811
+ }));
2812
+ data = await response.json().catch(() => ({}));
2813
+ } catch (e) {
2814
+ return { text: `${kind} generation failed: ${e.message}`, data: {} };
2815
+ }
2816
+ if (!response.ok) return { text: `${kind} generation failed: HTTP ${response.status} ${JSON.stringify(data).slice(0, 240)}`, data: { x402: receiptToX402(receipt) } };
2817
+ if (kind === 'image') {
2818
+ const url = data?.data?.[0]?.url || data?.outputs?.image_url || data?.url || null;
2819
+ return { text: url ? `🖼️ ${url}` : JSON.stringify(data).slice(0, 400), data: { x402: receiptToX402(receipt) } };
2820
+ }
2821
+ const id = data?.id;
2822
+ let url = data?.outputs?.video_url || null;
2823
+ let status = data?.status || 'queued';
2824
+ let err = null;
2825
+ const t0 = Date.now();
2826
+ while (!url && id && Date.now() - t0 < 8 * 60_000) {
2827
+ if (signal?.aborted) throw new Error('superseded');
2828
+ await new Promise((ok) => setTimeout(ok, 5000));
2829
+ try {
2830
+ const r = await fetch(`${config.apiBase}/v1/videos/${encodeURIComponent(id)}`, { headers: withNamespace({}), signal: AbortSignal.timeout(15000) });
2831
+ const j = await r.json().catch(() => ({}));
2832
+ status = j?.status || status;
2833
+ url = j?.outputs?.video_url || null;
2834
+ if (status === 'failed') { err = j?.error?.message || JSON.stringify(j?.error || j).slice(0, 200); break; }
2835
+ } catch (e) { log(`cursor-backend: media poll ${e.message}`); }
2836
+ say(`Rendering video… ${status} ${Math.round((Date.now() - t0) / 1000)}s`);
2837
+ }
2838
+ const text = url
2839
+ ? `🎬 ${url}`
2840
+ : err ? `video failed: ${err}` : `video job ${id || '?'} still ${status} after 8 min — check later: ${config.apiBase}/v1/videos/${id || ''}`;
2841
+ log(`cursor-backend: media video done id=${id} status=${status} url=${url ? 'yes' : 'no'}`);
2842
+ return { text, data: { x402: receiptToX402(receipt) } };
2843
+ }
2844
+
2747
2845
  async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
2748
2846
  const model = currentModel(agentId);
2749
2847
  const helper = localExecSse.size > 0;
@@ -2757,6 +2855,14 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
2757
2855
  };
2758
2856
  await ensureTranscriptHydrated(agentId, log);
2759
2857
  try { await reattachChrome({ log }); } catch (e) { log(`cursor-backend: chrome reattach ${e.message}`); }
2858
+ // A bot pinned to a video/image model: the prompt IS the render request.
2859
+ // A chat POST to ByteDance/Seedance-2.5 came back 502 with an empty body
2860
+ // and painted "(empty zoo reply)" — measured 2026-09-01.
2861
+ if (!chatOnly) {
2862
+ let mediaKind = null;
2863
+ try { mediaKind = mediaKindOf(await zooModelRow(model)); } catch { mediaKind = null; }
2864
+ if (mediaKind) return await mediaTurn({ model, kind: mediaKind, prompt: spoken, log, onProgress: opts.onProgress, signal: opts.signal });
2865
+ }
2760
2866
  log(`cursor-backend: zoo POST :8402 model=${model} helper=${helper ? localExecSse.size : 0} hist=${historyMessages(agentId, spoken).length}${chatOnly ? ` visitor=${visitor.shortname} chat-only` : ''} ${JSON.stringify((spoken || '').slice(0, 60))}`);
2761
2867
  if (typeof opts.onProgress === 'function') opts.onProgress('Working on your Mac…');
2762
2868
 
@@ -2922,6 +3028,14 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
2922
3028
  }
2923
3029
  }
2924
3030
  const data = await r.json().catch(() => ({}));
3031
+ // EVIDENCE FOR THE NEXT DEBUG: the exact body behind a non-200, on disk.
3032
+ // "Invalid model provider request." reproduced with no probe until the
3033
+ // real transcript could be replayed.
3034
+ if (r.status !== 200) {
3035
+ try {
3036
+ fs.writeFileSync(path.join(os.homedir(), '.openzoo', 'zoo-last-error-body.json'), JSON.stringify({ at: new Date().toISOString(), status: r.status, agentId, response: data, payload }, null, 1));
3037
+ } catch { /* */ }
3038
+ }
2925
3039
  if (r.status === 402) {
2926
3040
  const raw = zooTextFromMessage(data?.choices?.[0]?.message, data)
2927
3041
  || data?.error?.message
package/lib/models.js CHANGED
@@ -541,3 +541,31 @@ export function wantsAnthropicModelList(headers = {}) {
541
541
  export function modelsListForRequest(payload, headers) {
542
542
  return wantsAnthropicModelList(headers) ? anthropicModelList(payload) : publishModelList(payload);
543
543
  }
544
+
545
+ /* ── media rows (video / image) ─────────────────────────────────────────── */
546
+ let rowsCache = { at: 0, rows: null, base: '' };
547
+ /** Raw catalog rows incl. media (`kind`, `endpoint`), cached like zooModelIds. */
548
+ export async function zooCatalogRows() {
549
+ if (rowsCache.rows && rowsCache.base === config.apiBase && Date.now() - rowsCache.at < CATALOG_TTL_MS) return rowsCache.rows;
550
+ const r = await fetchHeaders(`${config.apiBase}/v1/models`);
551
+ if (!r.ok) throw new Error(`model catalog fetch failed: HTTP ${r.status}`);
552
+ const d = await r.json();
553
+ const rows = Array.isArray(d?.data) ? d.data : [];
554
+ if (rows.length) rowsCache = { at: Date.now(), rows, base: config.apiBase };
555
+ return rows;
556
+ }
557
+ export async function zooModelRow(id) {
558
+ const want = String(id || '');
559
+ if (!want) return null;
560
+ const rows = await zooCatalogRows();
561
+ return rows.find((m) => m?.id === want) || rows.find((m) => String(m?.id || '').toLowerCase() === want.toLowerCase()) || null;
562
+ }
563
+ /** 'video' | 'image' | null — a chat POST to one of these is a guaranteed empty reply. */
564
+ export function mediaKindOf(row) {
565
+ const k = String(row?.kind || '').toLowerCase();
566
+ if (k === 'video' || k === 'image') return k;
567
+ const ep = String(row?.endpoint || '');
568
+ if (/\/videos\/generations/.test(ep)) return 'video';
569
+ if (/\/images\/generations/.test(ep)) return 'image';
570
+ return null;
571
+ }
package/lib/openclaw.js CHANGED
@@ -18,6 +18,14 @@
18
18
  * charges at most this, less with trailing volume and leCore context reuse.
19
19
  * A client-side estimate can only be honest-or-high; receipts on the proxy
20
20
  * console stay the ground truth.
21
+ *
22
+ * RE-MEASURED on OpenClaw 2026.8.2 (2026-09-02): the block this writes still
23
+ * validates (`openclaw config validate` accepts baseUrl/apiKey/api/models with
24
+ * cost/contextWindow/maxTokens), and a hand-added provider still gets no live
25
+ * discovery — 2026.8's "live model discovery" is provider-plugin-owned. The
26
+ * bundled `extensions/openzoo` provider plugin (upstream PR) is the proper
27
+ * 2026.8+ path: discovery + pricing from /v1/models, no config surgery. This
28
+ * command stays for older builds and for pinning an exact model list.
21
29
  */
22
30
  import fs from 'node:fs';
23
31
  import os from 'node:os';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.50.77",
3
+ "version": "0.50.79",
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",