openzoo 0.50.41 → 0.50.43
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 +119 -7
- package/lib/grokbotAccount.js +14 -1
- package/lib/grokcli.js +6 -1
- package/lib/ozSpendChip.js +105 -24
- package/lib/spendProof.js +55 -8
- package/package.json +1 -1
package/lib/cursorbackend.js
CHANGED
|
@@ -39,7 +39,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
39
39
|
import { encodeAvailableModels, encodeForMethod, encodeEnsureSandBox, encodeGetGrokBotSendStatus, decodeProtoFields, unwrapConnect } from './cursorapi.js';
|
|
40
40
|
import {
|
|
41
41
|
accountPodPath, accountAgentsPath, rosterForAccount, rosterForEvent,
|
|
42
|
-
readHouseRoster, houseAgentsPath, shapeAgent,
|
|
42
|
+
readHouseRoster, houseAgentsPath, shapeAgent, agentBrief,
|
|
43
43
|
} from './grokbotAccount.js';
|
|
44
44
|
import { formatSpendFooter, mergeTurnProof } from './spendProof.js';
|
|
45
45
|
import { prefixVisitorRichText } from './grokbotweb.js';
|
|
@@ -1885,17 +1885,56 @@ const LOCAL_TOOLS = [
|
|
|
1885
1885
|
type: 'function',
|
|
1886
1886
|
function: {
|
|
1887
1887
|
name: 'create_agent',
|
|
1888
|
-
description: 'Mint another Grok Bot in the sidebar.
|
|
1888
|
+
description: 'Mint another Grok Bot in the sidebar. Pass brief so they keep their job across restarts.',
|
|
1889
1889
|
parameters: {
|
|
1890
1890
|
type: 'object',
|
|
1891
1891
|
properties: {
|
|
1892
1892
|
name: { type: 'string', description: 'Sidebar label for the new bot.' },
|
|
1893
|
+
brief: { type: 'string', description: 'Standing job. Persisted. Injected as their system brief on every turn.' },
|
|
1893
1894
|
select: { type: 'boolean', description: 'If true (default), switch the UI to the new bot.' },
|
|
1894
1895
|
},
|
|
1895
1896
|
required: ['name'],
|
|
1896
1897
|
},
|
|
1897
1898
|
},
|
|
1898
1899
|
},
|
|
1900
|
+
{
|
|
1901
|
+
type: 'function',
|
|
1902
|
+
function: {
|
|
1903
|
+
name: 'set_brief',
|
|
1904
|
+
description: 'Save a standing brief on a sidebar bot (this one if agent omitted). Survives hijack restart.',
|
|
1905
|
+
parameters: {
|
|
1906
|
+
type: 'object',
|
|
1907
|
+
properties: {
|
|
1908
|
+
brief: { type: 'string' },
|
|
1909
|
+
agent: { type: 'string', description: 'Name or id. Default: the current bot.' },
|
|
1910
|
+
},
|
|
1911
|
+
required: ['brief'],
|
|
1912
|
+
},
|
|
1913
|
+
},
|
|
1914
|
+
},
|
|
1915
|
+
{
|
|
1916
|
+
type: 'function',
|
|
1917
|
+
function: {
|
|
1918
|
+
name: 'list_agents',
|
|
1919
|
+
description: 'List sidebar bots with id, name, brief. Use before message_agent.',
|
|
1920
|
+
parameters: { type: 'object', properties: {} },
|
|
1921
|
+
},
|
|
1922
|
+
},
|
|
1923
|
+
{
|
|
1924
|
+
type: 'function',
|
|
1925
|
+
function: {
|
|
1926
|
+
name: 'message_agent',
|
|
1927
|
+
description: 'Send text to another sidebar bot by name or id, wait for their reply, paint it on their canvas. One hop — do not chain.',
|
|
1928
|
+
parameters: {
|
|
1929
|
+
type: 'object',
|
|
1930
|
+
properties: {
|
|
1931
|
+
to: { type: 'string', description: 'Name or id of the other bot.' },
|
|
1932
|
+
text: { type: 'string' },
|
|
1933
|
+
},
|
|
1934
|
+
required: ['to', 'text'],
|
|
1935
|
+
},
|
|
1936
|
+
},
|
|
1937
|
+
},
|
|
1899
1938
|
];
|
|
1900
1939
|
|
|
1901
1940
|
export const LOCAL_TOOL_NAMES = LOCAL_TOOLS.map((t) => t.function.name);
|
|
@@ -1935,7 +1974,50 @@ async function captureScreenshot(log) {
|
|
|
1935
1974
|
return meta;
|
|
1936
1975
|
}
|
|
1937
1976
|
|
|
1938
|
-
|
|
1977
|
+
function findAgent(q) {
|
|
1978
|
+
const list = cachedAgentList() || [];
|
|
1979
|
+
const s = String(q || '').trim();
|
|
1980
|
+
if (!s) return null;
|
|
1981
|
+
const lower = s.toLowerCase();
|
|
1982
|
+
return list.find((a) => a.id === s)
|
|
1983
|
+
|| list.find((a) => String(a.name || '').toLowerCase() === lower)
|
|
1984
|
+
|| list.find((a) => String(a.name || '').toLowerCase().includes(lower))
|
|
1985
|
+
|| null;
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
let messageAgentDepth = 0;
|
|
1989
|
+
async function deliverAgentMessage({ fromId, to, text, parsed = {}, log }) {
|
|
1990
|
+
const dest = findAgent(to);
|
|
1991
|
+
if (!dest) return `ERROR no bot named ${JSON.stringify(to)}. list_agents first.`;
|
|
1992
|
+
if (fromId && dest.id === String(fromId)) return 'ERROR cannot message_agent yourself';
|
|
1993
|
+
const msg = String(text || '').trim();
|
|
1994
|
+
if (!msg) return 'ERROR empty message';
|
|
1995
|
+
if (messageAgentDepth >= 1) return 'ERROR message_agent is one hop — reply in chat instead of chaining';
|
|
1996
|
+
const from = findAgent(fromId);
|
|
1997
|
+
const fromName = from?.name || 'another bot';
|
|
1998
|
+
const nonce = `oz-msg-${Date.now()}`;
|
|
1999
|
+
const prompt = `[message from ${fromName}]\n${msg}`;
|
|
2000
|
+
messageAgentDepth += 1;
|
|
2001
|
+
try {
|
|
2002
|
+
const userLine = fanoutLine(dest.id, 'user', prompt, { clientNonce: nonce, requestId: nonce });
|
|
2003
|
+
ssePush('transcript', { ...gatewayEntry(userLine), agentId: dest.id });
|
|
2004
|
+
bumpAgent(dest.id, { preview: msg, notify: true });
|
|
2005
|
+
const z = await zooComplete(prompt, log, dest.id, { ...parsed, visitor: undefined });
|
|
2006
|
+
const reply = String(z.text || '');
|
|
2007
|
+
const line = fanoutLine(dest.id, 'assistant', reply, { clientNonce: nonce, requestId: nonce });
|
|
2008
|
+
ssePush('transcript', { ...gatewayEntry(line), agentId: dest.id });
|
|
2009
|
+
bumpAgent(dest.id, { preview: reply, notify: true });
|
|
2010
|
+
return JSON.stringify({
|
|
2011
|
+
ok: true,
|
|
2012
|
+
to: { id: dest.id, name: dest.name },
|
|
2013
|
+
reply: stripSpendFooter(reply).slice(0, 4000),
|
|
2014
|
+
});
|
|
2015
|
+
} finally {
|
|
2016
|
+
messageAgentDepth -= 1;
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
async function runLocalTool(name, args, log, ctx = {}) {
|
|
1939
2021
|
try {
|
|
1940
2022
|
if (name === 'read_file') {
|
|
1941
2023
|
const buf = await readLocalBytes(expandUserPath(args.path), log);
|
|
@@ -1998,10 +2080,34 @@ async function runLocalTool(name, args, log) {
|
|
|
1998
2080
|
if (name === 'create_agent') {
|
|
1999
2081
|
const agent = mintLocalAgent({
|
|
2000
2082
|
name: String(args.name || args.title || 'New Bot').slice(0, 80),
|
|
2083
|
+
brief: String(args.brief || args.instructions || ''),
|
|
2001
2084
|
});
|
|
2002
2085
|
pushCreatedAgent(agent, { select: args.select !== false });
|
|
2003
2086
|
log(`cursor-backend: create_agent tool id=${agent.id} name=${JSON.stringify(agent.name)}`);
|
|
2004
|
-
return JSON.stringify({ ok: true, id: agent.id, name: agent.name });
|
|
2087
|
+
return JSON.stringify({ ok: true, id: agent.id, name: agent.name, brief: agent.brief || '' });
|
|
2088
|
+
}
|
|
2089
|
+
if (name === 'set_brief') {
|
|
2090
|
+
const who = findAgent(args.agent || args.id || args.name) || findAgent(ctx.agentId);
|
|
2091
|
+
if (!who) return 'ERROR no such agent';
|
|
2092
|
+
const agent = mintLocalAgent({ id: who.id, name: who.name, brief: String(args.brief || '') });
|
|
2093
|
+
return JSON.stringify({ ok: true, id: agent.id, name: agent.name, brief: agent.brief || '' });
|
|
2094
|
+
}
|
|
2095
|
+
if (name === 'list_agents') {
|
|
2096
|
+
const list = cachedAgentList() || [];
|
|
2097
|
+
return JSON.stringify(list.map((a) => ({
|
|
2098
|
+
id: a.id,
|
|
2099
|
+
name: a.name,
|
|
2100
|
+
brief: String(a.brief || '').slice(0, 120),
|
|
2101
|
+
})));
|
|
2102
|
+
}
|
|
2103
|
+
if (name === 'message_agent') {
|
|
2104
|
+
return await deliverAgentMessage({
|
|
2105
|
+
fromId: ctx.agentId,
|
|
2106
|
+
to: args.to || args.agent,
|
|
2107
|
+
text: args.text || args.message || args.prompt,
|
|
2108
|
+
parsed: ctx.parsed,
|
|
2109
|
+
log,
|
|
2110
|
+
});
|
|
2005
2111
|
}
|
|
2006
2112
|
return `unknown tool ${name}`;
|
|
2007
2113
|
} catch (e) {
|
|
@@ -2092,9 +2198,15 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
|
|
|
2092
2198
|
].join(' ')
|
|
2093
2199
|
: [
|
|
2094
2200
|
`You are ${model} served through openzoo inside Grok Bot.`,
|
|
2201
|
+
(() => {
|
|
2202
|
+
const me = (cachedAgentList() || []).find((a) => a.id === agentId);
|
|
2203
|
+
const brief = me ? agentBrief(me) : '';
|
|
2204
|
+
const who = me?.name ? `You are "${me.name}" in this Grok Bot sidebar.` : '';
|
|
2205
|
+
return brief ? `${who} Standing brief (persisted): ${brief}` : who;
|
|
2206
|
+
})(),
|
|
2095
2207
|
`You HAVE local tools on the user's computer via ${via}.`,
|
|
2096
|
-
'Tools: read_file, write_file, exec, list_dir, screenshot, click, type_text, key, ui_tree, focus_app, open_url, create_agent.',
|
|
2097
|
-
'create_agent mints a
|
|
2208
|
+
'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.',
|
|
2209
|
+
'create_agent mints a sidebar bot. ALWAYS pass brief so they keep the job across restart. set_brief updates it. list_agents + message_agent talk to other bots (one hop). Do not tell the human to copy-paste between canvases.',
|
|
2098
2210
|
'You CAN click the Mac and fill/submit browser forms. That is required when the user asks. Do not write a markdown briefing instead of clicking. Do not tell the user to click.',
|
|
2099
2211
|
'Form loop: focus_app Brave Browser → screenshot → click the field (query or x,y) → type_text → screenshot to confirm → click Submit / key enter. screenshot.screen is click coordinate space. Prefer click query="Submit".',
|
|
2100
2212
|
'screenshot captures the display and attaches the image. For any on-screen form, dashboard, or click target, screenshot first this turn. Do not guess at UI you have not seen.',
|
|
@@ -2221,7 +2333,7 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
|
|
|
2221
2333
|
let args = {};
|
|
2222
2334
|
try { args = JSON.parse(c.function?.arguments || c.arguments || '{}'); } catch { args = {}; }
|
|
2223
2335
|
const name = c.function?.name || c.name || '';
|
|
2224
|
-
let result = await runLocalTool(name, args, log);
|
|
2336
|
+
let result = await runLocalTool(name, args, log, { agentId, parsed });
|
|
2225
2337
|
if (name === 'screenshot') {
|
|
2226
2338
|
try {
|
|
2227
2339
|
const shot = JSON.parse(result);
|
package/lib/grokbotAccount.js
CHANGED
|
@@ -111,6 +111,17 @@ function activityTs(agent, activity) {
|
|
|
111
111
|
* nulls, then clears the whole persisted tray. That is how a group vanished
|
|
112
112
|
* after the first send: bumpAgent wrote a partial row, restore returned null.
|
|
113
113
|
*/
|
|
114
|
+
/** Standing job text. shapeAgent used to drop this, so every restart was amnesia. */
|
|
115
|
+
export function agentBrief(raw = {}) {
|
|
116
|
+
const a = raw && typeof raw === 'object' ? raw : {};
|
|
117
|
+
for (const k of ['brief', 'instructions', 'customInstructions', 'systemPrompt']) {
|
|
118
|
+
const v = a[k];
|
|
119
|
+
if (typeof v === 'string' && v.trim()) return v.trim().slice(0, 8000);
|
|
120
|
+
}
|
|
121
|
+
const d = String(a.description || '').trim();
|
|
122
|
+
return d.slice(0, 8000);
|
|
123
|
+
}
|
|
124
|
+
|
|
114
125
|
export function shapeAgent(raw = {}) {
|
|
115
126
|
const a = raw && typeof raw === 'object' ? raw : {};
|
|
116
127
|
const id = String(a.id || '');
|
|
@@ -119,10 +130,12 @@ export function shapeAgent(raw = {}) {
|
|
|
119
130
|
: (Array.isArray(a.memberAgentIds) ? a.memberAgentIds.map((x) => String(x)).filter(Boolean) : []);
|
|
120
131
|
const isGroup = a.isGroup === true || memberIds.length > 0;
|
|
121
132
|
const name = String(a.name || a.title || (isGroup ? 'group' : 'chat'));
|
|
133
|
+
const brief = agentBrief(a);
|
|
122
134
|
return {
|
|
123
135
|
id,
|
|
124
136
|
name,
|
|
125
|
-
|
|
137
|
+
brief,
|
|
138
|
+
description: String(a.description || brief || ''),
|
|
126
139
|
title: String(a.title || name),
|
|
127
140
|
origin: String(a.origin || 'user'),
|
|
128
141
|
path: String(a.path || (id ? `/local/${id}` : '/local')),
|
package/lib/grokcli.js
CHANGED
|
@@ -22,6 +22,7 @@ import { dirname, join } from 'node:path';
|
|
|
22
22
|
import { fileURLToPath } from 'node:url';
|
|
23
23
|
|
|
24
24
|
import { config } from './config.js';
|
|
25
|
+
import { killListen } from './proxy.js';
|
|
25
26
|
import {
|
|
26
27
|
GROKBOT_CDP_PORT,
|
|
27
28
|
grokBotChromiumArgs,
|
|
@@ -251,10 +252,13 @@ export async function runBot(argv = []) {
|
|
|
251
252
|
'gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-4.1', 'gpt-4.1-mini', 'gpt-3.5-turbo',
|
|
252
253
|
].map((n) => ({ name: n, label: n }));
|
|
253
254
|
const log = (m) => console.error(' backend:', m);
|
|
255
|
+
const stale = killListen(port);
|
|
256
|
+
if (stale.length) console.error(`openzoo: killed stale hijack on :${port} pids=${stale.join(',')}`);
|
|
254
257
|
try {
|
|
255
258
|
startCursorBackend({ port, models, log });
|
|
256
259
|
} catch (e) {
|
|
257
|
-
console.error('openzoo: 8443
|
|
260
|
+
console.error('openzoo: 8443 bind failed after kill (', e.message, ')');
|
|
261
|
+
throw e;
|
|
258
262
|
}
|
|
259
263
|
await new Promise((r) => setTimeout(r, 400));
|
|
260
264
|
let ver = '?';
|
|
@@ -443,6 +447,7 @@ export async function setupGrokBot(argv = []) {
|
|
|
443
447
|
// persists, nothing else on the machine is affected, no password needed.
|
|
444
448
|
const { startCursorBackend } = await import('./cursorbackend.js');
|
|
445
449
|
const port = 8443;
|
|
450
|
+
killListen(port);
|
|
446
451
|
process.env.OPENZOO_BYOK = '1';
|
|
447
452
|
// LOUD BY DEFAULT. The previous run failed silently — "listening" and then
|
|
448
453
|
// nothing — and silence looked identical to success. Every arriving method
|
package/lib/ozSpendChip.js
CHANGED
|
@@ -18,30 +18,97 @@ export function grokBotChromiumArgs(port = GROKBOT_CDP_PORT) {
|
|
|
18
18
|
];
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
function chipUsd(n) {
|
|
22
|
+
const x = Number(n) || 0;
|
|
23
|
+
if (Math.abs(x) >= 0.01) return `$${x.toFixed(2)}`;
|
|
24
|
+
return `$${x.toFixed(4)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Rebuild pill from spent/saved even if the tag is only `$0.0010`. */
|
|
28
|
+
export function labelFromSpendBody(body, fallback) {
|
|
29
|
+
const s = String(body || '');
|
|
30
|
+
const spent = Number(s.match(/spent \$([0-9.]+)/i)?.[1]);
|
|
31
|
+
const saved = Number(s.match(/saved \$([0-9.]+)/i)?.[1]);
|
|
32
|
+
const would = Number(s.match(/OpenRouter would \$([0-9.]+)/i)?.[1]);
|
|
33
|
+
if (!Number.isFinite(spent) || !(spent > 0)) return (fallback || 'spend').trim();
|
|
34
|
+
const savedN = Number.isFinite(saved) ? saved : (Number.isFinite(would) ? Math.max(0, would - spent) : 0);
|
|
35
|
+
const wouldN = Number.isFinite(would) && would > 0 ? would : 0;
|
|
36
|
+
const pct = wouldN > 0 ? Math.round((100 * savedN) / wouldN) : 0;
|
|
37
|
+
const bits = [chipUsd(spent)];
|
|
38
|
+
if (savedN > 0.00005) bits.push(`saved ${chipUsd(savedN)}`);
|
|
39
|
+
const sav = [];
|
|
40
|
+
if (pct >= 1) sav.push(`${pct}%`);
|
|
41
|
+
if (wouldN > 0 && spent > 0) {
|
|
42
|
+
const m = wouldN / spent;
|
|
43
|
+
if (m >= 1.05) sav.push(m >= 10 ? `${m.toFixed(1)}×` : `${m.toFixed(2)}×`);
|
|
44
|
+
}
|
|
45
|
+
if (sav.length) bits.push(sav.join('/'));
|
|
46
|
+
return bits.join(' · ');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Drop proves-wall / other-bot transcript that leaked into vis text. */
|
|
50
|
+
export function spendLinesOnly(body) {
|
|
51
|
+
const raw = String(body || '').replace(/::oz-spend::[^\n]*/gi, ' ');
|
|
52
|
+
const parts = raw.split(/\n|(?=this call \$)|(?=spent \$)|(?=tx )|(?=memo )|(?=proves )/i);
|
|
53
|
+
const keep = [];
|
|
54
|
+
let txs = 0;
|
|
55
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
56
|
+
const line = String(parts[i] || '').trim();
|
|
57
|
+
if (!line) continue;
|
|
58
|
+
if (/^this call \$/i.test(line) || /^spent \$/i.test(line)) { keep.push(line); continue; }
|
|
59
|
+
if (/^tx /i.test(line) || /^https?:\/\/(?:solscan|basescan)/i.test(line)) {
|
|
60
|
+
txs += 1;
|
|
61
|
+
if (txs === 1) keep.push(/^tx /i.test(line) ? line : `tx ${line}`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (/^memo /i.test(line)) { keep.push(line.slice(0, 160)); continue; }
|
|
65
|
+
if (/^proves /i.test(line)) continue;
|
|
66
|
+
if (keep.length) break;
|
|
67
|
+
}
|
|
68
|
+
if (txs > 1 && keep.length) {
|
|
69
|
+
const last = keep.length - 1;
|
|
70
|
+
if (/^tx /i.test(keep[last])) keep[last] += ` (+${txs - 1} earlier)`;
|
|
71
|
+
}
|
|
72
|
+
return keep.join('\n');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Pure. Same split the renderer IIFE uses.
|
|
76
|
+
* vis text from TreeWalker has NO newlines between React text nodes, so
|
|
77
|
+
* `[^\n]+` used to swallow the whole footer and skip the pill (body < 12). */
|
|
22
78
|
export function splitSpendText(text) {
|
|
23
79
|
const s = String(text || '');
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
80
|
+
const mark = s.search(/::oz-spend::/i);
|
|
81
|
+
let head = '';
|
|
82
|
+
let raw = '';
|
|
83
|
+
if (mark >= 0) {
|
|
84
|
+
head = s.slice(0, mark);
|
|
85
|
+
raw = s.slice(mark + '::oz-spend::'.length);
|
|
86
|
+
} else {
|
|
87
|
+
const m = s.match(/(?:this call \$|spent \$)[\s\S]*$/i);
|
|
88
|
+
if (!m) return null;
|
|
89
|
+
head = s.slice(0, m.index);
|
|
90
|
+
raw = m[0];
|
|
35
91
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
92
|
+
const bodyAt = raw.search(/(?:this call \$)|(?:spent \$)/i);
|
|
93
|
+
let summary = '';
|
|
94
|
+
let body = raw;
|
|
95
|
+
if (bodyAt > 0) {
|
|
96
|
+
summary = raw.slice(0, bodyAt).replace(/^\s+|\s+$/g, '');
|
|
97
|
+
body = raw.slice(bodyAt);
|
|
98
|
+
} else if (bodyAt === 0) {
|
|
99
|
+
summary = '';
|
|
100
|
+
body = raw;
|
|
101
|
+
} else {
|
|
102
|
+
const nl = raw.indexOf('\n');
|
|
103
|
+
if (nl >= 0) {
|
|
104
|
+
summary = raw.slice(0, nl).trim();
|
|
105
|
+
body = raw.slice(nl + 1);
|
|
106
|
+
}
|
|
43
107
|
}
|
|
44
|
-
|
|
108
|
+
body = spendLinesOnly(body);
|
|
109
|
+
if (!body || body.length < 8) return null;
|
|
110
|
+
summary = labelFromSpendBody(body, summary);
|
|
111
|
+
return { head, summary: summary || 'spend', body };
|
|
45
112
|
}
|
|
46
113
|
|
|
47
114
|
export function spendOnlyText(t) {
|
|
@@ -56,6 +123,9 @@ export function spendOnlyText(t) {
|
|
|
56
123
|
.replace(/saved \$[0-9.]+/gi, ' ')
|
|
57
124
|
.replace(/balance \$[0-9.]+/gi, ' ')
|
|
58
125
|
.replace(/\(\s*\d+%\s*\)/g, ' ')
|
|
126
|
+
.replace(/\d+%\s*\/\s*[\d.]+×/g, ' ')
|
|
127
|
+
.replace(/\d+%/g, ' ')
|
|
128
|
+
.replace(/[×xX]/g, ' ')
|
|
59
129
|
.replace(/\b(?:tx|memo|proves)\b[^\n]*/gi, ' ')
|
|
60
130
|
.replace(/https?:\/\/\S+/gi, ' ')
|
|
61
131
|
.replace(/[·•.,;:/$%\d\s()[\]+\-]/g, '');
|
|
@@ -69,7 +139,8 @@ function ozEnsureSpendCss() {
|
|
|
69
139
|
s.textContent = [
|
|
70
140
|
'.oz-spend{margin:.55rem 0 0;font-size:12px;color:inherit;opacity:.82;max-width:36em}',
|
|
71
141
|
'.oz-spend>summary{cursor:help;list-style:none;display:inline-flex;align-items:center;gap:.35rem;',
|
|
72
|
-
'padding:3px 9px;border-radius:999px;border:1px solid rgba(255,255,255,.18)
|
|
142
|
+
'padding:3px 9px;border-radius:999px;border:1px solid rgba(255,255,255,.18);white-space:nowrap;',
|
|
143
|
+
'max-width:100%;overflow:hidden;text-overflow:ellipsis}',
|
|
73
144
|
'.oz-spend>summary::-webkit-details-marker{display:none}',
|
|
74
145
|
'.oz-spend-body{white-space:pre-wrap;margin:.55rem 0 0;font-size:11px;line-height:1.45;opacity:.88;overflow-wrap:anywhere}',
|
|
75
146
|
'[data-oz-spend-hide]{display:none!important}',
|
|
@@ -165,7 +236,14 @@ function ozPreviousMessageCard(el) {
|
|
|
165
236
|
|
|
166
237
|
function ozAttachSpendChip(host, split) {
|
|
167
238
|
if (!host || !split) return;
|
|
168
|
-
|
|
239
|
+
const existing = host.querySelector && host.querySelector('.oz-spend');
|
|
240
|
+
if (existing) {
|
|
241
|
+
const sum = existing.querySelector('summary');
|
|
242
|
+
const body = existing.querySelector('.oz-spend-body');
|
|
243
|
+
if (sum) sum.textContent = 'ⓘ ' + split.summary;
|
|
244
|
+
if (body) body.textContent = split.body;
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
169
247
|
const d = document.createElement('details');
|
|
170
248
|
d.className = 'oz-spend';
|
|
171
249
|
const sum = document.createElement('summary');
|
|
@@ -232,8 +310,11 @@ export function spendChipSource() {
|
|
|
232
310
|
return [
|
|
233
311
|
'(function ozSpendChip(){',
|
|
234
312
|
"'use strict';",
|
|
235
|
-
'if (window.__OZ_SPEND_CHIP__) return;',
|
|
236
|
-
'window.__OZ_SPEND_CHIP__ =
|
|
313
|
+
'if (window.__OZ_SPEND_CHIP__ === 3) return;',
|
|
314
|
+
'window.__OZ_SPEND_CHIP__ = 3;',
|
|
315
|
+
chipUsd.toString(),
|
|
316
|
+
labelFromSpendBody.toString(),
|
|
317
|
+
spendLinesOnly.toString(),
|
|
237
318
|
splitSpendText.toString(),
|
|
238
319
|
spendOnlyText.toString(),
|
|
239
320
|
ozEnsureSpendCss.toString(),
|
package/lib/spendProof.js
CHANGED
|
@@ -250,9 +250,53 @@ export function mergeTurnProof(prev, data) {
|
|
|
250
250
|
}
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
+
function chipUsd(n) {
|
|
254
|
+
const x = Number(n) || 0;
|
|
255
|
+
if (Math.abs(x) >= 0.01) return `$${x.toFixed(2)}`;
|
|
256
|
+
return `$${x.toFixed(4)}`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function chipMult(would, spent) {
|
|
260
|
+
if (!(spent > 0) || !(would > 0)) return null;
|
|
261
|
+
const m = would / spent;
|
|
262
|
+
if (!Number.isFinite(m) || m < 1) return null;
|
|
263
|
+
if (m >= 100) return `${Math.round(m)}×`;
|
|
264
|
+
if (m >= 10) return `${m.toFixed(1)}×`;
|
|
265
|
+
return `${m.toFixed(2)}×`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Collapsed chip: spent · saved $ · saved % / multiplier. Visible before click. */
|
|
269
|
+
export function spendChipLabel({
|
|
270
|
+
billedUsd,
|
|
271
|
+
directUsd,
|
|
272
|
+
spent = 0,
|
|
273
|
+
would = 0,
|
|
274
|
+
saved = 0,
|
|
275
|
+
pct = 0,
|
|
276
|
+
} = {}) {
|
|
277
|
+
let spentN = Number(spent);
|
|
278
|
+
let wouldN = Number(would);
|
|
279
|
+
let savedN = Number(saved);
|
|
280
|
+
if (!(spentN > 0.00005) && Number(billedUsd) > 0.00005) {
|
|
281
|
+
spentN = Number(billedUsd);
|
|
282
|
+
wouldN = Number(directUsd) > 0 ? Number(directUsd) : wouldN;
|
|
283
|
+
savedN = Math.max(0, wouldN - spentN);
|
|
284
|
+
}
|
|
285
|
+
if (!(savedN > 0) && wouldN > spentN) savedN = wouldN - spentN;
|
|
286
|
+
let pctN = Number(pct);
|
|
287
|
+
if (wouldN > 0) pctN = 100 * Math.max(0, savedN) / wouldN;
|
|
288
|
+
const bits = [chipUsd(spentN), `saved ${chipUsd(savedN)}`];
|
|
289
|
+
const sav = [];
|
|
290
|
+
if (Number.isFinite(pctN)) sav.push(`${Math.round(pctN)}%`);
|
|
291
|
+
const mult = chipMult(wouldN, spentN);
|
|
292
|
+
if (mult) sav.push(mult);
|
|
293
|
+
if (sav.length) bits.push(sav.join('/'));
|
|
294
|
+
return bits.join(' · ');
|
|
295
|
+
}
|
|
296
|
+
|
|
253
297
|
/**
|
|
254
298
|
* Two leading newlines, then the existing spend lines, then optional
|
|
255
|
-
* tx / memo / proves. Never throws.
|
|
299
|
+
* tx / memo / proves. Never throws. Collapsed chip reads the ::oz-spend:: line.
|
|
256
300
|
*/
|
|
257
301
|
export function formatSpendFooter({
|
|
258
302
|
billedUsd,
|
|
@@ -280,15 +324,18 @@ export function formatSpendFooter({
|
|
|
280
324
|
const savedN = Number.isFinite(Number(saved)) ? Number(saved) : 0;
|
|
281
325
|
const pctN = Number.isFinite(Number(pct)) ? Number(pct) : 0;
|
|
282
326
|
const bal = balance != null && Number.isFinite(Number(balance)) ? Number(balance) : null;
|
|
283
|
-
const balTxt = bal != null ? ` · balance $${bal.toFixed(2)}` : '';
|
|
327
|
+
const balTxt = bal != null && bal > 0.004 ? ` · balance $${bal.toFixed(2)}` : '';
|
|
284
328
|
lines.push(`spent $${spentN.toFixed(4)}${balTxt} · OpenRouter would $${wouldN.toFixed(4)} · saved $${savedN.toFixed(4)} (${pctN.toFixed(0)}%)`);
|
|
285
329
|
|
|
286
330
|
const sigs = collectTxs({ tx: tx ?? x.tx, txs: txs ?? x.txs });
|
|
287
331
|
const r = rail || x.rail;
|
|
288
332
|
const net = network || x.network;
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
333
|
+
const last = sigs[sigs.length - 1];
|
|
334
|
+
if (last) {
|
|
335
|
+
const url = explorerUrl(last, { rail: r, network: net });
|
|
336
|
+
if (url) {
|
|
337
|
+
lines.push(sigs.length > 1 ? `tx ${url} (+${sigs.length - 1} earlier)` : `tx ${url}`);
|
|
338
|
+
}
|
|
292
339
|
}
|
|
293
340
|
const mem = memo ?? x.memo;
|
|
294
341
|
if (mem != null && String(mem).length) {
|
|
@@ -297,9 +344,9 @@ export function formatSpendFooter({
|
|
|
297
344
|
if (d.proves) lines.push(`proves ${d.proves}`);
|
|
298
345
|
}
|
|
299
346
|
const body = lines.filter((l) => l !== '').join('\n');
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
347
|
+
const tag = spendChipLabel({
|
|
348
|
+
billedUsd, directUsd, spent: spentN, would: wouldN, saved: savedN, pct: pctN,
|
|
349
|
+
});
|
|
303
350
|
return `\n\n::oz-spend::${tag}\n${body}`;
|
|
304
351
|
} catch {
|
|
305
352
|
return '\n\n';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.50.
|
|
3
|
+
"version": "0.50.43",
|
|
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",
|