openzoo 0.50.40 → 0.50.42

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.
@@ -46,6 +46,9 @@ import { prefixVisitorRichText } from './grokbotweb.js';
46
46
  import {
47
47
  ingestUpload, lookupUpload, readUploadChunk, readUploadImage, readUploadText,
48
48
  } from './grokbotUploads.js';
49
+ import {
50
+ desktopAction, displayBounds, imageSize, noteShotMeta, resolveAppName,
51
+ } from './grokbotDesktop.js';
49
52
 
50
53
  const TLS_DIR = path.join(os.homedir(), '.openzoo', 'cursor-tls');
51
54
  const CURSOR_HOSTS = ['api2.cursor.sh', 'api3.cursor.sh', 'api4.cursor.sh', 'repo42.cursor.sh'];
@@ -1181,7 +1184,10 @@ export function formatZooProgress({ step, maxSteps, names, command } = {}) {
1181
1184
  /** One canvas line per tool. Asar ingest is append-only — mutating a working
1182
1185
  * bubble is still silence. Keep it short; the model history skips ephemeral. */
1183
1186
  export function formatZooToolLine({ name, args = {}, result } = {}) {
1184
- const detail = args.command || args.path || args.name || args.window || '';
1187
+ const detail = args.command || args.path || args.name || args.window
1188
+ || args.query || args.url || args.key || args.app
1189
+ || (args.x != null && args.y != null ? `${args.x},${args.y}` : '')
1190
+ || (args.text != null ? String(args.text).slice(0, 80) : '');
1185
1191
  const head = detail
1186
1192
  ? `${name} ${JSON.stringify(String(detail).slice(0, 90))}`
1187
1193
  : String(name || 'tool');
@@ -1601,6 +1607,11 @@ const MODEL_ALIASES = {
1601
1607
  grok: 'x-ai/grok-4.6',
1602
1608
  'grok-4': 'x-ai/grok-4.6',
1603
1609
  'grok-4.6': 'x-ai/grok-4.6',
1610
+ glm: 'zai-org/glm-5.3-flash',
1611
+ 'glm-5': 'zai-org/glm-5.3-flash',
1612
+ 'glm-5.3': 'zai-org/glm-5.3-flash',
1613
+ 'glm-5.3-flash': 'zai-org/glm-5.3-flash',
1614
+ flash: 'zai-org/glm-5.3-flash',
1604
1615
  };
1605
1616
  function resolveModelId(raw) {
1606
1617
  const s = String(raw || '').trim();
@@ -1613,7 +1624,7 @@ function resolveModelId(raw) {
1613
1624
  function currentModel(agentId) {
1614
1625
  return agentModels.get(agentId)
1615
1626
  || process.env.OPENZOO_DEFAULT_MODEL
1616
- || 'x-ai/grok-4.6';
1627
+ || 'zai-org/glm-5.3-flash';
1617
1628
  }
1618
1629
 
1619
1630
  const execFileAsync = promisify(execFile);
@@ -1765,7 +1776,7 @@ const LOCAL_TOOLS = [
1765
1776
  type: 'function',
1766
1777
  function: {
1767
1778
  name: 'screenshot',
1768
- description: 'Capture the Mac screen (or the front window) and attach the image so you can SEE the UI. Use this before filling forms or clicking. Do not guess at a dashboard you have not screenshotted this turn.',
1779
+ description: 'Capture the Mac display and attach the image. Returns screen.width/height in POINTS for click x,y. Screenshot first, then click/type, then screenshot again to confirm. Do not guess at a form you have not seen this turn.',
1769
1780
  parameters: {
1770
1781
  type: 'object',
1771
1782
  properties: {
@@ -1774,6 +1785,102 @@ const LOCAL_TOOLS = [
1774
1785
  },
1775
1786
  },
1776
1787
  },
1788
+ {
1789
+ type: 'function',
1790
+ function: {
1791
+ name: 'click',
1792
+ description: 'Click the Mac UI. Prefer query (AX title/button text like "Submit" or "Complete form"). Or x,y in SCREEN POINTS from screenshot.screen (not image pixels). Use image_x/image_y if you measured the attached screenshot. This is how you fill and submit browser forms.',
1793
+ parameters: {
1794
+ type: 'object',
1795
+ properties: {
1796
+ x: { type: 'number' },
1797
+ y: { type: 'number' },
1798
+ image_x: { type: 'number', description: 'X in the attached screenshot pixels; mapped via last screenshot.' },
1799
+ image_y: { type: 'number' },
1800
+ query: { type: 'string', description: 'Visible name: Submit, Complete form, Compose, …' },
1801
+ app: { type: 'string', description: 'Brave Browser, Brave, Grok Bot, …' },
1802
+ button: { type: 'string', enum: ['left', 'right'] },
1803
+ double: { type: 'boolean' },
1804
+ },
1805
+ },
1806
+ },
1807
+ },
1808
+ {
1809
+ type: 'function',
1810
+ function: {
1811
+ name: 'type_text',
1812
+ description: 'Type into the focused field. Click the field first. Long text is pasted. Use this to fill form inputs.',
1813
+ parameters: {
1814
+ type: 'object',
1815
+ properties: {
1816
+ text: { type: 'string' },
1817
+ paste: { type: 'boolean' },
1818
+ app: { type: 'string' },
1819
+ },
1820
+ required: ['text'],
1821
+ },
1822
+ },
1823
+ },
1824
+ {
1825
+ type: 'function',
1826
+ function: {
1827
+ name: 'key',
1828
+ description: 'Press a key. enter/return submits, tab moves fields, escape cancels. cmd+l focuses the URL bar.',
1829
+ parameters: {
1830
+ type: 'object',
1831
+ properties: {
1832
+ key: { type: 'string', description: 'enter, tab, escape, space, a letter, or a named key' },
1833
+ cmd: { type: 'boolean' },
1834
+ alt: { type: 'boolean' },
1835
+ shift: { type: 'boolean' },
1836
+ ctrl: { type: 'boolean' },
1837
+ app: { type: 'string' },
1838
+ },
1839
+ required: ['key'],
1840
+ },
1841
+ },
1842
+ },
1843
+ {
1844
+ type: 'function',
1845
+ function: {
1846
+ name: 'ui_tree',
1847
+ description: 'Dump clickable AX controls of the front window (or app) with screen x,y. Use to find Submit/text fields when the screenshot is ambiguous.',
1848
+ parameters: {
1849
+ type: 'object',
1850
+ properties: {
1851
+ app: { type: 'string' },
1852
+ limit: { type: 'number' },
1853
+ },
1854
+ },
1855
+ },
1856
+ },
1857
+ {
1858
+ type: 'function',
1859
+ function: {
1860
+ name: 'focus_app',
1861
+ description: 'Bring an app to the front. Use "Brave Browser" before clicking Gmail/Stripe.',
1862
+ parameters: {
1863
+ type: 'object',
1864
+ properties: { app: { type: 'string' } },
1865
+ required: ['app'],
1866
+ },
1867
+ },
1868
+ },
1869
+ {
1870
+ type: 'function',
1871
+ function: {
1872
+ name: 'open_url',
1873
+ description: 'Open an http(s) URL in Brave (or app). Prefer this over guessing at the address bar.',
1874
+ parameters: {
1875
+ type: 'object',
1876
+ properties: {
1877
+ url: { type: 'string' },
1878
+ app: { type: 'string' },
1879
+ },
1880
+ required: ['url'],
1881
+ },
1882
+ },
1883
+ },
1777
1884
  {
1778
1885
  type: 'function',
1779
1886
  function: {
@@ -1803,18 +1910,29 @@ async function captureScreenshot(log) {
1803
1910
  if (!fs.existsSync(raw) || fs.statSync(raw).size < 80) {
1804
1911
  throw new Error('screencapture wrote nothing — Screen Recording permission may be off for Grok Bot / Terminal');
1805
1912
  }
1913
+ let screen = { width: 0, height: 0 };
1914
+ try { screen = await displayBounds(); } catch (e) {
1915
+ log(`cursor-backend: displayBounds failed: ${e.message}`);
1916
+ }
1917
+ let out = { path: raw, mime: 'image/png', buf: fs.readFileSync(raw) };
1806
1918
  try {
1807
1919
  await execFileAsync('sips', [
1808
1920
  '-Z', '1400', '-s', 'format', 'jpeg', '-s', 'formatOptions', '70',
1809
1921
  raw, '--out', jpg,
1810
1922
  ], { timeout: 15000 });
1811
1923
  if (fs.existsSync(jpg) && fs.statSync(jpg).size > 80) {
1812
- return { path: jpg, mime: 'image/jpeg', buf: fs.readFileSync(jpg) };
1924
+ out = { path: jpg, mime: 'image/jpeg', buf: fs.readFileSync(jpg) };
1813
1925
  }
1814
1926
  } catch (e) {
1815
1927
  log(`cursor-backend: sips screenshot resize failed: ${e.message}`);
1816
1928
  }
1817
- return { path: raw, mime: 'image/png', buf: fs.readFileSync(raw) };
1929
+ let image = { width: 0, height: 0 };
1930
+ try { image = await imageSize(out.path); } catch (e) {
1931
+ log(`cursor-backend: imageSize failed: ${e.message}`);
1932
+ }
1933
+ const meta = { path: out.path, mime: out.mime, buf: out.buf, screen, image };
1934
+ noteShotMeta({ screen, image, path: out.path });
1935
+ return meta;
1818
1936
  }
1819
1937
 
1820
1938
  async function runLocalTool(name, args, log) {
@@ -1847,9 +1965,36 @@ async function runLocalTool(name, args, log) {
1847
1965
  path: shot.path,
1848
1966
  mime: shot.mime,
1849
1967
  bytes: shot.buf.length,
1968
+ screen: shot.screen,
1969
+ image: shot.image,
1970
+ click: 'x,y are SCREEN points (screenshot.screen). Or pass image_x,image_y from this image. Prefer click query="Submit".',
1850
1971
  dataUrl: `data:${shot.mime};base64,${shot.buf.toString('base64')}`,
1851
1972
  });
1852
1973
  }
1974
+ if (name === 'click') {
1975
+ const got = await desktopAction('click', args, log);
1976
+ return JSON.stringify(got);
1977
+ }
1978
+ if (name === 'type_text') {
1979
+ const got = await desktopAction('type', args, log);
1980
+ return JSON.stringify(got);
1981
+ }
1982
+ if (name === 'key') {
1983
+ const got = await desktopAction('key', args, log);
1984
+ return JSON.stringify(got);
1985
+ }
1986
+ if (name === 'ui_tree') {
1987
+ const got = await desktopAction('tree', args, log);
1988
+ return JSON.stringify(got);
1989
+ }
1990
+ if (name === 'focus_app') {
1991
+ const got = await desktopAction('focus', { app: resolveAppName(args.app) }, log);
1992
+ return JSON.stringify(got);
1993
+ }
1994
+ if (name === 'open_url') {
1995
+ const got = await desktopAction('open_url', args, log);
1996
+ return JSON.stringify(got);
1997
+ }
1853
1998
  if (name === 'create_agent') {
1854
1999
  const agent = mintLocalAgent({
1855
2000
  name: String(args.name || args.title || 'New Bot').slice(0, 80),
@@ -1948,8 +2093,10 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
1948
2093
  : [
1949
2094
  `You are ${model} served through openzoo inside Grok Bot.`,
1950
2095
  `You HAVE local tools on the user's computer via ${via}.`,
1951
- 'Tools: read_file, write_file, exec, list_dir, screenshot, create_agent. USE THEM. Write files to disk instead of pasting giant HTML into chat.',
2096
+ 'Tools: read_file, write_file, exec, list_dir, screenshot, click, type_text, key, ui_tree, focus_app, open_url, create_agent.',
1952
2097
  'create_agent mints a new Grok Bot in the sidebar. When asked to spawn bots, call it — do not only describe spawning.',
2098
+ '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
+ '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".',
1953
2100
  '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.',
1954
2101
  'Pasted images arrive as attachments — you can see them when present. Do not claim you cannot see images if they are in this turn.',
1955
2102
  'Never claim you lack filesystem access or local-exec. If a tool errors, report the error.',
@@ -2485,7 +2632,7 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
2485
2632
  const want = modelCmd[1];
2486
2633
  if (!want) {
2487
2634
  const cur = currentModel(agentId);
2488
- text = `current model: ${cur}\nset with /model fable | opus | sonnet | grok | provider/id`;
2635
+ text = `current model: ${cur}\nset with /model fable | opus | sonnet | grok | glm | provider/id`;
2489
2636
  } else {
2490
2637
  const id = resolveModelId(want) || (want.includes('/') ? want : null);
2491
2638
  if (!id) {
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Drive the Mac UI from Grok Bot tools: click, type, key, AX dump.
3
+ * JXA + CGEvent (no cliclick). Needs Accessibility for the node process
4
+ * that runs `openzoo bot` (Terminal / iTerm / Grok).
5
+ */
6
+ import { execFile } from 'node:child_process';
7
+ import { promisify } from 'node:util';
8
+ import { fileURLToPath } from 'node:url';
9
+ import path from 'node:path';
10
+ import fs from 'node:fs';
11
+
12
+ const execFileAsync = promisify(execFile);
13
+ const JXA_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), 'grokbotDesktop.jxa');
14
+
15
+ const APP_ALIASES = {
16
+ brave: 'Brave Browser',
17
+ chrome: 'Google Chrome',
18
+ safari: 'Safari',
19
+ grok: 'Grok Bot',
20
+ finder: 'Finder',
21
+ };
22
+
23
+ export function resolveAppName(raw) {
24
+ const s = String(raw || '').trim();
25
+ if (!s) return '';
26
+ const hit = APP_ALIASES[s.toLowerCase()];
27
+ return hit || s;
28
+ }
29
+
30
+ export function mapImageClick(imageX, imageY, meta) {
31
+ const iw = Number(meta?.image?.width) || 0;
32
+ const ih = Number(meta?.image?.height) || 0;
33
+ const sw = Number(meta?.screen?.width) || 0;
34
+ const sh = Number(meta?.screen?.height) || 0;
35
+ if (!iw || !ih || !sw || !sh) return null;
36
+ return {
37
+ x: Math.round((Number(imageX) / iw) * sw),
38
+ y: Math.round((Number(imageY) / ih) * sh),
39
+ };
40
+ }
41
+
42
+ let lastShotMeta = null;
43
+ export function noteShotMeta(meta) {
44
+ lastShotMeta = meta && typeof meta === 'object' ? meta : null;
45
+ return lastShotMeta;
46
+ }
47
+ export function lastShot() {
48
+ return lastShotMeta;
49
+ }
50
+
51
+ export async function displayBounds() {
52
+ const { stdout } = await execFileAsync('osascript', [
53
+ '-e',
54
+ 'tell application "Finder" to get bounds of window of desktop',
55
+ ], { timeout: 8000 });
56
+ const n = String(stdout).match(/-?\d+/g)?.map(Number) || [];
57
+ const [x0, y0, x1, y1] = n;
58
+ if (![x0, y0, x1, y1].every(Number.isFinite)) {
59
+ throw new Error(`bad display bounds: ${stdout.trim()}`);
60
+ }
61
+ return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
62
+ }
63
+
64
+ export async function imageSize(p) {
65
+ const { stdout } = await execFileAsync('sips', ['-g', 'pixelWidth', '-g', 'pixelHeight', p], { timeout: 8000 });
66
+ const w = Number(String(stdout).match(/pixelWidth:\s*(\d+)/)?.[1]);
67
+ const h = Number(String(stdout).match(/pixelHeight:\s*(\d+)/)?.[1]);
68
+ if (!w || !h) throw new Error(`sips no size for ${p}`);
69
+ return { width: w, height: h };
70
+ }
71
+
72
+ export async function desktopAction(op, args = {}, log = () => {}) {
73
+ if (!fs.existsSync(JXA_PATH)) throw new Error(`missing ${JXA_PATH}`);
74
+ const payload = { op: String(op || ''), ...args };
75
+ if (payload.app) payload.app = resolveAppName(payload.app);
76
+ if (payload.image_x != null || payload.imageX != null) {
77
+ const mapped = mapImageClick(payload.image_x ?? payload.imageX, payload.image_y ?? payload.imageY, lastShotMeta);
78
+ if (mapped) {
79
+ payload.x = mapped.x;
80
+ payload.y = mapped.y;
81
+ payload.mappedFromImage = true;
82
+ }
83
+ }
84
+ log(`cursor-backend: desktop ${payload.op} ${JSON.stringify({
85
+ x: payload.x, y: payload.y, query: payload.query, key: payload.key, app: payload.app,
86
+ n: payload.text ? String(payload.text).length : undefined,
87
+ })}`);
88
+ const { stdout, stderr } = await execFileAsync('osascript', [
89
+ '-l', 'JavaScript', JXA_PATH, JSON.stringify(payload),
90
+ ], { timeout: 20000, maxBuffer: 2 * 1024 * 1024 });
91
+ const raw = String(stdout || '').trim() || String(stderr || '').trim();
92
+ try { return JSON.parse(raw); } catch {
93
+ return { ok: false, error: raw || 'empty jxa' };
94
+ }
95
+ }
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/osascript -l JavaScript
2
+ // Grok Bot desktop driver. argv[0] is a JSON payload {op, ...}.
3
+ ObjC.import('Cocoa');
4
+ ObjC.import('CoreGraphics');
5
+
6
+ function run(argv) {
7
+ var a = {};
8
+ try { a = JSON.parse(argv[0] || '{}'); } catch (e) {
9
+ return JSON.stringify({ ok: false, error: 'bad json: ' + e.message });
10
+ }
11
+ try {
12
+ return JSON.stringify(dispatch(a));
13
+ } catch (e) {
14
+ return JSON.stringify({ ok: false, error: String(e.message || e) });
15
+ }
16
+ }
17
+
18
+ function dispatch(a) {
19
+ var op = String(a.op || '');
20
+ if (op === 'bounds') return bounds();
21
+ if (op === 'focus') return focusApp(a.app);
22
+ if (op === 'click') return click(a);
23
+ if (op === 'type') return typeText(a);
24
+ if (op === 'key') return keyPress(a);
25
+ if (op === 'tree') return uiTree(a);
26
+ if (op === 'open_url') return openUrl(a);
27
+ throw new Error('unknown op ' + op);
28
+ }
29
+
30
+ function bounds() {
31
+ var f = $.NSScreen.mainScreen.frame;
32
+ // Cocoa origin is bottom-left; width/height are points (not retina pixels).
33
+ return {
34
+ ok: true,
35
+ width: f.size.width,
36
+ height: f.size.height,
37
+ x: f.origin.x,
38
+ y: f.origin.y,
39
+ };
40
+ }
41
+
42
+ function focusApp(name) {
43
+ name = String(name || '').trim();
44
+ if (!name) throw new Error('app required');
45
+ var app = Application(name);
46
+ app.activate();
47
+ delay(0.25);
48
+ return { ok: true, app: name };
49
+ }
50
+
51
+ function click(a) {
52
+ if (a.app) focusApp(a.app);
53
+ if (a.query) {
54
+ var hit = findQuery(a.query, a.app);
55
+ if (!hit) throw new Error('no AX match for ' + a.query);
56
+ a.x = hit.x + Math.floor((hit.w || 8) / 2);
57
+ a.y = hit.y + Math.floor((hit.h || 8) / 2);
58
+ a.matched = hit.title || hit.role;
59
+ }
60
+ var x = Number(a.x);
61
+ var y = Number(a.y);
62
+ if (!isFinite(x) || !isFinite(y)) throw new Error('click needs x,y or query');
63
+ // CGEvent uses top-left origin in global display points — same as AX position.
64
+ var pt = $.CGPointMake(x, y);
65
+ var move = $.CGEventCreateMouseEvent(null, $.kCGEventMouseMoved, pt, 0);
66
+ $.CGEventPost($.kCGHIDEventTap, move);
67
+ delay(0.04);
68
+ var btn = String(a.button || 'left') === 'right' ? $.kCGMouseButtonRight : $.kCGMouseButtonLeft;
69
+ var downType = btn === $.kCGMouseButtonRight ? $.kCGEventRightMouseDown : $.kCGEventLeftMouseDown;
70
+ var upType = btn === $.kCGMouseButtonRight ? $.kCGEventRightMouseUp : $.kCGEventLeftMouseUp;
71
+ var down = $.CGEventCreateMouseEvent(null, downType, pt, btn);
72
+ var up = $.CGEventCreateMouseEvent(null, upType, pt, btn);
73
+ $.CGEventPost($.kCGHIDEventTap, down);
74
+ delay(0.03);
75
+ $.CGEventPost($.kCGHIDEventTap, up);
76
+ if (a.double) {
77
+ delay(0.05);
78
+ $.CGEventPost($.kCGHIDEventTap, down);
79
+ delay(0.03);
80
+ $.CGEventPost($.kCGHIDEventTap, up);
81
+ }
82
+ return { ok: true, x: x, y: y, matched: a.matched || null, mappedFromImage: !!a.mappedFromImage };
83
+ }
84
+
85
+ function typeText(a) {
86
+ if (a.app) focusApp(a.app);
87
+ var text = String(a.text == null ? '' : a.text);
88
+ if (!text) throw new Error('text required');
89
+ var se = Application('System Events');
90
+ var paste = a.paste === true || (a.paste !== false && text.length > 20);
91
+ if (paste) {
92
+ var app = Application.currentApplication();
93
+ app.includeStandardAdditions = true;
94
+ app.setTheClipboardTo(text);
95
+ delay(0.05);
96
+ se.keystroke('v', { using: 'command down' });
97
+ return { ok: true, method: 'paste', n: text.length };
98
+ }
99
+ se.keystroke(text);
100
+ return { ok: true, method: 'keystroke', n: text.length };
101
+ }
102
+
103
+ var KEYCODES = {
104
+ enter: 36, return: 36, tab: 48, escape: 53, esc: 53, space: 49,
105
+ delete: 51, backspace: 51, up: 126, down: 125, left: 123, right: 124,
106
+ };
107
+
108
+ function keyPress(a) {
109
+ if (a.app) focusApp(a.app);
110
+ var key = String(a.key || '').toLowerCase();
111
+ if (!key) throw new Error('key required');
112
+ var using = [];
113
+ if (a.cmd || a.command || a.meta) using.push('command down');
114
+ if (a.alt || a.option) using.push('option down');
115
+ if (a.shift) using.push('shift down');
116
+ if (a.ctrl || a.control) using.push('control down');
117
+ var se = Application('System Events');
118
+ var opts = using.length ? { using: using } : {};
119
+ var code = KEYCODES[key];
120
+ if (code != null) se.keyCode(code, opts);
121
+ else se.keystroke(key.length === 1 ? key : key.charAt(0), opts);
122
+ return { ok: true, key: key, mods: using };
123
+ }
124
+
125
+ function openUrl(a) {
126
+ var url = String(a.url || a.href || '').trim();
127
+ if (!/^https?:\/\//i.test(url)) throw new Error('url must be http(s)');
128
+ var appName = resolveBrave(a.app);
129
+ var app = Application(appName);
130
+ app.activate();
131
+ delay(0.2);
132
+ try {
133
+ app.windows[0].activeTab.url = url;
134
+ } catch (e) {
135
+ app.openLocation(url);
136
+ }
137
+ return { ok: true, app: appName, url: url };
138
+ }
139
+
140
+ function resolveBrave(name) {
141
+ name = String(name || 'Brave Browser');
142
+ if (/brave/i.test(name)) return 'Brave Browser';
143
+ return name;
144
+ }
145
+
146
+ function uiTree(a) {
147
+ var se = Application('System Events');
148
+ var proc;
149
+ if (a.app) {
150
+ proc = se.processes.byName(String(a.app));
151
+ } else {
152
+ proc = se.processes.whose({ frontmost: true })[0];
153
+ }
154
+ var limit = Math.min(120, Math.max(10, Number(a.limit) || 60));
155
+ var rows = [];
156
+ var win;
157
+ try { win = proc.windows[0]; } catch (e) { throw new Error('no window for ' + (a.app || 'front app')); }
158
+ function walk(el, depth) {
159
+ if (rows.length >= limit || depth > 10) return;
160
+ var role = '', title = '', val = '', pos = null, size = null;
161
+ try { role = String(el.role()); } catch (e) {}
162
+ try { title = String(el.title() || el.description() || el.name() || ''); } catch (e) {}
163
+ try {
164
+ var v = el.value();
165
+ val = v == null ? '' : String(v);
166
+ } catch (e) {}
167
+ try { pos = el.position(); } catch (e) {}
168
+ try { size = el.size(); } catch (e) {}
169
+ var interesting = /button|text|field|link|check|pop|menu|tab|combo|scroll|heading|web/i.test(role + ' ' + title);
170
+ if (interesting && (title || val || /button|field|link|check/i.test(role))) {
171
+ rows.push({
172
+ role: role,
173
+ title: title.slice(0, 80),
174
+ value: val.slice(0, 80),
175
+ x: pos ? pos[0] : null,
176
+ y: pos ? pos[1] : null,
177
+ w: size ? size[0] : null,
178
+ h: size ? size[1] : null,
179
+ });
180
+ }
181
+ var kids = [];
182
+ try { kids = el.uiElements(); } catch (e) { return; }
183
+ for (var i = 0; i < kids.length; i++) walk(kids[i], depth + 1);
184
+ }
185
+ walk(win, 0);
186
+ var title = '';
187
+ try { title = String(win.title()); } catch (e) {}
188
+ var pname = '';
189
+ try { pname = String(proc.name()); } catch (e) {}
190
+ return { ok: true, app: pname, window: title, n: rows.length, rows: rows };
191
+ }
192
+
193
+ function findQuery(q, app) {
194
+ var tree = uiTree({ app: app, limit: 120 });
195
+ var want = String(q || '').toLowerCase();
196
+ if (!want) return null;
197
+ var rows = tree.rows || [];
198
+ for (var i = 0; i < rows.length; i++) {
199
+ var r = rows[i];
200
+ var blob = ((r.title || '') + ' ' + (r.value || '') + ' ' + (r.role || '')).toLowerCase();
201
+ if (blob.indexOf(want) >= 0 && r.x != null && r.y != null) return r;
202
+ }
203
+ return null;
204
+ }
@@ -143,7 +143,7 @@
143
143
  autoUpdateWhenIdleOptIn: false,
144
144
  autoUpdateWhenIdleGateEnabled: false,
145
145
  };
146
- const DEFAULT_MODEL = { modelId: 'x-ai/grok-4.6', maxMode: true, parameters: [] };
146
+ const DEFAULT_MODEL = { modelId: 'zai-org/glm-5.3-flash', maxMode: true, parameters: [] };
147
147
  let defaultModel = DEFAULT_MODEL;
148
148
  try {
149
149
  const raw = localStorage.getItem('sand.p.default-model');
@@ -300,7 +300,7 @@
300
300
  setComputerUseModel: async () => null,
301
301
  getAvailableModels: async () => ({
302
302
  models: [
303
- { modelId: 'x-ai/grok-4.6', maxMode: true, parameters: [] },
303
+ { modelId: 'zai-org/glm-5.3-flash', maxMode: true, parameters: [] },
304
304
  { modelId: 'x-ai/grok-4.5', maxMode: true, parameters: [] },
305
305
  ],
306
306
  }),
@@ -21,11 +21,11 @@ export function grokBotChromiumArgs(port = GROKBOT_CDP_PORT) {
21
21
  /** Pure. Same split the renderer IIFE uses. */
22
22
  export function splitSpendText(text) {
23
23
  const s = String(text || '');
24
- let m = s.match(/::oz-spend::(\$[0-9.]+|[^\n]*)\s*([\s\S]*)$/);
25
- if (m && (m[2] || '').length > 8) {
24
+ let m = s.match(/::oz-spend::([^\n]+)\n?([\s\S]*)$/);
25
+ if (m && ((m[2] || '').length > 8 || /\$[0-9.]/.test(m[1] || ''))) {
26
26
  let summary = (m[1] || '').trim();
27
- const body = m[2].trim();
28
- if (!/^\$[0-9.]+$/.test(summary) || summary === '$0.0000') {
27
+ const body = (m[2] || '').trim();
28
+ if (!summary || summary === '$0.0000' || summary === 'spend') {
29
29
  const spent = body.match(/spent \$([0-9.]+)/i);
30
30
  const call = body.match(/this call \$([0-9.]+)/i);
31
31
  const n = Number((call && Number(call[1]) > 0.00005 ? call[1] : null) || (spent && spent[1]) || 0);
@@ -69,7 +69,8 @@ function ozEnsureSpendCss() {
69
69
  s.textContent = [
70
70
  '.oz-spend{margin:.55rem 0 0;font-size:12px;color:inherit;opacity:.82;max-width:36em}',
71
71
  '.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)}',
72
+ 'padding:3px 9px;border-radius:999px;border:1px solid rgba(255,255,255,.18);white-space:nowrap;',
73
+ 'max-width:100%;overflow:hidden;text-overflow:ellipsis}',
73
74
  '.oz-spend>summary::-webkit-details-marker{display:none}',
74
75
  '.oz-spend-body{white-space:pre-wrap;margin:.55rem 0 0;font-size:11px;line-height:1.45;opacity:.88;overflow-wrap:anywhere}',
75
76
  '[data-oz-spend-hide]{display:none!important}',
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
- for (const sig of sigs) {
290
- const url = explorerUrl(sig, { rail: r, network: net });
291
- if (url) lines.push(`tx ${url}`);
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 billedN = Number(billedUsd);
301
- const tagN = Number.isFinite(billedN) && billedN > 0.00005 ? billedN : spentN;
302
- const tag = `$${tagN.toFixed(4)}`;
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.40",
3
+ "version": "0.50.42",
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",