@remcp/runtime 0.2.47 → 0.2.49
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/CHANGELOG.md +23 -0
- package/README.md +45 -26
- package/package.json +6 -2
- package/src/catalog.mjs +73 -1
- package/src/extended/browser.mjs +592 -0
- package/src/extended/catalog.mjs +564 -0
- package/src/extended/common.mjs +209 -0
- package/src/extended/desktop-linux.mjs +1173 -0
- package/src/extended/desktop-macos.mjs +408 -0
- package/src/extended/desktop-windows.mjs +546 -0
- package/src/extended/desktop.mjs +1313 -0
- package/src/extended/diagnostics.mjs +274 -0
- package/src/extended/documents.mjs +381 -0
- package/src/extended/ocr.mjs +139 -0
- package/src/helpers/wayland-eis-helper.py +450 -0
- package/src/index.mjs +46 -44
- package/src/invoke.mjs +3 -3
- package/src/mcp-v2.mjs +104 -0
- package/src/permissions.mjs +1 -1
- package/src/tools/terminal.mjs +58 -13
- package/src/util.mjs +24 -1
- package/src/wayland-remote-desktop.mjs +654 -0
|
@@ -0,0 +1,1313 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { fileToolHandlers } from '../tools/files.mjs';
|
|
4
|
+
import { liveConfig, runtimeConfig } from '../config.mjs';
|
|
5
|
+
import { assertAllowedCommand } from '../policy.mjs';
|
|
6
|
+
import { multi, resolveSafePath, text } from '../util.mjs';
|
|
7
|
+
import { clamp, jsonResult, optionalString, requireEnum } from './common.mjs';
|
|
8
|
+
import * as linux from './desktop-linux.mjs';
|
|
9
|
+
import * as macos from './desktop-macos.mjs';
|
|
10
|
+
import * as windows from './desktop-windows.mjs';
|
|
11
|
+
import { browserAction, browserCapabilityAvailable, browserSnapshot } from './browser.mjs';
|
|
12
|
+
import { findOcrText, ocrImage } from './ocr.mjs';
|
|
13
|
+
|
|
14
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
15
|
+
const adapter = process.platform === 'win32' ? windows : process.platform === 'darwin' ? macos : linux;
|
|
16
|
+
|
|
17
|
+
const UI_CACHE_TTL_MS = 5_000;
|
|
18
|
+
let latestUiSnapshot = null;
|
|
19
|
+
|
|
20
|
+
function suggestedUiAction(node = {}) {
|
|
21
|
+
const role = String(node.role || '').toLowerCase();
|
|
22
|
+
const patterns = Array.isArray(node.patterns) ? node.patterns.map(value => String(value).toLowerCase()) : [];
|
|
23
|
+
if (patterns.includes('toggle') || /check|switch/.test(role)) return 'toggle';
|
|
24
|
+
if (patterns.includes('range_value') || /slider|spin/.test(role)) return 'set_range_value';
|
|
25
|
+
if (patterns.includes('selection_item') || /radio|listitem|treeitem|tabitem|option/.test(role)) return 'select';
|
|
26
|
+
if (patterns.includes('expand_collapse') || /combo|treeitem|menuitem/.test(role)) return 'expand';
|
|
27
|
+
if (patterns.includes('value') || /edit|textbox|text field|entry|search/.test(role)) return 'set_value';
|
|
28
|
+
if (patterns.includes('invoke') || /button|link|menuitem/.test(role)) return 'invoke';
|
|
29
|
+
if (node.scrollable === true || patterns.includes('scroll') || patterns.includes('scroll_item')) return 'scroll_into_view';
|
|
30
|
+
return 'click';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function semanticTreeText(nodes = [], maxChars = 48_000) {
|
|
34
|
+
const lines = [];
|
|
35
|
+
let chars = 0;
|
|
36
|
+
for (const node of nodes) {
|
|
37
|
+
const depth = Math.max(0, Math.min(32, Number(node.depth) || 0));
|
|
38
|
+
const label = Number.isInteger(node.label) ? `#${node.label} ` : '';
|
|
39
|
+
const role = String(node.role || 'element');
|
|
40
|
+
const name = String(node.name || node.value || '').replace(/\s+/g, ' ').trim();
|
|
41
|
+
const states = [
|
|
42
|
+
node.focused === true ? 'focused' : '',
|
|
43
|
+
node.enabled === false ? 'disabled' : '',
|
|
44
|
+
node.offscreen === true ? 'offscreen' : '',
|
|
45
|
+
node.password === true ? 'password' : '',
|
|
46
|
+
node.toggle_state != null ? `toggle:${node.toggle_state}` : '',
|
|
47
|
+
node.expand_collapse_state ? `state:${node.expand_collapse_state}` : '',
|
|
48
|
+
node.range_value != null ? `value:${node.range_value}` : '',
|
|
49
|
+
].filter(Boolean);
|
|
50
|
+
const line = `${' '.repeat(depth)}${label}${role}${name ? ` "${name.slice(0, 240)}"` : ''} [${node.suggested_action || suggestedUiAction(node)}]${states.length ? ` ${states.map(v => `[${v}]`).join(' ')}` : ''}`;
|
|
51
|
+
if (chars + line.length + (lines.length ? 1 : 0) > maxChars) {
|
|
52
|
+
lines.push('… semantic tree truncated …');
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
lines.push(line);
|
|
56
|
+
chars += line.length + (lines.length > 1 ? 1 : 0);
|
|
57
|
+
}
|
|
58
|
+
return lines.join('\n');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function enrichUiPayload(value, args = {}) {
|
|
62
|
+
const base = Array.isArray(value) ? { platform:process.platform, count:value.length, nodes:value } : { ...(value || {}) };
|
|
63
|
+
const source = Array.isArray(base.nodes) ? base.nodes : [];
|
|
64
|
+
let label = 0;
|
|
65
|
+
const nodes = source.map(node => {
|
|
66
|
+
const x = Number(node.x), y = Number(node.y), width = Number(node.width), height = Number(node.height);
|
|
67
|
+
const usableBounds = [x, y, width, height].every(Number.isFinite) && width > 0 && height > 0;
|
|
68
|
+
const actionable = usableBounds && node.offscreen !== true && (
|
|
69
|
+
node.enabled !== false || node.focused === true || String(node.name || '').trim() || String(node.role || '').trim()
|
|
70
|
+
);
|
|
71
|
+
const enriched = {
|
|
72
|
+
...node,
|
|
73
|
+
...(usableBounds ? { center_x:Math.round(x + width / 2), center_y:Math.round(y + height / 2) } : {}),
|
|
74
|
+
suggested_action:suggestedUiAction(node),
|
|
75
|
+
};
|
|
76
|
+
if (actionable) enriched.label = label++;
|
|
77
|
+
return enriched;
|
|
78
|
+
});
|
|
79
|
+
const payload = {
|
|
80
|
+
...base,
|
|
81
|
+
count:Number(base.count) || nodes.length,
|
|
82
|
+
nodes,
|
|
83
|
+
label_count:label,
|
|
84
|
+
semantic_tree:args.semantic_tree === false ? undefined : semanticTreeText(nodes),
|
|
85
|
+
captured_at:new Date().toISOString(),
|
|
86
|
+
};
|
|
87
|
+
latestUiSnapshot = { at:Date.now(), payload, args:{ ...args } };
|
|
88
|
+
return payload;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function cachedUiNodes(args = {}) {
|
|
92
|
+
if (!latestUiSnapshot || Date.now() - latestUiSnapshot.at > UI_CACHE_TTL_MS) return null;
|
|
93
|
+
const requestedPid = Number.isInteger(Number(args.pid)) ? Number(args.pid) : null;
|
|
94
|
+
const cachedNodes = latestUiSnapshot.payload?.nodes;
|
|
95
|
+
if (!Array.isArray(cachedNodes)) return null;
|
|
96
|
+
if (requestedPid != null && !cachedNodes.some(node => Number(node.pid) === requestedPid)) return null;
|
|
97
|
+
if (args.app && !cachedNodes.some(node => String(node.app || '').toLowerCase().includes(String(args.app).toLowerCase()))) return null;
|
|
98
|
+
if ((args.window_title || args.windowTitle) && !cachedNodes.some(node => String(node.window || node.window_title || '').toLowerCase().includes(String(args.window_title || args.windowTitle).toLowerCase()))) return null;
|
|
99
|
+
return cachedNodes;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function resolveCachedLabel(value, field = 'label') {
|
|
103
|
+
if (value == null || value === '') return null;
|
|
104
|
+
const label = Number(value);
|
|
105
|
+
if (!Number.isInteger(label) || label < 0) throw new Error(`${field} must be a non-negative integer label returned by ui_snapshot/ui_find`);
|
|
106
|
+
if (!latestUiSnapshot || Date.now() - latestUiSnapshot.at > UI_CACHE_TTL_MS) {
|
|
107
|
+
throw new Error(`${field} refers to an expired UI snapshot; refresh ui_snapshot/ui_find before acting`);
|
|
108
|
+
}
|
|
109
|
+
const nodes = latestUiSnapshot.payload?.nodes;
|
|
110
|
+
if (!Array.isArray(nodes)) throw new Error(`${field} requires a prior ui_snapshot/ui_find on the current desktop state`);
|
|
111
|
+
const node = nodes.find(item => Number(item.label) === label);
|
|
112
|
+
if (!node) throw new Error(`${field} ${label} is not present in the latest UI snapshot; refresh ui_snapshot`);
|
|
113
|
+
return node;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function withResolvedUiLabel(args = {}) {
|
|
117
|
+
const node = resolveCachedLabel(args.label);
|
|
118
|
+
if (!node) return args;
|
|
119
|
+
return {
|
|
120
|
+
...args,
|
|
121
|
+
id:args.id || node.id,
|
|
122
|
+
pid:args.pid ?? node.pid,
|
|
123
|
+
app:args.app || node.app,
|
|
124
|
+
window_title:args.window_title || args.windowTitle || node.window || node.window_title,
|
|
125
|
+
name:args.name || (!node.id ? node.name : undefined),
|
|
126
|
+
role:args.role || (!node.id ? node.role : undefined),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function resultValue(result) {
|
|
131
|
+
if (result?.structuredContent && !(Object.keys(result.structuredContent).length === 1 && typeof result.structuredContent.text === 'string')) {
|
|
132
|
+
if (Object.keys(result.structuredContent).length === 1 && Object.hasOwn(result.structuredContent, 'data')) return result.structuredContent.data;
|
|
133
|
+
return result.structuredContent;
|
|
134
|
+
}
|
|
135
|
+
const raw = result?.content?.find(part => part.type === 'text')?.text;
|
|
136
|
+
if (typeof raw !== 'string') return null;
|
|
137
|
+
try { return JSON.parse(raw); } catch { return raw; }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function jsonBytes(value) {
|
|
141
|
+
return Buffer.byteLength(JSON.stringify(value), 'utf8');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function boundedComputerSnapshot(payload) {
|
|
145
|
+
const budget = Math.min(768 * 1024, Math.max(16 * 1024, Math.floor(Number(liveConfig('maxOutputBytes') || 0) * 0.35)));
|
|
146
|
+
const originalBytes = jsonBytes(payload);
|
|
147
|
+
if (originalBytes <= budget) {
|
|
148
|
+
return { ...payload, snapshot_bytes:originalBytes, snapshot_budget_bytes:budget, snapshot_truncated:false };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const bounded = {
|
|
152
|
+
...payload,
|
|
153
|
+
windows:Array.isArray(payload.windows) ? [...payload.windows] : payload.windows,
|
|
154
|
+
errors:Array.isArray(payload.errors) ? payload.errors.map(value => String(value).slice(0, 1000)).slice(0, 50) : payload.errors,
|
|
155
|
+
ui:payload.ui && typeof payload.ui === 'object' ? {
|
|
156
|
+
...payload.ui,
|
|
157
|
+
...(Array.isArray(payload.ui.nodes) ? { nodes:[...payload.ui.nodes] } : {}),
|
|
158
|
+
} : payload.ui,
|
|
159
|
+
browser:payload.browser && typeof payload.browser === 'object' ? {
|
|
160
|
+
...payload.browser,
|
|
161
|
+
...(Array.isArray(payload.browser.nodes) ? { nodes:[...payload.browser.nodes] } : {}),
|
|
162
|
+
} : payload.browser,
|
|
163
|
+
ocr:payload.ocr && typeof payload.ocr === 'object' ? {
|
|
164
|
+
...payload.ocr,
|
|
165
|
+
text:String(payload.ocr.text || '').slice(0, 50_000),
|
|
166
|
+
...(Array.isArray(payload.ocr.words) ? { words:[...payload.ocr.words] } : {}),
|
|
167
|
+
...(Array.isArray(payload.ocr.lines) ? { lines:[...payload.ocr.lines] } : {}),
|
|
168
|
+
} : payload.ocr,
|
|
169
|
+
snapshot_original_bytes:originalBytes,
|
|
170
|
+
snapshot_budget_bytes:budget,
|
|
171
|
+
snapshot_truncated:true,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const lists = [
|
|
175
|
+
[bounded.ocr, 'words', 50],
|
|
176
|
+
[bounded.ui, 'nodes', 100],
|
|
177
|
+
[bounded.browser, 'nodes', 100],
|
|
178
|
+
[bounded.visual, 'labeled_targets', 25],
|
|
179
|
+
[bounded.ocr, 'lines', 25],
|
|
180
|
+
[bounded, 'windows', 20],
|
|
181
|
+
];
|
|
182
|
+
let guard = 0;
|
|
183
|
+
while (jsonBytes(bounded) > budget && guard < 32) {
|
|
184
|
+
guard += 1;
|
|
185
|
+
let changed = false;
|
|
186
|
+
for (const [container, key, floor] of lists) {
|
|
187
|
+
const values = container?.[key];
|
|
188
|
+
if (!Array.isArray(values) || values.length <= floor) continue;
|
|
189
|
+
const nextLength = Math.max(floor, Math.floor(values.length * 0.6));
|
|
190
|
+
container[key] = values.slice(0, nextLength);
|
|
191
|
+
container.truncated = true;
|
|
192
|
+
container[`returned_${key}`] = nextLength;
|
|
193
|
+
changed = true;
|
|
194
|
+
if (jsonBytes(bounded) <= budget) break;
|
|
195
|
+
}
|
|
196
|
+
if (!changed) break;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (jsonBytes(bounded) > budget && bounded.ui && typeof bounded.ui.semantic_tree === 'string') {
|
|
200
|
+
bounded.ui.semantic_tree = bounded.ui.semantic_tree.slice(0, 8_000);
|
|
201
|
+
bounded.ui.semantic_tree_truncated = true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (jsonBytes(bounded) > budget) {
|
|
205
|
+
if (bounded.ui && typeof bounded.ui === 'object') {
|
|
206
|
+
bounded.ui = {
|
|
207
|
+
platform:bounded.ui.platform || null,
|
|
208
|
+
count:Number(bounded.ui.count) || (Array.isArray(bounded.ui.nodes) ? bounded.ui.nodes.length : 0),
|
|
209
|
+
nodes:[],
|
|
210
|
+
truncated:true,
|
|
211
|
+
reason:'snapshot payload budget',
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
if (bounded.browser && typeof bounded.browser === 'object') {
|
|
215
|
+
bounded.browser = {
|
|
216
|
+
target_id:bounded.browser.target_id || null,
|
|
217
|
+
title:String(bounded.browser.title || '').slice(0, 500),
|
|
218
|
+
url:String(bounded.browser.url || '').slice(0, 2000),
|
|
219
|
+
count:Number(bounded.browser.count) || (Array.isArray(bounded.browser.nodes) ? bounded.browser.nodes.length : 0),
|
|
220
|
+
nodes:[],
|
|
221
|
+
truncated:true,
|
|
222
|
+
reason:'snapshot payload budget',
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
if (bounded.ocr && typeof bounded.ocr === 'object') {
|
|
226
|
+
bounded.ocr = {
|
|
227
|
+
available:Boolean(bounded.ocr.available),
|
|
228
|
+
backend:bounded.ocr.backend || null,
|
|
229
|
+
language:bounded.ocr.language || null,
|
|
230
|
+
text:String(bounded.ocr.text || '').slice(0, 10_000),
|
|
231
|
+
count:Number(bounded.ocr.count) || 0,
|
|
232
|
+
total_words:Number(bounded.ocr.total_words) || 0,
|
|
233
|
+
words:[],
|
|
234
|
+
lines:[],
|
|
235
|
+
truncated:true,
|
|
236
|
+
reason:'snapshot payload budget',
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
if (Array.isArray(bounded.windows)) bounded.windows = bounded.windows.slice(0, 10);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
bounded.snapshot_bytes = jsonBytes(bounded);
|
|
243
|
+
return bounded;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function bestMonitor(window, displays) {
|
|
247
|
+
if (!Array.isArray(displays)) return null;
|
|
248
|
+
const wx1 = Number(window.x), wy1 = Number(window.y), ww = Number(window.width), wh = Number(window.height);
|
|
249
|
+
if (![wx1, wy1, ww, wh].every(Number.isFinite) || ww <= 0 || wh <= 0) return null;
|
|
250
|
+
const wx2 = wx1 + ww, wy2 = wy1 + wh;
|
|
251
|
+
let best = null;
|
|
252
|
+
let bestArea = 0;
|
|
253
|
+
for (let index = 0; index < displays.length; index += 1) {
|
|
254
|
+
const display = displays[index];
|
|
255
|
+
const dx1 = Number(display.x), dy1 = Number(display.y), dw = Number(display.width), dh = Number(display.height);
|
|
256
|
+
if (![dx1, dy1, dw, dh].every(Number.isFinite) || dw <= 0 || dh <= 0) continue;
|
|
257
|
+
const area = Math.max(0, Math.min(wx2, dx1 + dw) - Math.max(wx1, dx1)) * Math.max(0, Math.min(wy2, dy1 + dh) - Math.max(wy1, dy1));
|
|
258
|
+
if (area > bestArea) { bestArea = area; best = { index, ...display }; }
|
|
259
|
+
}
|
|
260
|
+
return best;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function listWindows(args = {}) {
|
|
264
|
+
const raw = await adapter.listWindows(args);
|
|
265
|
+
if (args.include_monitor === false) return raw;
|
|
266
|
+
const windows = resultValue(raw);
|
|
267
|
+
if (!Array.isArray(windows)) return raw;
|
|
268
|
+
let displays = null;
|
|
269
|
+
try { displays = resultValue(await adapter.displayInventory()); } catch {}
|
|
270
|
+
if (!Array.isArray(displays)) return jsonResult(windows.map(window => ({ ...window, monitor: window.monitor ?? null })));
|
|
271
|
+
return jsonResult(windows.map(window => {
|
|
272
|
+
const monitor = bestMonitor(window, displays);
|
|
273
|
+
return { ...window, monitor: monitor?.name ?? null, monitor_index: monitor?.index ?? null };
|
|
274
|
+
}));
|
|
275
|
+
}
|
|
276
|
+
export async function windowAction(args = {}) { return adapter.windowAction(args); }
|
|
277
|
+
export async function uiSnapshot(args = {}) {
|
|
278
|
+
const explicitlyScoped = args.pid != null || args.app || args.window_title || args.windowTitle;
|
|
279
|
+
const activeOnly = args.active_only != null
|
|
280
|
+
? args.active_only === true
|
|
281
|
+
: (!explicitlyScoped && args.scope !== 'desktop');
|
|
282
|
+
const effective = { ...args, active_only:activeOnly };
|
|
283
|
+
const raw = await adapter.uiSnapshot(effective);
|
|
284
|
+
const value = resultValue(raw);
|
|
285
|
+
return jsonResult(enrichUiPayload(value, effective));
|
|
286
|
+
}
|
|
287
|
+
export async function uiAction(args = {}) {
|
|
288
|
+
return adapter.uiAction(withResolvedUiLabel(args));
|
|
289
|
+
}
|
|
290
|
+
export async function keyboard(args = {}) { return adapter.keyboard(args); }
|
|
291
|
+
export async function pointer(args = {}) { return adapter.pointer(args); }
|
|
292
|
+
export async function clipboard(args = {}) { return adapter.clipboard(args); }
|
|
293
|
+
export async function displayInventory(args = {}) { return adapter.displayInventory(args); }
|
|
294
|
+
export async function cursorPosition(args = {}) { return typeof adapter.cursorPosition === 'function' ? adapter.cursorPosition(args) : jsonResult({ x:null, y:null }); }
|
|
295
|
+
|
|
296
|
+
export async function screenshotRegion(args = {}) {
|
|
297
|
+
let x = Number(args.x), y = Number(args.y), width = Number(args.width), height = Number(args.height);
|
|
298
|
+
const hasRect = [x, y, width, height].every(Number.isFinite) && width > 0 && height > 0;
|
|
299
|
+
if (!hasRect) {
|
|
300
|
+
const windowId = optionalString(args.window_id || args.id);
|
|
301
|
+
const app = optionalString(args.app);
|
|
302
|
+
const title = optionalString(args.title);
|
|
303
|
+
const pid = Number.isInteger(Number(args.pid)) ? Number(args.pid) : null;
|
|
304
|
+
if (windowId || app || title || pid != null) {
|
|
305
|
+
const rows = resultValue(await listWindows({ include_monitor:false }));
|
|
306
|
+
const row = Array.isArray(rows) ? rows.find(item =>
|
|
307
|
+
(!windowId || item.id === windowId)
|
|
308
|
+
&& (pid == null || Number(item.pid) === pid)
|
|
309
|
+
&& (!app || String(item.app || '').toLowerCase().includes(app.toLowerCase()))
|
|
310
|
+
&& (!title || String(item.title || '').toLowerCase().includes(title.toLowerCase()))
|
|
311
|
+
) : null;
|
|
312
|
+
if (!row) throw new Error('No matching window found for screenshot_region');
|
|
313
|
+
x = Number(row.x); y = Number(row.y); width = Number(row.width); height = Number(row.height);
|
|
314
|
+
} else if (args.monitor != null || args.monitor_index != null) {
|
|
315
|
+
const displays = resultValue(await displayInventory());
|
|
316
|
+
if (!Array.isArray(displays)) throw new Error('This platform does not expose normalized monitor geometry for screenshot_region');
|
|
317
|
+
const index = Number.isInteger(Number(args.monitor_index)) ? Number(args.monitor_index) : null;
|
|
318
|
+
const monitor = optionalString(args.monitor);
|
|
319
|
+
const row = displays.find((item, itemIndex) =>
|
|
320
|
+
(index == null || itemIndex === index)
|
|
321
|
+
&& (!monitor || (monitor.toLowerCase() === 'primary' ? Boolean(item.primary) : String(item.name || item.display_name || '').toLowerCase().includes(monitor.toLowerCase())))
|
|
322
|
+
);
|
|
323
|
+
if (!row) throw new Error('No matching monitor found for screenshot_region');
|
|
324
|
+
x = Number(row.x); y = Number(row.y); width = Number(row.width); height = Number(row.height);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (![x, y, width, height].every(Number.isFinite) || width <= 0 || height <= 0) {
|
|
328
|
+
throw new Error('Provide x/y/width/height, a window selector, or a monitor selector');
|
|
329
|
+
}
|
|
330
|
+
const padding = clamp(args.padding, 0, 0, 200);
|
|
331
|
+
if (padding) { x -= padding; y -= padding; width += padding * 2; height += padding * 2; }
|
|
332
|
+
return adapter.screenshotRegion({ ...args, x, y, width, height });
|
|
333
|
+
}
|
|
334
|
+
export async function notification(args = {}) { return adapter.notification(args); }
|
|
335
|
+
export async function scroll(args = {}) {
|
|
336
|
+
const direction = optionalString(args.direction);
|
|
337
|
+
const times = clamp(args.wheel_times, 1, 1, 50);
|
|
338
|
+
let deltaX = Number(args.delta_x || 0);
|
|
339
|
+
let deltaY = Number(args.delta_y ?? args.delta ?? 0);
|
|
340
|
+
if (direction) {
|
|
341
|
+
const normalized = requireEnum(direction, 'direction', ['up','down','left','right']);
|
|
342
|
+
if (normalized === 'up') deltaY = -120 * times;
|
|
343
|
+
if (normalized === 'down') deltaY = 120 * times;
|
|
344
|
+
if (normalized === 'left') deltaX = -120 * times;
|
|
345
|
+
if (normalized === 'right') deltaX = 120 * times;
|
|
346
|
+
}
|
|
347
|
+
const semanticTarget = args.label != null || args.id || args.name || args.role || args.automation_id || args.automationId;
|
|
348
|
+
if (semanticTarget) {
|
|
349
|
+
const nodes = await uiMatches({ ...args, limit:1 });
|
|
350
|
+
const node = nodes[0];
|
|
351
|
+
if (!node) throw new Error('Scroll target UI element was not found');
|
|
352
|
+
const x = Number(node.center_x ?? (Number(node.x) + Number(node.width) / 2));
|
|
353
|
+
const y = Number(node.center_y ?? (Number(node.y) + Number(node.height) / 2));
|
|
354
|
+
if (Number.isFinite(x) && Number.isFinite(y)) await pointer({ action:'move', x:Math.round(x), y:Math.round(y), backend:args.backend });
|
|
355
|
+
} else if (Number.isFinite(Number(args.x)) && Number.isFinite(Number(args.y))) {
|
|
356
|
+
await pointer({ action:'move', x:Number(args.x), y:Number(args.y), backend:args.backend });
|
|
357
|
+
}
|
|
358
|
+
return adapter.scroll({ ...args, delta_x:deltaX, delta_y:deltaY });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function matches(node, args) {
|
|
362
|
+
const id = optionalString(args.id);
|
|
363
|
+
const label = args.label == null || args.label === '' ? null : Number(args.label);
|
|
364
|
+
const name = optionalString(args.name);
|
|
365
|
+
const role = optionalString(args.role);
|
|
366
|
+
const value = optionalString(args.value);
|
|
367
|
+
const automationId = optionalString(args.automation_id || args.automationId);
|
|
368
|
+
const app = optionalString(args.app);
|
|
369
|
+
const windowTitle = optionalString(args.window_title || args.windowTitle);
|
|
370
|
+
const pid = Number.isInteger(Number(args.pid)) ? Number(args.pid) : null;
|
|
371
|
+
if (label != null && (!Number.isInteger(label) || Number(node.label) !== label)) return false;
|
|
372
|
+
if (id && node.id !== id) return false;
|
|
373
|
+
if (pid != null && Number(node.pid) !== pid) return false;
|
|
374
|
+
if (app && !String(node.app || '').toLowerCase().includes(app.toLowerCase())) return false;
|
|
375
|
+
if (windowTitle && !String(node.window || node.window_title || '').toLowerCase().includes(windowTitle.toLowerCase())) return false;
|
|
376
|
+
if (name && !String(node.name || '').toLowerCase().includes(name.toLowerCase())) return false;
|
|
377
|
+
if (role && !String(node.role || '').toLowerCase().includes(role.toLowerCase())) return false;
|
|
378
|
+
if (value && !String(node.value || '').toLowerCase().includes(value.toLowerCase())) return false;
|
|
379
|
+
if (automationId && String(node.automationId || '') !== automationId) return false;
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function uiMatches(args = {}) {
|
|
384
|
+
const resolved = args.label != null ? withResolvedUiLabel(args) : args;
|
|
385
|
+
const cached = resolved.refresh === true ? null : cachedUiNodes(resolved);
|
|
386
|
+
if (cached) {
|
|
387
|
+
const hits = cached.filter(node => matches(node, resolved)).slice(0, clamp(resolved.limit, 20, 1, 200));
|
|
388
|
+
if (hits.length || resolved.cache_only === true || resolved.id || resolved.label != null) return hits;
|
|
389
|
+
}
|
|
390
|
+
const stableLinuxPid = optionalString(resolved.id)?.match(/^linux:(\d+):/)?.[1];
|
|
391
|
+
const scopedPid = resolved.pid != null ? resolved.pid : stableLinuxPid ? Number(stableLinuxPid) : null;
|
|
392
|
+
const result = await uiSnapshot({
|
|
393
|
+
max_nodes: resolved.max_nodes || 2000,
|
|
394
|
+
max_depth: resolved.max_depth || 16,
|
|
395
|
+
...(scopedPid != null ? { pid: scopedPid } : {}),
|
|
396
|
+
...(resolved.app ? { app: resolved.app } : {}),
|
|
397
|
+
...((resolved.window_title || resolved.windowTitle) ? { window_title: resolved.window_title || resolved.windowTitle } : {}),
|
|
398
|
+
...(resolved.browser_dom === true ? { browser_dom:true } : {}),
|
|
399
|
+
});
|
|
400
|
+
const payload = resultValue(result) || {};
|
|
401
|
+
const nodes = Array.isArray(payload) ? payload : payload.nodes || [];
|
|
402
|
+
return nodes.filter(node => matches(node, resolved)).slice(0, clamp(resolved.limit, 20, 1, 200));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export async function uiFind(args = {}) {
|
|
406
|
+
const nodes = await uiMatches(args);
|
|
407
|
+
return jsonResult({ count: nodes.length, nodes });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function uiFingerprint(nodes) {
|
|
411
|
+
return JSON.stringify((nodes || []).map(node => ({
|
|
412
|
+
id: node.id ?? null,
|
|
413
|
+
role: node.role ?? null,
|
|
414
|
+
name: node.name ?? null,
|
|
415
|
+
value: node.value ?? null,
|
|
416
|
+
x: Number(node.x || 0), y: Number(node.y || 0),
|
|
417
|
+
width: Number(node.width || 0), height: Number(node.height || 0),
|
|
418
|
+
})));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function uiTextContains(node, needle) {
|
|
422
|
+
if (!needle) return true;
|
|
423
|
+
const normalized = String(needle).toLowerCase();
|
|
424
|
+
return [
|
|
425
|
+
node.name, node.value, node.description, node.help_text, node.helpText,
|
|
426
|
+
node.role, node.window, node.window_title, node.automationId,
|
|
427
|
+
].some(value => String(value || '').toLowerCase().includes(normalized));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function uiConditionMatches(args, condition) {
|
|
431
|
+
if (condition === 'active_window') {
|
|
432
|
+
const expected = optionalString(args.window_title || args.windowTitle || args.text || args.name);
|
|
433
|
+
const nodes = await uiMatches({ ...args, name:undefined, role:undefined, value:undefined, id:undefined, label:undefined, limit:200 });
|
|
434
|
+
const active = nodes.filter(node => node.active === true || (
|
|
435
|
+
node.focused === true && /window|frame|dialog/i.test(String(node.role || ''))
|
|
436
|
+
));
|
|
437
|
+
const matched = expected ? active.filter(node => uiTextContains(node, expected)) : active;
|
|
438
|
+
return { matched:matched.length > 0, nodes:matched, detail:matched[0]?.window || matched[0]?.name || null };
|
|
439
|
+
}
|
|
440
|
+
if (condition === 'text_exists') {
|
|
441
|
+
const needle = optionalString(args.text || args.name || args.value);
|
|
442
|
+
if (!needle) throw new Error('text is required for wait_for_ui condition=text_exists');
|
|
443
|
+
const cached = cachedUiNodes(args);
|
|
444
|
+
let nodes = cached || [];
|
|
445
|
+
if (!cached) {
|
|
446
|
+
const snapshot = resultValue(await uiSnapshot({
|
|
447
|
+
max_nodes:args.max_nodes || 2000,
|
|
448
|
+
max_depth:args.max_depth || 16,
|
|
449
|
+
...(args.pid != null ? { pid:args.pid } : {}),
|
|
450
|
+
...(args.app ? { app:args.app } : {}),
|
|
451
|
+
...((args.window_title || args.windowTitle) ? { window_title:args.window_title || args.windowTitle } : {}),
|
|
452
|
+
...(args.browser_dom === true ? { browser_dom:true } : {}),
|
|
453
|
+
})) || {};
|
|
454
|
+
nodes = Array.isArray(snapshot) ? snapshot : snapshot.nodes || [];
|
|
455
|
+
}
|
|
456
|
+
const matched = nodes.filter(node => uiTextContains(node, needle)).slice(0, clamp(args.limit, 20, 1, 200));
|
|
457
|
+
return { matched:matched.length > 0, nodes:matched, detail:needle };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const nodes = await uiMatches(args);
|
|
461
|
+
if (condition === 'element_enabled') {
|
|
462
|
+
const enabled = nodes.filter(node => node.enabled === true || node.disabled === false);
|
|
463
|
+
return { matched:enabled.length > 0, nodes:enabled };
|
|
464
|
+
}
|
|
465
|
+
if (condition === 'focused_element') {
|
|
466
|
+
const focused = nodes.filter(node => node.focused === true || node.has_focused === true);
|
|
467
|
+
return { matched:focused.length > 0, nodes:focused };
|
|
468
|
+
}
|
|
469
|
+
return { matched:nodes.length > 0, nodes };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export async function waitForUi(args = {}) {
|
|
473
|
+
const timeoutMs = clamp(args.timeout_ms, 10_000, 100, 120_000);
|
|
474
|
+
const pollMs = clamp(args.poll_ms, 250, 50, 5000);
|
|
475
|
+
const rawCondition = optionalString(args.condition);
|
|
476
|
+
const aliases = { text:'text_exists', window:'active_window', element:'element_exists', enabled:'element_enabled', focused:'focused_element' };
|
|
477
|
+
const condition = rawCondition
|
|
478
|
+
? requireEnum(aliases[rawCondition] || rawCondition, 'condition', ['text_exists','active_window','element_exists','element_enabled','focused_element'])
|
|
479
|
+
: null;
|
|
480
|
+
const state = condition ? null : requireEnum(args.state || 'present', 'state', ['present', 'absent', 'changed']);
|
|
481
|
+
const deadline = Date.now() + timeoutMs;
|
|
482
|
+
const startedAt = Date.now();
|
|
483
|
+
let attempts = 0;
|
|
484
|
+
|
|
485
|
+
if (condition) {
|
|
486
|
+
let evaluation = { matched:false, nodes:[] };
|
|
487
|
+
do {
|
|
488
|
+
attempts += 1;
|
|
489
|
+
// A wait must observe fresh state, not repeatedly query the same 5-second cache.
|
|
490
|
+
latestUiSnapshot = null;
|
|
491
|
+
evaluation = await uiConditionMatches(args, condition);
|
|
492
|
+
if (evaluation.matched) {
|
|
493
|
+
return jsonResult({
|
|
494
|
+
condition,
|
|
495
|
+
matched:true,
|
|
496
|
+
attempts,
|
|
497
|
+
elapsed_ms:Date.now() - startedAt,
|
|
498
|
+
count:evaluation.nodes.length,
|
|
499
|
+
nodes:evaluation.nodes,
|
|
500
|
+
...(evaluation.detail ? { detail:evaluation.detail } : {}),
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
await sleep(pollMs);
|
|
504
|
+
} while (Date.now() < deadline);
|
|
505
|
+
return jsonResult({
|
|
506
|
+
condition,
|
|
507
|
+
matched:false,
|
|
508
|
+
state:'timeout',
|
|
509
|
+
attempts,
|
|
510
|
+
elapsed_ms:Date.now() - startedAt,
|
|
511
|
+
count:evaluation.nodes?.length || 0,
|
|
512
|
+
nodes:evaluation.nodes || [],
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
latestUiSnapshot = null;
|
|
517
|
+
const baseline = state === 'changed' ? await uiMatches(args) : [];
|
|
518
|
+
const baselineFingerprint = state === 'changed' ? uiFingerprint(baseline) : null;
|
|
519
|
+
let nodes = baseline;
|
|
520
|
+
do {
|
|
521
|
+
attempts += 1;
|
|
522
|
+
latestUiSnapshot = null;
|
|
523
|
+
nodes = await uiMatches(args);
|
|
524
|
+
const changed = state === 'changed' && uiFingerprint(nodes) !== baselineFingerprint;
|
|
525
|
+
if ((state === 'present' && nodes.length) || (state === 'absent' && !nodes.length) || changed) {
|
|
526
|
+
return jsonResult({
|
|
527
|
+
state,
|
|
528
|
+
matched: nodes.length,
|
|
529
|
+
attempts,
|
|
530
|
+
elapsed_ms:Date.now() - startedAt,
|
|
531
|
+
nodes,
|
|
532
|
+
...(state === 'changed' ? { before: baseline } : {}),
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
await sleep(pollMs);
|
|
536
|
+
} while (Date.now() < deadline);
|
|
537
|
+
return jsonResult({ state:'timeout', wanted:state, matched:nodes.length, attempts, elapsed_ms:Date.now() - startedAt, nodes, ...(state === 'changed' ? { before:baseline } : {}) });
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export async function typeText(args = {}) {
|
|
541
|
+
const value = String(args.text ?? '');
|
|
542
|
+
const method = requireEnum(args.method || 'auto', 'method', ['auto','accessibility','clipboard','keys']);
|
|
543
|
+
const resolvedArgs = args.label != null ? withResolvedUiLabel(args) : args;
|
|
544
|
+
const hasSelector = Boolean(
|
|
545
|
+
optionalString(resolvedArgs.id) || optionalString(resolvedArgs.name) || optionalString(resolvedArgs.role) ||
|
|
546
|
+
optionalString(resolvedArgs.automation_id || resolvedArgs.automationId) || optionalString(resolvedArgs.app) ||
|
|
547
|
+
optionalString(resolvedArgs.window_title || resolvedArgs.windowTitle) || Number.isInteger(Number(resolvedArgs.pid))
|
|
548
|
+
);
|
|
549
|
+
const clear = args.clear === true;
|
|
550
|
+
const pressEnter = args.press_enter === true;
|
|
551
|
+
const caret = requireEnum(args.caret_position || 'idle', 'caret_position', ['start','idle','end']);
|
|
552
|
+
|
|
553
|
+
if ((method === 'auto' || method === 'accessibility') && hasSelector) {
|
|
554
|
+
const result = await uiAction({ ...resolvedArgs, action:'set_value', value });
|
|
555
|
+
const raw = result.content?.[0]?.text || '';
|
|
556
|
+
let target = raw;
|
|
557
|
+
try { target = JSON.parse(raw); } catch {}
|
|
558
|
+
if (pressEnter) await keyboard({ ...resolvedArgs, key:'ENTER' });
|
|
559
|
+
return jsonResult({ length:value.length, method:'accessibility', target, clear:true, press_enter:pressEnter });
|
|
560
|
+
}
|
|
561
|
+
if ((method === 'auto' || method === 'accessibility') && typeof adapter.typeTextFocused === 'function' && !clear && caret === 'idle') {
|
|
562
|
+
const semantic = await adapter.typeTextFocused(value, resolvedArgs);
|
|
563
|
+
if (semantic) {
|
|
564
|
+
if (pressEnter) await keyboard({ ...resolvedArgs, key:'ENTER' });
|
|
565
|
+
return jsonResult({ length:value.length, method:'accessibility', target:semantic, press_enter:pressEnter });
|
|
566
|
+
}
|
|
567
|
+
if (method === 'accessibility') throw new Error('No focused editable accessibility element is available');
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (hasSelector) await uiAction({ ...resolvedArgs, action:'focus' }).catch(() => null);
|
|
571
|
+
if (clear) {
|
|
572
|
+
await keyboard({ ...resolvedArgs, shortcut:process.platform === 'darwin' ? 'CMD+A' : 'CTRL+A' });
|
|
573
|
+
await keyboard({ ...resolvedArgs, key:'BACKSPACE' });
|
|
574
|
+
}
|
|
575
|
+
if (caret === 'start') await keyboard({ ...resolvedArgs, key:'HOME' });
|
|
576
|
+
if (caret === 'end') await keyboard({ ...resolvedArgs, key:'END' });
|
|
577
|
+
|
|
578
|
+
if (method === 'keys' && typeof adapter.typeTextKeys === 'function') {
|
|
579
|
+
const result = await adapter.typeTextKeys(value, resolvedArgs);
|
|
580
|
+
if (pressEnter) await keyboard({ ...resolvedArgs, key:'ENTER' });
|
|
581
|
+
return jsonResult({ length:value.length, method:'keys', press_enter:pressEnter, ...(result?.backend ? { backend:result.backend } : {}) });
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
let prior = null;
|
|
585
|
+
let priorReadable = false;
|
|
586
|
+
try {
|
|
587
|
+
const previous = await clipboard({ action:'read' });
|
|
588
|
+
prior = previous.content?.[0]?.text ?? '';
|
|
589
|
+
priorReadable = true;
|
|
590
|
+
} catch {}
|
|
591
|
+
try {
|
|
592
|
+
await clipboard({ action:'write', text:value });
|
|
593
|
+
await sleep(50);
|
|
594
|
+
await keyboard({ ...resolvedArgs, shortcut:process.platform === 'darwin' ? 'CMD+V' : 'CTRL+V' });
|
|
595
|
+
await sleep(75);
|
|
596
|
+
if (pressEnter) await keyboard({ ...resolvedArgs, key:'ENTER' });
|
|
597
|
+
} finally {
|
|
598
|
+
if (priorReadable) {
|
|
599
|
+
try { await clipboard({ action:'write', text:prior }); } catch {}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return jsonResult({ length:value.length, method:'clipboard_paste', clipboard_restored:priorReadable, press_enter:pressEnter });
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async function uiElementCenter(id, field) {
|
|
606
|
+
const matches = await uiMatches({ id, limit: 1, max_nodes: 5000, max_depth: 32 });
|
|
607
|
+
const node = matches[0];
|
|
608
|
+
if (!node) throw new Error(`${field} UI element was not found: ${id}`);
|
|
609
|
+
const x = Number(node.x), y = Number(node.y), width = Number(node.width), height = Number(node.height);
|
|
610
|
+
if (![x, y, width, height].every(Number.isFinite) || width <= 0 || height <= 0) {
|
|
611
|
+
throw new Error(`${field} UI element has no usable screen bounds: ${id}`);
|
|
612
|
+
}
|
|
613
|
+
return { x: Math.round(x + width / 2), y: Math.round(y + height / 2), node };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export async function dragDrop(args = {}) {
|
|
617
|
+
let fromX = Number(args.from_x), fromY = Number(args.from_y);
|
|
618
|
+
let toX = Number(args.to_x), toY = Number(args.to_y);
|
|
619
|
+
let fromNode = null;
|
|
620
|
+
let toNode = null;
|
|
621
|
+
if (!Number.isFinite(fromX) || !Number.isFinite(fromY)) {
|
|
622
|
+
const id = optionalString(args.from_id);
|
|
623
|
+
if (!id) throw new Error('Provide from_x/from_y or from_id');
|
|
624
|
+
const resolved = await uiElementCenter(id, 'from_id');
|
|
625
|
+
fromX = resolved.x; fromY = resolved.y; fromNode = resolved.node;
|
|
626
|
+
}
|
|
627
|
+
if (!Number.isFinite(toX) || !Number.isFinite(toY)) {
|
|
628
|
+
const id = optionalString(args.to_id);
|
|
629
|
+
if (!id) throw new Error('Provide to_x/to_y or to_id');
|
|
630
|
+
const resolved = await uiElementCenter(id, 'to_id');
|
|
631
|
+
toX = resolved.x; toY = resolved.y; toNode = resolved.node;
|
|
632
|
+
}
|
|
633
|
+
if (typeof adapter.dragDrop === 'function') {
|
|
634
|
+
const result = await adapter.dragDrop({ ...args, from_x: fromX, from_y: fromY, to_x: toX, to_y: toY });
|
|
635
|
+
if (!fromNode && !toNode) return result;
|
|
636
|
+
return jsonResult({ from: [fromX, fromY], to: [toX, toY], from_element: fromNode?.id || null, to_element: toNode?.id || null });
|
|
637
|
+
}
|
|
638
|
+
await pointer({ action: 'move', x: fromX, y: fromY, button: args.button });
|
|
639
|
+
await pointer({ action: 'down', x: fromX, y: fromY, button: args.button });
|
|
640
|
+
await sleep(clamp(args.hold_ms, 120, 0, 5000));
|
|
641
|
+
await pointer({ action: 'move', x: toX, y: toY, button: args.button });
|
|
642
|
+
await sleep(clamp(args.duration_ms, 120, 0, 5000));
|
|
643
|
+
await pointer({ action: 'up', x: toX, y: toY, button: args.button });
|
|
644
|
+
return jsonResult({ from: [fromX, fromY], to: [toX, toY], from_element: fromNode?.id || null, to_element: toNode?.id || null });
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export async function launchApp(args = {}) {
|
|
648
|
+
const app = optionalString(args.app || args.path);
|
|
649
|
+
if (!app) throw new Error('app is required');
|
|
650
|
+
const argv = Array.isArray(args.args) ? args.args.map(String) : [];
|
|
651
|
+
const cwd = args.cwd != null ? await resolveSafePath(args.cwd, 'cwd') : null;
|
|
652
|
+
const policy = assertAllowedCommand([app, ...argv].join(' '));
|
|
653
|
+
const result = await adapter.launchApp(app, argv, { ...args, cwd });
|
|
654
|
+
const body = result?.content?.[0]?.text || (typeof result === 'string' ? result : `Launched ${app}.`);
|
|
655
|
+
|
|
656
|
+
const waitHint = optionalString(args.wait_for_window);
|
|
657
|
+
if (waitHint) {
|
|
658
|
+
const timeoutMs = clamp(args.wait_timeout_ms, 10_000, 100, 120_000);
|
|
659
|
+
const deadline = Date.now() + timeoutMs;
|
|
660
|
+
let match = null;
|
|
661
|
+
do {
|
|
662
|
+
const windows = resultValue(await listWindows({ include_monitor:false }));
|
|
663
|
+
match = Array.isArray(windows) ? windows.find(row =>
|
|
664
|
+
String(row.title || '').toLowerCase().includes(waitHint.toLowerCase())
|
|
665
|
+
|| String(row.app || '').toLowerCase().includes(waitHint.toLowerCase())
|
|
666
|
+
) : null;
|
|
667
|
+
if (match) break;
|
|
668
|
+
await sleep(100);
|
|
669
|
+
} while (Date.now() < deadline);
|
|
670
|
+
if (!match) throw new Error(`Application launched but no window matching "${waitHint}" appeared within ${timeoutMs} ms`);
|
|
671
|
+
return text(`${policy.note ? `${policy.note}\n` : ''}${body}\nWindow ready: ${match.title || match.app || match.id}`);
|
|
672
|
+
}
|
|
673
|
+
return text(`${policy.note ? `${policy.note}\n` : ''}${body}`);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
export async function openPath(args = {}) {
|
|
677
|
+
const target = await resolveSafePath(args.path, 'path');
|
|
678
|
+
await adapter.openPath(target);
|
|
679
|
+
return text(`Opened ${target}.`);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export async function revealPath(args = {}) {
|
|
683
|
+
const target = await resolveSafePath(args.path, 'path');
|
|
684
|
+
await adapter.revealPath(target);
|
|
685
|
+
return text(`Revealed ${target}.`);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function screenshotImagePart(screenshotResult) {
|
|
689
|
+
return screenshotResult?.content?.find(part => part.type === 'image' && typeof part.data === 'string') || null;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async function ocrScreenshotResult(screenshotResult, args = {}) {
|
|
693
|
+
const imagePart = screenshotImagePart(screenshotResult);
|
|
694
|
+
if (!imagePart) return { available:false, reason:'no screenshot image is available for OCR' };
|
|
695
|
+
return ocrImage(Buffer.from(imagePart.data, 'base64'), {
|
|
696
|
+
language:args.ocr_language,
|
|
697
|
+
max_words:args.ocr_max_words,
|
|
698
|
+
psm:args.ocr_psm,
|
|
699
|
+
timeout_ms:args.ocr_timeout_ms,
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function pngDimensions(imagePart) {
|
|
704
|
+
if (!imagePart || imagePart.mimeType !== 'image/png') return null;
|
|
705
|
+
let buffer;
|
|
706
|
+
try { buffer = Buffer.from(imagePart.data, 'base64'); } catch { return null; }
|
|
707
|
+
if (buffer.length < 24 || buffer.toString('ascii', 1, 4) !== 'PNG') return null;
|
|
708
|
+
const width = buffer.readUInt32BE(16);
|
|
709
|
+
const height = buffer.readUInt32BE(20);
|
|
710
|
+
return width > 0 && height > 0 ? { width, height } : null;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function virtualDesktopBounds(displays) {
|
|
714
|
+
if (!Array.isArray(displays) || !displays.length) return null;
|
|
715
|
+
const valid = displays.filter(display =>
|
|
716
|
+
[display.x, display.y, display.width, display.height].every(value => Number.isFinite(Number(value)))
|
|
717
|
+
&& Number(display.width) > 0
|
|
718
|
+
&& Number(display.height) > 0
|
|
719
|
+
);
|
|
720
|
+
if (!valid.length) return null;
|
|
721
|
+
const x = Math.min(...valid.map(display => Number(display.x)));
|
|
722
|
+
const y = Math.min(...valid.map(display => Number(display.y)));
|
|
723
|
+
const right = Math.max(...valid.map(display => Number(display.x) + Number(display.width)));
|
|
724
|
+
const bottom = Math.max(...valid.map(display => Number(display.y) + Number(display.height)));
|
|
725
|
+
return { x, y, width:right - x, height:bottom - y };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
export function mapOcrBoxToDesktop(box, imagePart, displays, captureBounds = null) {
|
|
729
|
+
const image = pngDimensions(imagePart);
|
|
730
|
+
const desktop = captureBounds || virtualDesktopBounds(displays);
|
|
731
|
+
const centerX = Number(box?.x) + Number(box?.width) / 2;
|
|
732
|
+
const centerY = Number(box?.y) + Number(box?.height) / 2;
|
|
733
|
+
if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) throw new Error('OCR match has invalid bounds');
|
|
734
|
+
if (!image || !desktop || desktop.width <= 0 || desktop.height <= 0) {
|
|
735
|
+
return { x:Math.round(centerX), y:Math.round(centerY), scale_x:1, scale_y:1, desktop_origin:[0,0] };
|
|
736
|
+
}
|
|
737
|
+
const scaleX = image.width / desktop.width;
|
|
738
|
+
const scaleY = image.height / desktop.height;
|
|
739
|
+
if (!Number.isFinite(scaleX) || !Number.isFinite(scaleY) || scaleX <= 0 || scaleY <= 0) {
|
|
740
|
+
throw new Error('Could not map OCR pixels to desktop coordinates');
|
|
741
|
+
}
|
|
742
|
+
return {
|
|
743
|
+
x:Math.round(desktop.x + centerX / scaleX),
|
|
744
|
+
y:Math.round(desktop.y + centerY / scaleY),
|
|
745
|
+
scale_x:scaleX,
|
|
746
|
+
scale_y:scaleY,
|
|
747
|
+
desktop_origin:[desktop.x, desktop.y],
|
|
748
|
+
screenshot_size:[image.width, image.height],
|
|
749
|
+
desktop_size:[desktop.width, desktop.height],
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
async function clickOcrText(args = {}) {
|
|
754
|
+
const needle = optionalString(args.ocr_text || args.name || args.browser_text);
|
|
755
|
+
if (!needle) throw new Error('ocr_text is required for OCR targeting');
|
|
756
|
+
|
|
757
|
+
let screenshot = null;
|
|
758
|
+
let captureBounds = null;
|
|
759
|
+
const explicitRegion = Array.isArray(args.ocr_region) && args.ocr_region.length === 4
|
|
760
|
+
? args.ocr_region.map(Number)
|
|
761
|
+
: null;
|
|
762
|
+
if (explicitRegion?.every(Number.isFinite)) {
|
|
763
|
+
const [left, top, right, bottom] = explicitRegion;
|
|
764
|
+
if (right <= left || bottom <= top) throw new Error('ocr_region must be [left,top,right,bottom] with positive area');
|
|
765
|
+
captureBounds = { x:Math.round(left), y:Math.round(top), width:Math.round(right-left), height:Math.round(bottom-top) };
|
|
766
|
+
screenshot = await screenshotRegion({ ...captureBounds, padding:0 });
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Without an explicit OCR region, discover the current semantic foreground first. OCR on a tightly
|
|
770
|
+
// scoped active window is much more accurate than OCR on a dense whole-desktop screenshot and still
|
|
771
|
+
// falls back safely below.
|
|
772
|
+
if (!screenshot) try {
|
|
773
|
+
const state = resultValue(await computerSnapshot({
|
|
774
|
+
include_screenshot:false,
|
|
775
|
+
include_browser:false,
|
|
776
|
+
include_ocr:false,
|
|
777
|
+
max_ui_nodes:300,
|
|
778
|
+
max_ui_depth:8,
|
|
779
|
+
}));
|
|
780
|
+
captureBounds = activeWindowCaptureBounds(state?.active_window);
|
|
781
|
+
if (captureBounds) screenshot = await screenshotRegion({ ...captureBounds, padding:0 });
|
|
782
|
+
} catch {}
|
|
783
|
+
|
|
784
|
+
if (!screenshot) {
|
|
785
|
+
screenshot = await fileToolHandlers.take_screenshot({
|
|
786
|
+
keep:false,
|
|
787
|
+
...(runtimeConfig.allowedRoots[0] ? { directory:runtimeConfig.allowedRoots[0] } : {}),
|
|
788
|
+
});
|
|
789
|
+
captureBounds = null;
|
|
790
|
+
}
|
|
791
|
+
const deadline = Date.now() + clamp(args.timeout_ms, 1500, 300, 10_000);
|
|
792
|
+
let imagePart = null;
|
|
793
|
+
let ocr = null;
|
|
794
|
+
let match = null;
|
|
795
|
+
let attempts = 0;
|
|
796
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
797
|
+
attempts = attempt + 1;
|
|
798
|
+
imagePart = screenshotImagePart(screenshot);
|
|
799
|
+
ocr = await ocrScreenshotResult(screenshot, args);
|
|
800
|
+
if (!ocr.available) throw new Error(`OCR is unavailable: ${ocr.reason || 'unknown OCR error'}`);
|
|
801
|
+
match = findOcrText(ocr, needle);
|
|
802
|
+
if (match) break;
|
|
803
|
+
if (attempt >= 2 || Date.now() >= deadline) break;
|
|
804
|
+
await sleep(Math.min(150, Math.max(1, deadline - Date.now())));
|
|
805
|
+
if (captureBounds) {
|
|
806
|
+
screenshot = await screenshotRegion({ ...captureBounds, padding:0 });
|
|
807
|
+
} else {
|
|
808
|
+
screenshot = await fileToolHandlers.take_screenshot({
|
|
809
|
+
keep:false,
|
|
810
|
+
...(runtimeConfig.allowedRoots[0] ? { directory:runtimeConfig.allowedRoots[0] } : {}),
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
if (!match) throw new Error(`OCR text not found after ${attempts} capture${attempts === 1 ? '' : 's'}: ${needle}`);
|
|
815
|
+
let displays = null;
|
|
816
|
+
try { displays = resultValue(await displayInventory()); } catch {}
|
|
817
|
+
const point = mapOcrBoxToDesktop(match, imagePart, displays, captureBounds);
|
|
818
|
+
const pointerResult = await pointer({ ...args, action:'click', x:point.x, y:point.y });
|
|
819
|
+
return jsonResult({
|
|
820
|
+
target:'ocr',
|
|
821
|
+
action:'click',
|
|
822
|
+
query:needle,
|
|
823
|
+
attempts,
|
|
824
|
+
match,
|
|
825
|
+
point:{ x:point.x, y:point.y },
|
|
826
|
+
mapping:{
|
|
827
|
+
scale_x:point.scale_x,
|
|
828
|
+
scale_y:point.scale_y,
|
|
829
|
+
desktop_origin:point.desktop_origin,
|
|
830
|
+
screenshot_size:point.screenshot_size || null,
|
|
831
|
+
desktop_size:point.desktop_size || null,
|
|
832
|
+
capture_bounds:captureBounds,
|
|
833
|
+
},
|
|
834
|
+
pointer:resultValue(pointerResult) || pointerResult?.content?.[0]?.text || null,
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function browserTitleHint(activeWindow) {
|
|
839
|
+
const title = optionalString(activeWindow?.title);
|
|
840
|
+
if (!title) return null;
|
|
841
|
+
const stripped = title.replace(/\s+[-—]\s+(Google Chrome|Chromium|Microsoft Edge|Brave)(?:\s+Browser)?$/i, '').trim();
|
|
842
|
+
return stripped || null;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function hasExplicitComputerScreenshotTarget(args = {}) {
|
|
846
|
+
return (Array.isArray(args.screenshot_region) && args.screenshot_region.length === 4)
|
|
847
|
+
|| Boolean(args.screenshot_window_id)
|
|
848
|
+
|| args.screenshot_monitor != null
|
|
849
|
+
|| args.screenshot_monitor_index != null;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function activeWindowCaptureBounds(window) {
|
|
853
|
+
if (!window) return null;
|
|
854
|
+
const x = Number(window.x), y = Number(window.y), width = Number(window.width), height = Number(window.height);
|
|
855
|
+
if (![x,y,width,height].every(Number.isFinite) || width <= 0 || height <= 0) return null;
|
|
856
|
+
return { x:Math.round(x), y:Math.round(y), width:Math.round(width), height:Math.round(height) };
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function computerScreenshotTask(args = {}) {
|
|
860
|
+
const region = Array.isArray(args.screenshot_region) ? args.screenshot_region.map(Number) : null;
|
|
861
|
+
const targeted = region
|
|
862
|
+
|| args.screenshot_window_id
|
|
863
|
+
|| args.screenshot_monitor != null
|
|
864
|
+
|| args.screenshot_monitor_index != null;
|
|
865
|
+
if (targeted) {
|
|
866
|
+
const targetArgs = {
|
|
867
|
+
padding:args.screenshot_padding,
|
|
868
|
+
...(region?.length === 4 ? { x:region[0], y:region[1], width:region[2] - region[0], height:region[3] - region[1] } : {}),
|
|
869
|
+
...(args.screenshot_window_id ? { window_id:args.screenshot_window_id } : {}),
|
|
870
|
+
...(args.screenshot_monitor != null ? { monitor:args.screenshot_monitor } : {}),
|
|
871
|
+
...(args.screenshot_monitor_index != null ? { monitor_index:args.screenshot_monitor_index } : {}),
|
|
872
|
+
};
|
|
873
|
+
return screenshotRegion(targetArgs);
|
|
874
|
+
}
|
|
875
|
+
return fileToolHandlers.take_screenshot({
|
|
876
|
+
keep:false,
|
|
877
|
+
...(runtimeConfig.allowedRoots[0] ? { directory:runtimeConfig.allowedRoots[0] } : {}),
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
export async function computerSnapshot(args = {}) {
|
|
882
|
+
const captureScreenshot = args.include_screenshot !== false || args.include_ocr === true;
|
|
883
|
+
const includeUi = args.include_ui !== false;
|
|
884
|
+
const [windowsResult, displaysResult, uiResult, clipboardResult, cursorResult, shotResult] = await Promise.allSettled([
|
|
885
|
+
listWindows(),
|
|
886
|
+
displayInventory(),
|
|
887
|
+
includeUi
|
|
888
|
+
? uiSnapshot({
|
|
889
|
+
max_nodes:args.max_ui_nodes || 800,
|
|
890
|
+
max_depth:args.max_ui_depth || 12,
|
|
891
|
+
semantic_tree:args.semantic_tree !== false,
|
|
892
|
+
active_only:args.ui_scope === 'desktop' ? false : true,
|
|
893
|
+
...(args.ui_browser_dom === true ? { browser_dom:true } : {}),
|
|
894
|
+
})
|
|
895
|
+
: Promise.resolve(null),
|
|
896
|
+
clipboard({ action:'read' }),
|
|
897
|
+
cursorPosition(),
|
|
898
|
+
captureScreenshot ? computerScreenshotTask(args) : Promise.resolve(null),
|
|
899
|
+
]);
|
|
900
|
+
const parse = result => {
|
|
901
|
+
if (result.status !== 'fulfilled' || !result.value) return null;
|
|
902
|
+
const raw = result.value.content?.[0]?.text;
|
|
903
|
+
if (typeof raw !== 'string') return null;
|
|
904
|
+
try { return JSON.parse(raw); } catch { return raw; }
|
|
905
|
+
};
|
|
906
|
+
const clipText = clipboardResult.status === 'fulfilled' ? clipboardResult.value.content?.[0]?.text || '' : '';
|
|
907
|
+
const windows = parse(windowsResult);
|
|
908
|
+
const ui = parse(uiResult);
|
|
909
|
+
const uiNodes = Array.isArray(ui?.nodes) ? ui.nodes : Array.isArray(ui) ? ui : [];
|
|
910
|
+
const focusedNodes = uiNodes.filter(node => node.focused === true);
|
|
911
|
+
const focusScore = node => {
|
|
912
|
+
const app = String(node.app || '').toLowerCase();
|
|
913
|
+
const windowName = String(node.window || '').trim();
|
|
914
|
+
const role = String(node.role || '').toLowerCase();
|
|
915
|
+
let score = 0;
|
|
916
|
+
if (windowName) score += 100;
|
|
917
|
+
if (app && app !== 'gnome-shell') score += 40;
|
|
918
|
+
if (node.name) score += 15;
|
|
919
|
+
if (!['window','filler','panel'].includes(role)) score += 15;
|
|
920
|
+
if (Number(node.width) >= 100 && Number(node.height) >= 100) score += 10;
|
|
921
|
+
if (/desktop icons/i.test(windowName)) score -= 80;
|
|
922
|
+
if (app === 'gnome-shell' && !windowName) score -= 120;
|
|
923
|
+
return score;
|
|
924
|
+
};
|
|
925
|
+
const focusedNode = focusedNodes.sort((a, b) => focusScore(b) - focusScore(a))[0] || null;
|
|
926
|
+
const activeTopLevel = uiNodes.find(node =>
|
|
927
|
+
node.active === true
|
|
928
|
+
&& /frame|window|dialog/i.test(String(node.role || ''))
|
|
929
|
+
&& String(node.window || node.name || '').trim()
|
|
930
|
+
&& !/desktop icons/i.test(String(node.window || node.name || ''))
|
|
931
|
+
) || null;
|
|
932
|
+
const activeNode = activeTopLevel
|
|
933
|
+
|| focusedNode
|
|
934
|
+
|| uiNodes.find(node => /frame|window|dialog/i.test(String(node.role || '')) && String(node.window || node.name || '').trim())
|
|
935
|
+
|| uiNodes[0]
|
|
936
|
+
|| null;
|
|
937
|
+
const windowRows = Array.isArray(windows) ? windows : [];
|
|
938
|
+
const activeWindow = activeNode ? windowRows.find(row => {
|
|
939
|
+
const activePid = Number(activeNode.pid);
|
|
940
|
+
const rowPid = Number(row.pid);
|
|
941
|
+
const wantedTitle = String(activeNode.window || activeNode.name || '').trim().toLowerCase();
|
|
942
|
+
const rowTitle = String(row.title || '').trim().toLowerCase();
|
|
943
|
+
const pidMatch = activePid > 0 && rowPid > 0 && rowPid === activePid;
|
|
944
|
+
// XWayland/WM enumeration can report pid=0 while AT-SPI still exposes the real application PID.
|
|
945
|
+
// A strong top-level title match is the safe fallback for joining those two views.
|
|
946
|
+
const titleMatch = Boolean(wantedTitle && rowTitle && (rowTitle === wantedTitle || rowTitle.includes(wantedTitle) || wantedTitle.includes(rowTitle)));
|
|
947
|
+
return (pidMatch || titleMatch) && (!wantedTitle || titleMatch || pidMatch);
|
|
948
|
+
}) || null : null;
|
|
949
|
+
const activeApp = activeNode ? {
|
|
950
|
+
pid: Number(activeNode.pid) || activeWindow?.pid || null,
|
|
951
|
+
app: activeNode.app || activeWindow?.app || null,
|
|
952
|
+
window: activeNode.window || activeWindow?.title || null,
|
|
953
|
+
} : null;
|
|
954
|
+
|
|
955
|
+
const activeLabel = [activeApp?.app, activeApp?.window, activeWindow?.app, activeWindow?.title].filter(Boolean).join(' ');
|
|
956
|
+
const browserRequested = args.include_browser === true
|
|
957
|
+
|| (args.include_browser !== false && /chrome|chromium|edge|brave/i.test(activeLabel));
|
|
958
|
+
let browser = null;
|
|
959
|
+
let browserError = null;
|
|
960
|
+
if (browserRequested && await browserCapabilityAvailable(undefined, 400)) {
|
|
961
|
+
const hint = browserTitleHint(activeWindow);
|
|
962
|
+
try {
|
|
963
|
+
browser = resultValue(await browserSnapshot({
|
|
964
|
+
...(hint ? { title:hint } : {}),
|
|
965
|
+
max_nodes:clamp(args.browser_max_nodes, 500, 1, 2000),
|
|
966
|
+
timeout_ms:3000,
|
|
967
|
+
}));
|
|
968
|
+
} catch (error) {
|
|
969
|
+
if (hint) {
|
|
970
|
+
try {
|
|
971
|
+
browser = resultValue(await browserSnapshot({
|
|
972
|
+
max_nodes:clamp(args.browser_max_nodes, 500, 1, 2000),
|
|
973
|
+
timeout_ms:3000,
|
|
974
|
+
}));
|
|
975
|
+
} catch (fallbackError) {
|
|
976
|
+
browserError = String(fallbackError?.message || fallbackError);
|
|
977
|
+
}
|
|
978
|
+
} else {
|
|
979
|
+
browserError = String(error?.message || error);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const shouldOcr = args.include_ocr === true
|
|
985
|
+
|| (args.include_ocr !== false && captureScreenshot && uiNodes.length === 0 && !browser);
|
|
986
|
+
let ocr = null;
|
|
987
|
+
let ocrCaptureBounds = null;
|
|
988
|
+
let ocrShotResult = shotResult;
|
|
989
|
+
// A whole-desktop OCR pass is noisy and slow when the active application is already known.
|
|
990
|
+
// Keep the normal screenshot contract unchanged, but use a second tightly scoped capture for OCR.
|
|
991
|
+
if (shouldOcr && !hasExplicitComputerScreenshotTarget(args)) {
|
|
992
|
+
const bounds = activeWindowCaptureBounds(activeWindow);
|
|
993
|
+
if (bounds) {
|
|
994
|
+
try {
|
|
995
|
+
const targeted = await screenshotRegion({ ...bounds, padding:0 });
|
|
996
|
+
ocrShotResult = { status:'fulfilled', value:targeted };
|
|
997
|
+
ocrCaptureBounds = bounds;
|
|
998
|
+
} catch {
|
|
999
|
+
// Fall back to the already captured desktop screenshot below.
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
if (shouldOcr) {
|
|
1004
|
+
ocr = ocrShotResult.status === 'fulfilled' && ocrShotResult.value
|
|
1005
|
+
? await ocrScreenshotResult(ocrShotResult.value, args)
|
|
1006
|
+
: { available:false, reason:'screenshot capture failed before OCR could run' };
|
|
1007
|
+
if (ocr?.available && ocrCaptureBounds) {
|
|
1008
|
+
ocr = { ...ocr, coordinate_space:'capture_pixels', capture_bounds:ocrCaptureBounds };
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const payload = {
|
|
1013
|
+
device: { hostname: os.hostname(), platform: process.platform, arch: process.arch },
|
|
1014
|
+
platform: process.platform,
|
|
1015
|
+
hostname: os.hostname(),
|
|
1016
|
+
active_app: activeApp,
|
|
1017
|
+
active_window: activeWindow,
|
|
1018
|
+
windows,
|
|
1019
|
+
displays: parse(displaysResult),
|
|
1020
|
+
cursor: parse(cursorResult),
|
|
1021
|
+
ui,
|
|
1022
|
+
...(browser ? { browser } : {}),
|
|
1023
|
+
...(ocr ? { ocr } : {}),
|
|
1024
|
+
fallback_chain: {
|
|
1025
|
+
accessibility: { requested:includeUi, available:includeUi && uiResult.status === 'fulfilled', nodes:uiNodes.length },
|
|
1026
|
+
native_browser_dom: {
|
|
1027
|
+
requested:includeUi && args.ui_browser_dom === true,
|
|
1028
|
+
available:Boolean(includeUi && ui?.browser_dom?.available),
|
|
1029
|
+
provider:ui?.browser_dom?.provider || null,
|
|
1030
|
+
},
|
|
1031
|
+
browser_dom: { requested:browserRequested, available:Boolean(browser) },
|
|
1032
|
+
ocr: { requested:shouldOcr, available:Boolean(ocr?.available), backend:ocr?.backend || null },
|
|
1033
|
+
vision_screenshot: { available:shotResult.status === 'fulfilled' && Boolean(shotResult.value?.content?.some(part => part.type === 'image')) },
|
|
1034
|
+
},
|
|
1035
|
+
visual: {
|
|
1036
|
+
coordinate_space:'virtual_desktop',
|
|
1037
|
+
labeled_targets:uiNodes.filter(node => Number.isInteger(node.label)).slice(0, 500).map(node => ({
|
|
1038
|
+
label:node.label,
|
|
1039
|
+
id:node.id || null,
|
|
1040
|
+
role:node.role || null,
|
|
1041
|
+
name:String(node.name || '').slice(0, 240),
|
|
1042
|
+
x:Number(node.x) || 0,
|
|
1043
|
+
y:Number(node.y) || 0,
|
|
1044
|
+
width:Number(node.width) || 0,
|
|
1045
|
+
height:Number(node.height) || 0,
|
|
1046
|
+
action:node.suggested_action || suggestedUiAction(node),
|
|
1047
|
+
})),
|
|
1048
|
+
...(Number(args.grid_columns) > 0 || Number(args.grid_rows) > 0 ? {
|
|
1049
|
+
reference_grid:{
|
|
1050
|
+
columns:clamp(args.grid_columns, 0, 0, 20),
|
|
1051
|
+
rows:clamp(args.grid_rows, 0, 0, 20),
|
|
1052
|
+
},
|
|
1053
|
+
} : {}),
|
|
1054
|
+
screenshot_scope:Array.isArray(args.screenshot_region) ? 'region'
|
|
1055
|
+
: args.screenshot_window_id ? 'window'
|
|
1056
|
+
: (args.screenshot_monitor != null || args.screenshot_monitor_index != null) ? 'monitor'
|
|
1057
|
+
: 'desktop',
|
|
1058
|
+
},
|
|
1059
|
+
clipboard: { available:clipboardResult.status === 'fulfilled', length:clipText.length },
|
|
1060
|
+
errors: [
|
|
1061
|
+
...[windowsResult, displaysResult, uiResult, clipboardResult, cursorResult, shotResult]
|
|
1062
|
+
.filter(result => result.status === 'rejected')
|
|
1063
|
+
.map(result => String(result.reason?.message || result.reason)),
|
|
1064
|
+
...(browserError ? [`browser snapshot: ${browserError}`] : []),
|
|
1065
|
+
],
|
|
1066
|
+
};
|
|
1067
|
+
const boundedPayload = boundedComputerSnapshot(payload);
|
|
1068
|
+
const parts = [{ type: 'text', text: JSON.stringify(boundedPayload, null, 2) }];
|
|
1069
|
+
if (args.include_screenshot !== false && shotResult.status === 'fulfilled' && shotResult.value?.content) {
|
|
1070
|
+
for (const part of shotResult.value.content) if (part.type === 'image') parts.push(part);
|
|
1071
|
+
}
|
|
1072
|
+
const result = multi(parts);
|
|
1073
|
+
result.structuredContent = boundedPayload;
|
|
1074
|
+
return result;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function browserComputerAction(args, action) {
|
|
1078
|
+
const mapped = {
|
|
1079
|
+
click:'click',
|
|
1080
|
+
invoke:'click',
|
|
1081
|
+
toggle:'click',
|
|
1082
|
+
focus:'focus',
|
|
1083
|
+
set_value:'set_value',
|
|
1084
|
+
select:'select',
|
|
1085
|
+
type:'type',
|
|
1086
|
+
}[action];
|
|
1087
|
+
if (!mapped) throw new Error(`Browser target does not support computer_action action: ${action}`);
|
|
1088
|
+
const visibleText = optionalString(args.browser_text)
|
|
1089
|
+
|| optionalString(args.name)
|
|
1090
|
+
|| ((['click','invoke','toggle','focus'].includes(action) && optionalString(args.text)) ? optionalString(args.text) : null);
|
|
1091
|
+
return browserAction({
|
|
1092
|
+
endpoint: args.endpoint,
|
|
1093
|
+
target_id: args.target_id,
|
|
1094
|
+
title: args.title,
|
|
1095
|
+
url_contains: args.url_contains,
|
|
1096
|
+
timeout_ms: args.timeout_ms,
|
|
1097
|
+
action: mapped,
|
|
1098
|
+
selector: args.selector,
|
|
1099
|
+
...(visibleText ? { text: visibleText } : {}),
|
|
1100
|
+
...(args.value != null ? { value: args.value } : {}),
|
|
1101
|
+
...(args.option != null ? { option: args.option } : {}),
|
|
1102
|
+
...(action === 'type' && args.text != null ? { value: args.text } : {}),
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
export async function computerAction(args = {}) {
|
|
1107
|
+
const legacy = ['ui','pointer','window','type','keyboard','scroll','drag','clipboard','launch_app','open_path','reveal_path','notification'];
|
|
1108
|
+
const universal = [
|
|
1109
|
+
'click','double_click','right_click','move','down','up','invoke','focus','set_value','select','toggle',
|
|
1110
|
+
'expand','collapse','scroll_into_view','set_range_value','add_to_selection','remove_from_selection',
|
|
1111
|
+
'minimize','maximize','restore','move_resize','resize','close','wait','batch','multi_select','multi_edit',
|
|
1112
|
+
];
|
|
1113
|
+
const action = requireEnum(args.action, 'action', [...legacy, ...universal]);
|
|
1114
|
+
const explicitTarget = optionalString(args.target || args.target_type);
|
|
1115
|
+
const target = explicitTarget ? requireEnum(explicitTarget, 'target', ['auto','ui','ui_element','browser','ocr','coordinates','window']) : 'auto';
|
|
1116
|
+
const nested = { ...args, action: args.operation || args.subaction };
|
|
1117
|
+
const hasUi = Boolean(args.label != null || args.id || args.name || args.role || args.automation_id || args.automationId);
|
|
1118
|
+
const hasBrowser = Boolean(args.selector || args.browser_text || args.name);
|
|
1119
|
+
const ocrNeedle = optionalString(args.ocr_text || args.name || args.browser_text);
|
|
1120
|
+
const hasOcr = Boolean(ocrNeedle);
|
|
1121
|
+
const hasCoordinates = Number.isFinite(Number(args.x)) && Number.isFinite(Number(args.y));
|
|
1122
|
+
const automatic = target === 'auto';
|
|
1123
|
+
|
|
1124
|
+
if (action === 'batch') {
|
|
1125
|
+
const steps = Array.isArray(args.steps) ? args.steps : [];
|
|
1126
|
+
if (!steps.length) throw new Error('computer_action batch requires a non-empty steps array');
|
|
1127
|
+
if (steps.length > 50) throw new Error('computer_action batch supports at most 50 steps');
|
|
1128
|
+
const stopOnError = args.stop_on_error !== false;
|
|
1129
|
+
const results = [];
|
|
1130
|
+
for (let index = 0; index < steps.length; index += 1) {
|
|
1131
|
+
const step = steps[index];
|
|
1132
|
+
if (!step || typeof step !== 'object' || Array.isArray(step)) throw new Error(`batch step ${index} must be an object`);
|
|
1133
|
+
if (step.action === 'batch') throw new Error('nested computer_action batches are not allowed');
|
|
1134
|
+
try {
|
|
1135
|
+
const result = await computerAction(step);
|
|
1136
|
+
results.push({ index, ok:true, action:step.action, result:resultValue(result) ?? result?.content?.[0]?.text ?? null });
|
|
1137
|
+
} catch (error) {
|
|
1138
|
+
const failure = { index, ok:false, action:step.action || null, error:String(error?.message || error).slice(0, 1000) };
|
|
1139
|
+
results.push(failure);
|
|
1140
|
+
if (stopOnError) return jsonResult({ action:'batch', ok:false, stopped_at:index, completed:index, results });
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
return jsonResult({ action:'batch', ok:results.every(row => row.ok), completed:results.length, results });
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
if (action === 'multi_select') {
|
|
1147
|
+
const targets = Array.isArray(args.targets) ? args.targets : [];
|
|
1148
|
+
if (!targets.length) throw new Error('multi_select requires a non-empty targets array');
|
|
1149
|
+
if (targets.length > 100) throw new Error('multi_select supports at most 100 targets');
|
|
1150
|
+
const results = [];
|
|
1151
|
+
for (let index = 0; index < targets.length; index += 1) {
|
|
1152
|
+
const targetSpec = targets[index];
|
|
1153
|
+
const operation = index === 0 && args.replace_selection !== false ? 'select' : 'add_to_selection';
|
|
1154
|
+
const result = await uiAction({ ...targetSpec, action:operation });
|
|
1155
|
+
results.push({ index, action:operation, result:resultValue(result) ?? result?.content?.[0]?.text ?? null });
|
|
1156
|
+
}
|
|
1157
|
+
return jsonResult({ action:'multi_select', count:results.length, results });
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
if (action === 'multi_edit') {
|
|
1161
|
+
const edits = Array.isArray(args.edits) ? args.edits : [];
|
|
1162
|
+
if (!edits.length) throw new Error('multi_edit requires a non-empty edits array');
|
|
1163
|
+
if (edits.length > 100) throw new Error('multi_edit supports at most 100 edits');
|
|
1164
|
+
const results = [];
|
|
1165
|
+
for (let index = 0; index < edits.length; index += 1) {
|
|
1166
|
+
const edit = edits[index];
|
|
1167
|
+
if (!edit || typeof edit !== 'object' || edit.text == null) throw new Error(`multi_edit item ${index} requires target fields plus text`);
|
|
1168
|
+
const result = await typeText({ ...edit, method:edit.method || 'auto', clear:edit.clear !== false });
|
|
1169
|
+
results.push({ index, result:resultValue(result) ?? result?.content?.[0]?.text ?? null });
|
|
1170
|
+
}
|
|
1171
|
+
return jsonResult({ action:'multi_edit', count:results.length, results });
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
if (action === 'wait') {
|
|
1175
|
+
const requested = args.wait_ms != null ? Number(args.wait_ms) : Number(args.seconds) * 1000;
|
|
1176
|
+
if (!Number.isFinite(requested) || requested < 0) throw new Error('computer_action wait requires wait_ms or seconds');
|
|
1177
|
+
const waitMs = Math.min(120_000, Math.round(requested));
|
|
1178
|
+
await sleep(waitMs);
|
|
1179
|
+
return jsonResult({ action:'wait', waited_ms:waitMs, capped:requested > 120_000 });
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
if (action === 'ui') return uiAction(nested);
|
|
1183
|
+
if (action === 'pointer') return pointer(nested);
|
|
1184
|
+
if (action === 'window') return windowAction(nested);
|
|
1185
|
+
if (action === 'type') return target === 'browser' ? browserComputerAction(args, 'type') : typeText(args);
|
|
1186
|
+
if (action === 'keyboard') {
|
|
1187
|
+
if (target === 'browser' && args.key && !args.shortcut && !Array.isArray(args.keys)) {
|
|
1188
|
+
return browserAction({ ...args, action:'press' });
|
|
1189
|
+
}
|
|
1190
|
+
return keyboard(args);
|
|
1191
|
+
}
|
|
1192
|
+
if (action === 'scroll') {
|
|
1193
|
+
if (target === 'browser' && (args.selector || args.browser_text)) {
|
|
1194
|
+
return browserAction({ ...args, action:'scroll_into_view', text:args.browser_text });
|
|
1195
|
+
}
|
|
1196
|
+
if (args.id || args.name || args.role || args.automation_id) await uiAction({ ...args, action:'focus' }).catch(() => null);
|
|
1197
|
+
return scroll(args);
|
|
1198
|
+
}
|
|
1199
|
+
if (action === 'drag') return dragDrop(args);
|
|
1200
|
+
if (action === 'clipboard') return clipboard(nested);
|
|
1201
|
+
if (action === 'launch_app') return launchApp(args);
|
|
1202
|
+
if (action === 'open_path') return openPath(args);
|
|
1203
|
+
if (action === 'reveal_path') return revealPath(args);
|
|
1204
|
+
if (action === 'notification') return notification(args);
|
|
1205
|
+
|
|
1206
|
+
const inferredTarget = automatic
|
|
1207
|
+
? (hasUi ? 'ui_element' : hasBrowser ? 'browser' : args.ocr_text ? 'ocr' : hasCoordinates ? 'coordinates' : 'window')
|
|
1208
|
+
: target;
|
|
1209
|
+
|
|
1210
|
+
const tryUiThenBrowser = async uiActionName => {
|
|
1211
|
+
let uiError = null;
|
|
1212
|
+
if (hasUi) {
|
|
1213
|
+
try { return await uiAction({ ...args, action:uiActionName }); }
|
|
1214
|
+
catch (error) { uiError = error; }
|
|
1215
|
+
}
|
|
1216
|
+
if (hasBrowser) return browserComputerAction(args, uiActionName);
|
|
1217
|
+
if (uiError) throw uiError;
|
|
1218
|
+
return null;
|
|
1219
|
+
};
|
|
1220
|
+
|
|
1221
|
+
if (['invoke','set_value','select','toggle','expand','collapse','scroll_into_view','set_range_value','add_to_selection','remove_from_selection'].includes(action)) {
|
|
1222
|
+
const browserCompatible = ['invoke','set_value','select','toggle'].includes(action);
|
|
1223
|
+
if (inferredTarget === 'browser') {
|
|
1224
|
+
if (!browserCompatible) throw new Error(`Browser target does not support ${action}; use browser_action or native accessibility`);
|
|
1225
|
+
return browserComputerAction(args, action);
|
|
1226
|
+
}
|
|
1227
|
+
if (inferredTarget === 'ui' || inferredTarget === 'ui_element') {
|
|
1228
|
+
if (automatic && browserCompatible) {
|
|
1229
|
+
const result = await tryUiThenBrowser(action);
|
|
1230
|
+
if (result) return result;
|
|
1231
|
+
}
|
|
1232
|
+
return uiAction({ ...args, action });
|
|
1233
|
+
}
|
|
1234
|
+
throw new Error(`${action} requires a UI${browserCompatible ? ' or browser' : ''} target`);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
if (action === 'focus') {
|
|
1238
|
+
if (inferredTarget === 'browser') return browserComputerAction(args, 'focus');
|
|
1239
|
+
if (inferredTarget === 'ui' || inferredTarget === 'ui_element') {
|
|
1240
|
+
if (automatic) {
|
|
1241
|
+
const result = await tryUiThenBrowser('focus');
|
|
1242
|
+
if (result) return result;
|
|
1243
|
+
}
|
|
1244
|
+
return uiAction({ ...args, action:'focus' });
|
|
1245
|
+
}
|
|
1246
|
+
return windowAction({ ...args, action:'focus' });
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
if (['minimize','maximize','restore','move_resize','resize','close'].includes(action)) {
|
|
1250
|
+
return windowAction({ ...args, action });
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
if (['click','double_click','right_click','move','down','up'].includes(action)) {
|
|
1254
|
+
if (action === 'click' && automatic) {
|
|
1255
|
+
const failures = [];
|
|
1256
|
+
if (hasUi) {
|
|
1257
|
+
try { return await uiAction({ ...args, action:'click' }); }
|
|
1258
|
+
catch (error) { failures.push(`accessibility: ${String(error?.message || error).slice(0, 300)}`); }
|
|
1259
|
+
}
|
|
1260
|
+
if (hasBrowser) {
|
|
1261
|
+
if (await browserCapabilityAvailable(args.endpoint, 300)) {
|
|
1262
|
+
try { return await browserComputerAction(args, 'click'); }
|
|
1263
|
+
catch (error) { failures.push(`browser DOM: ${String(error?.message || error).slice(0, 300)}`); }
|
|
1264
|
+
} else {
|
|
1265
|
+
failures.push('browser DOM: no local CDP endpoint is available');
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
if (hasOcr) {
|
|
1269
|
+
try { return await clickOcrText({ ...args, ocr_text:ocrNeedle }); }
|
|
1270
|
+
catch (error) { failures.push(`OCR: ${String(error?.message || error).slice(0, 300)}`); }
|
|
1271
|
+
}
|
|
1272
|
+
if (hasCoordinates) return pointer({ ...args, action:'click' });
|
|
1273
|
+
if (failures.length) throw new Error(`computer_action click targeting failed: ${failures.join('; ')}`);
|
|
1274
|
+
throw new Error('computer_action click requires a UI/browser/OCR target or x/y coordinates');
|
|
1275
|
+
}
|
|
1276
|
+
if (inferredTarget === 'browser') {
|
|
1277
|
+
if (action !== 'click') throw new Error(`Browser target does not support ${action}; use browser_action or coordinates`);
|
|
1278
|
+
return browserComputerAction(args, 'click');
|
|
1279
|
+
}
|
|
1280
|
+
if (inferredTarget === 'ocr') {
|
|
1281
|
+
if (action !== 'click') throw new Error(`OCR target only supports click; ${action} requires a semantic or coordinate target`);
|
|
1282
|
+
return clickOcrText({ ...args, ocr_text:ocrNeedle });
|
|
1283
|
+
}
|
|
1284
|
+
if ((inferredTarget === 'ui' || inferredTarget === 'ui_element') && action === 'click') {
|
|
1285
|
+
return uiAction({ ...args, action:'click' });
|
|
1286
|
+
}
|
|
1287
|
+
return pointer({ ...args, action });
|
|
1288
|
+
}
|
|
1289
|
+
throw new Error(`Unsupported computer_action action: ${action}`);
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
export const desktopHandlers = {
|
|
1293
|
+
computer_snapshot: computerSnapshot,
|
|
1294
|
+
computer_action: computerAction,
|
|
1295
|
+
list_windows: listWindows,
|
|
1296
|
+
window_action: windowAction,
|
|
1297
|
+
launch_app: launchApp,
|
|
1298
|
+
ui_snapshot: uiSnapshot,
|
|
1299
|
+
ui_find: uiFind,
|
|
1300
|
+
ui_action: uiAction,
|
|
1301
|
+
type_text: typeText,
|
|
1302
|
+
keyboard,
|
|
1303
|
+
pointer,
|
|
1304
|
+
drag_drop: dragDrop,
|
|
1305
|
+
scroll,
|
|
1306
|
+
wait_for_ui: waitForUi,
|
|
1307
|
+
clipboard,
|
|
1308
|
+
display_inventory: displayInventory,
|
|
1309
|
+
screenshot_region: screenshotRegion,
|
|
1310
|
+
open_path: openPath,
|
|
1311
|
+
reveal_path: revealPath,
|
|
1312
|
+
notification,
|
|
1313
|
+
};
|