@remcp/runtime 0.2.153 → 0.2.155
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 +5 -0
- package/package.json +1 -1
- package/src/extended/browser-actions.mjs +311 -0
- package/src/extended/browser-endpoint.mjs +469 -0
- package/src/extended/browser-read.mjs +288 -0
- package/src/extended/browser-session.mjs +413 -0
- package/src/extended/browser.mjs +4 -1440
- package/src/extended/desktop-computer-actions.mjs +346 -0
- package/src/extended/desktop-computer-snapshot.mjs +245 -0
- package/src/extended/desktop-core.mjs +449 -0
- package/src/extended/desktop-linux-displays.mjs +366 -0
- package/src/extended/desktop-linux-input.mjs +485 -0
- package/src/extended/desktop-linux-screen.mjs +105 -0
- package/src/extended/desktop-linux-ui.mjs +407 -0
- package/src/extended/desktop-linux-window-actions.mjs +237 -0
- package/src/extended/desktop-linux-windows.mjs +321 -0
- package/src/extended/desktop-linux.mjs +6 -1791
- package/src/extended/desktop-ui-actions.mjs +402 -0
- package/src/extended/desktop-vision.mjs +209 -0
- package/src/extended/desktop.mjs +6 -1583
- package/src/tools/files-archive-actions.mjs +166 -0
- package/src/tools/files-archive-safety.mjs +395 -0
- package/src/tools/files-bulk-operations.mjs +415 -0
- package/src/tools/files-read-traversal.mjs +347 -0
- package/src/tools/files-screenshots.mjs +167 -0
- package/src/tools/files-secure-paths.mjs +408 -0
- package/src/tools/files-write-edit.mjs +336 -0
- package/src/tools/files.mjs +8 -2111
|
@@ -0,0 +1,402 @@
|
|
|
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, desktopSessionAvailable, 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
|
+
|
|
15
|
+
import { adapter, cachedUiNodes, clipboard, keyboard, latestUiSnapshot, listWindows, pointer, resultValue, sleep, uiAction, uiSnapshot, windowAction, withResolvedUiLabel } from './desktop-core.mjs';
|
|
16
|
+
|
|
17
|
+
export function matches(node, args) {
|
|
18
|
+
const id = optionalString(args.id);
|
|
19
|
+
const label = args.label == null || args.label === '' ? null : Number(args.label);
|
|
20
|
+
const name = optionalString(args.name);
|
|
21
|
+
const role = optionalString(args.role);
|
|
22
|
+
const value = optionalString(args.value);
|
|
23
|
+
const automationId = optionalString(args.automation_id || args.automationId);
|
|
24
|
+
const app = optionalString(args.app);
|
|
25
|
+
const windowTitle = optionalString(args.window_title || args.windowTitle);
|
|
26
|
+
const pid = Number.isInteger(Number(args.pid)) ? Number(args.pid) : null;
|
|
27
|
+
if (label != null && (!Number.isInteger(label) || Number(node.label) !== label)) return false;
|
|
28
|
+
if (id && node.id !== id) return false;
|
|
29
|
+
if (pid != null && Number(node.pid) !== pid) return false;
|
|
30
|
+
if (app && !String(node.app || '').toLowerCase().includes(app.toLowerCase())) return false;
|
|
31
|
+
if (windowTitle && !String(node.window || node.window_title || '').toLowerCase().includes(windowTitle.toLowerCase())) return false;
|
|
32
|
+
if (name && !String(node.name || '').toLowerCase().includes(name.toLowerCase())) return false;
|
|
33
|
+
if (role && !String(node.role || '').toLowerCase().includes(role.toLowerCase())) return false;
|
|
34
|
+
if (value && !String(node.value || '').toLowerCase().includes(value.toLowerCase())) return false;
|
|
35
|
+
if (automationId && String(node.automationId || '') !== automationId) return false;
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function uiMatches(args = {}) {
|
|
40
|
+
const resolved = args.label != null ? withResolvedUiLabel(args) : args;
|
|
41
|
+
const cached = resolved.refresh === true ? null : cachedUiNodes(resolved);
|
|
42
|
+
if (cached) {
|
|
43
|
+
const hits = cached.filter(node => matches(node, resolved)).slice(0, clamp(resolved.limit, 20, 1, 200));
|
|
44
|
+
if (hits.length || resolved.cache_only === true || resolved.id || resolved.label != null) return hits;
|
|
45
|
+
}
|
|
46
|
+
const stableLinuxPid = optionalString(resolved.id)?.match(/^linux:(\d+):/)?.[1];
|
|
47
|
+
const scopedPid = resolved.pid != null ? resolved.pid : stableLinuxPid ? Number(stableLinuxPid) : null;
|
|
48
|
+
const result = await uiSnapshot({
|
|
49
|
+
max_nodes: resolved.max_nodes || 2000,
|
|
50
|
+
max_depth: resolved.max_depth || (process.platform === 'linux' ? 12 : 16),
|
|
51
|
+
...(scopedPid != null ? { pid: scopedPid } : {}),
|
|
52
|
+
...(resolved.app ? { app: resolved.app } : {}),
|
|
53
|
+
...((resolved.window_title || resolved.windowTitle) ? { window_title: resolved.window_title || resolved.windowTitle } : {}),
|
|
54
|
+
...(resolved.browser_dom === true ? { browser_dom:true } : {}),
|
|
55
|
+
});
|
|
56
|
+
const payload = resultValue(result) || {};
|
|
57
|
+
const nodes = Array.isArray(payload) ? payload : payload.nodes || [];
|
|
58
|
+
return nodes.filter(node => matches(node, resolved)).slice(0, clamp(resolved.limit, 20, 1, 200));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function uiFind(args = {}) {
|
|
62
|
+
const hasSelector = Boolean(args.id || args.label != null || args.name || args.role || args.automation_id || args.automationId);
|
|
63
|
+
if (!hasSelector) throw new Error('ui_find requires id, label, name, role, or automation_id; use ui_snapshot to enumerate UI');
|
|
64
|
+
const nodes = await uiMatches(args);
|
|
65
|
+
return jsonResult({ count: nodes.length, nodes });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function uiFingerprint(nodes) {
|
|
69
|
+
return JSON.stringify((nodes || []).map(node => ({
|
|
70
|
+
id: node.id ?? null,
|
|
71
|
+
role: node.role ?? null,
|
|
72
|
+
name: node.name ?? null,
|
|
73
|
+
value: node.value ?? null,
|
|
74
|
+
x: Number(node.x || 0), y: Number(node.y || 0),
|
|
75
|
+
width: Number(node.width || 0), height: Number(node.height || 0),
|
|
76
|
+
})));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function uiTextContains(node, needle) {
|
|
80
|
+
if (!needle) return true;
|
|
81
|
+
const normalized = String(needle).toLowerCase();
|
|
82
|
+
return [
|
|
83
|
+
node.name, node.value, node.description, node.help_text, node.helpText,
|
|
84
|
+
node.role, node.window, node.window_title, node.automationId,
|
|
85
|
+
].some(value => String(value || '').toLowerCase().includes(normalized));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function uiConditionMatches(args, condition) {
|
|
89
|
+
if (condition === 'active_window') {
|
|
90
|
+
const expected = optionalString(args.window_title || args.windowTitle || args.text || args.name);
|
|
91
|
+
const nodes = await uiMatches({ ...args, name:undefined, role:undefined, value:undefined, id:undefined, label:undefined, limit:200 });
|
|
92
|
+
const active = nodes.filter(node => node.active === true || (
|
|
93
|
+
node.focused === true && /window|frame|dialog/i.test(String(node.role || ''))
|
|
94
|
+
));
|
|
95
|
+
const matched = expected ? active.filter(node => uiTextContains(node, expected)) : active;
|
|
96
|
+
return { matched:matched.length > 0, nodes:matched, detail:matched[0]?.window || matched[0]?.name || null };
|
|
97
|
+
}
|
|
98
|
+
if (condition === 'text_exists') {
|
|
99
|
+
const needle = optionalString(args.text || args.name || args.value);
|
|
100
|
+
if (!needle) throw new Error('text is required for wait_for_ui condition=text_exists');
|
|
101
|
+
const cached = cachedUiNodes(args);
|
|
102
|
+
let nodes = cached || [];
|
|
103
|
+
if (!cached) {
|
|
104
|
+
const snapshot = resultValue(await uiSnapshot({
|
|
105
|
+
max_nodes:args.max_nodes || 2000,
|
|
106
|
+
max_depth:args.max_depth || (process.platform === 'linux' ? 12 : 16),
|
|
107
|
+
...(args.pid != null ? { pid:args.pid } : {}),
|
|
108
|
+
...(args.app ? { app:args.app } : {}),
|
|
109
|
+
...((args.window_title || args.windowTitle) ? { window_title:args.window_title || args.windowTitle } : {}),
|
|
110
|
+
...(args.browser_dom === true ? { browser_dom:true } : {}),
|
|
111
|
+
})) || {};
|
|
112
|
+
nodes = Array.isArray(snapshot) ? snapshot : snapshot.nodes || [];
|
|
113
|
+
}
|
|
114
|
+
const matched = nodes.filter(node => uiTextContains(node, needle)).slice(0, clamp(args.limit, 20, 1, 200));
|
|
115
|
+
return { matched:matched.length > 0, nodes:matched, detail:needle };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const nodes = await uiMatches(args);
|
|
119
|
+
if (condition === 'element_enabled') {
|
|
120
|
+
const enabled = nodes.filter(node => node.enabled === true || node.disabled === false);
|
|
121
|
+
return { matched:enabled.length > 0, nodes:enabled };
|
|
122
|
+
}
|
|
123
|
+
if (condition === 'focused_element') {
|
|
124
|
+
const focused = nodes.filter(node => node.focused === true || node.has_focused === true);
|
|
125
|
+
return { matched:focused.length > 0, nodes:focused };
|
|
126
|
+
}
|
|
127
|
+
return { matched:nodes.length > 0, nodes };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function waitForUi(args = {}) {
|
|
131
|
+
const timeoutMs = clamp(args.timeout_ms, 10_000, 100, 120_000);
|
|
132
|
+
const pollMs = clamp(args.poll_ms, 250, 50, 5000);
|
|
133
|
+
const rawCondition = optionalString(args.condition);
|
|
134
|
+
const rawState = optionalString(args.state);
|
|
135
|
+
if (!rawCondition && !rawState) throw new Error('wait_for_ui requires state or condition');
|
|
136
|
+
const aliases = { text:'text_exists', window:'active_window', element:'element_exists', enabled:'element_enabled', focused:'focused_element' };
|
|
137
|
+
const condition = rawCondition
|
|
138
|
+
? requireEnum(aliases[rawCondition] || rawCondition, 'condition', ['text_exists','active_window','element_exists','element_enabled','focused_element'])
|
|
139
|
+
: null;
|
|
140
|
+
const state = condition ? null : requireEnum(rawState, 'state', ['present', 'absent', 'changed']);
|
|
141
|
+
const hasSelector = Boolean(args.id || args.label != null || args.name || args.role || args.automation_id || args.automationId);
|
|
142
|
+
if (['element_exists','element_enabled'].includes(condition) && !hasSelector) throw new Error(`wait_for_ui condition=${condition} requires a semantic target`);
|
|
143
|
+
if (condition === 'active_window' && !(args.text || args.name || args.window_title || args.windowTitle)) throw new Error('wait_for_ui condition=active_window requires text, name, or window_title');
|
|
144
|
+
if (['present','absent'].includes(state) && !hasSelector) throw new Error(`wait_for_ui state=${state} requires a semantic target`);
|
|
145
|
+
const deadline = Date.now() + timeoutMs;
|
|
146
|
+
const startedAt = Date.now();
|
|
147
|
+
let attempts = 0;
|
|
148
|
+
|
|
149
|
+
if (condition) {
|
|
150
|
+
let evaluation = { matched:false, nodes:[] };
|
|
151
|
+
do {
|
|
152
|
+
attempts += 1;
|
|
153
|
+
// A wait must observe fresh state, not repeatedly query the same 5-second cache.
|
|
154
|
+
latestUiSnapshot = null;
|
|
155
|
+
evaluation = await uiConditionMatches(args, condition);
|
|
156
|
+
if (evaluation.matched) {
|
|
157
|
+
return jsonResult({
|
|
158
|
+
condition,
|
|
159
|
+
matched:true,
|
|
160
|
+
attempts,
|
|
161
|
+
elapsed_ms:Date.now() - startedAt,
|
|
162
|
+
count:evaluation.nodes.length,
|
|
163
|
+
nodes:evaluation.nodes,
|
|
164
|
+
...(evaluation.detail ? { detail:evaluation.detail } : {}),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
await sleep(pollMs);
|
|
168
|
+
} while (Date.now() < deadline);
|
|
169
|
+
return jsonResult({
|
|
170
|
+
condition,
|
|
171
|
+
matched:false,
|
|
172
|
+
state:'timeout',
|
|
173
|
+
attempts,
|
|
174
|
+
elapsed_ms:Date.now() - startedAt,
|
|
175
|
+
count:evaluation.nodes?.length || 0,
|
|
176
|
+
nodes:evaluation.nodes || [],
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
latestUiSnapshot = null;
|
|
181
|
+
const baseline = state === 'changed' ? await uiMatches(args) : [];
|
|
182
|
+
const baselineFingerprint = state === 'changed' ? uiFingerprint(baseline) : null;
|
|
183
|
+
let nodes = baseline;
|
|
184
|
+
do {
|
|
185
|
+
attempts += 1;
|
|
186
|
+
latestUiSnapshot = null;
|
|
187
|
+
nodes = await uiMatches(args);
|
|
188
|
+
const changed = state === 'changed' && uiFingerprint(nodes) !== baselineFingerprint;
|
|
189
|
+
if ((state === 'present' && nodes.length) || (state === 'absent' && !nodes.length) || changed) {
|
|
190
|
+
return jsonResult({
|
|
191
|
+
state,
|
|
192
|
+
matched: nodes.length,
|
|
193
|
+
attempts,
|
|
194
|
+
elapsed_ms:Date.now() - startedAt,
|
|
195
|
+
nodes,
|
|
196
|
+
...(state === 'changed' ? { before: baseline } : {}),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
await sleep(pollMs);
|
|
200
|
+
} while (Date.now() < deadline);
|
|
201
|
+
return jsonResult({ state:'timeout', wanted:state, matched:nodes.length, attempts, elapsed_ms:Date.now() - startedAt, nodes, ...(state === 'changed' ? { before:baseline } : {}) });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function explicitTypeTextWindowTarget(args = {}, hasElementSelector = false) {
|
|
205
|
+
const windowId = optionalString(args.window_id || args.windowId);
|
|
206
|
+
const title = optionalString(args.title);
|
|
207
|
+
if (!windowId && !title && hasElementSelector) return null;
|
|
208
|
+
const pid = Number.isInteger(Number(args.pid)) ? Number(args.pid) : null;
|
|
209
|
+
const app = optionalString(args.app);
|
|
210
|
+
const scopedTitle = optionalString(args.window_title || args.windowTitle);
|
|
211
|
+
if (!windowId && !title && pid == null && !app && !scopedTitle) return null;
|
|
212
|
+
return {
|
|
213
|
+
...(windowId ? { id:windowId } : {}),
|
|
214
|
+
...(pid != null ? { pid } : {}),
|
|
215
|
+
...(app ? { app } : {}),
|
|
216
|
+
...((title || scopedTitle) ? { title:title || scopedTitle } : {}),
|
|
217
|
+
...(args.backend ? { backend:args.backend } : {}),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function typeText(args = {}) {
|
|
222
|
+
const value = String(args.text ?? '');
|
|
223
|
+
const method = requireEnum(args.method || 'auto', 'method', ['auto','accessibility','clipboard','keys']);
|
|
224
|
+
const resolvedArgs = args.label != null ? withResolvedUiLabel(args) : args;
|
|
225
|
+
const hasElementSelector = Boolean(
|
|
226
|
+
optionalString(resolvedArgs.id) || optionalString(resolvedArgs.name) || optionalString(resolvedArgs.role) ||
|
|
227
|
+
optionalString(resolvedArgs.automation_id || resolvedArgs.automationId)
|
|
228
|
+
);
|
|
229
|
+
const explicitWindowTarget = explicitTypeTextWindowTarget(resolvedArgs, hasElementSelector);
|
|
230
|
+
const clear = args.clear === true;
|
|
231
|
+
const pressEnter = args.press_enter === true;
|
|
232
|
+
const caret = requireEnum(args.caret_position || 'idle', 'caret_position', ['start','idle','end']);
|
|
233
|
+
|
|
234
|
+
if (
|
|
235
|
+
explicitWindowTarget && !hasElementSelector && clear && !pressEnter
|
|
236
|
+
&& (method === 'auto' || method === 'accessibility')
|
|
237
|
+
&& typeof adapter.typeTextWindowTarget === 'function'
|
|
238
|
+
) {
|
|
239
|
+
const semantic = await adapter.typeTextWindowTarget(value, resolvedArgs);
|
|
240
|
+
if (semantic) {
|
|
241
|
+
return jsonResult({ length:value.length, method:'accessibility', target:semantic, clear:true, press_enter:false });
|
|
242
|
+
}
|
|
243
|
+
if (method === 'accessibility') {
|
|
244
|
+
throw new Error('Accessibility-only text input needs exactly one editable control in the targeted window when no element selector is provided');
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if ((method === 'auto' || method === 'accessibility') && hasElementSelector) {
|
|
249
|
+
try {
|
|
250
|
+
const result = await uiAction({ ...resolvedArgs, action:'set_value', value });
|
|
251
|
+
const raw = result.content?.[0]?.text || '';
|
|
252
|
+
let target = raw;
|
|
253
|
+
try { target = JSON.parse(raw); } catch {}
|
|
254
|
+
if (pressEnter) await keyboard({ ...resolvedArgs, key:'ENTER' });
|
|
255
|
+
return jsonResult({ length:value.length, method:'accessibility', target, clear:true, press_enter:pressEnter });
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if (method === 'accessibility') throw error;
|
|
258
|
+
if (!clear && caret === 'idle' && typeof adapter.typeTextFocused === 'function') {
|
|
259
|
+
await uiAction({ ...resolvedArgs, action:'focus' });
|
|
260
|
+
const semantic = await adapter.typeTextFocused(value, resolvedArgs);
|
|
261
|
+
if (semantic) {
|
|
262
|
+
if (pressEnter) await keyboard({ ...resolvedArgs, key:'ENTER' });
|
|
263
|
+
return jsonResult({ length:value.length, method:'accessibility', target:semantic, press_enter:pressEnter });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
267
|
+
throw new Error(`Semantic text target rejected accessibility input. Refresh the UI target or use browser_action for browser page content. ${detail}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (
|
|
271
|
+
!explicitWindowTarget && (method === 'auto' || method === 'accessibility') && !hasElementSelector
|
|
272
|
+
&& typeof adapter.typeTextFocused === 'function' && !clear && caret === 'idle'
|
|
273
|
+
) {
|
|
274
|
+
const semantic = await adapter.typeTextFocused(value, resolvedArgs);
|
|
275
|
+
if (semantic) {
|
|
276
|
+
if (pressEnter) await keyboard({ ...resolvedArgs, key:'ENTER' });
|
|
277
|
+
return jsonResult({ length:value.length, method:'accessibility', target:semantic, press_enter:pressEnter });
|
|
278
|
+
}
|
|
279
|
+
if (method === 'accessibility') throw new Error('No focused editable accessibility element is available');
|
|
280
|
+
}
|
|
281
|
+
if (method === 'accessibility') {
|
|
282
|
+
throw new Error('Accessibility-only text input requires a semantic element target, one editable control in the targeted window with clear=true, or a focused editable control with clear=false and caret_position=idle');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (explicitWindowTarget) await windowAction({ action:'focus', ...explicitWindowTarget });
|
|
286
|
+
if (hasElementSelector) await uiAction({ ...resolvedArgs, action:'focus' });
|
|
287
|
+
if (clear) {
|
|
288
|
+
await keyboard({ ...resolvedArgs, shortcut:process.platform === 'darwin' ? 'CMD+A' : 'CTRL+A' });
|
|
289
|
+
await keyboard({ ...resolvedArgs, key:'BACKSPACE' });
|
|
290
|
+
}
|
|
291
|
+
if (caret === 'start') await keyboard({ ...resolvedArgs, key:'HOME' });
|
|
292
|
+
if (caret === 'end') await keyboard({ ...resolvedArgs, key:'END' });
|
|
293
|
+
|
|
294
|
+
if (method === 'keys' && typeof adapter.typeTextKeys === 'function') {
|
|
295
|
+
const result = await adapter.typeTextKeys(value, resolvedArgs);
|
|
296
|
+
if (pressEnter) await keyboard({ ...resolvedArgs, key:'ENTER' });
|
|
297
|
+
return jsonResult({ length:value.length, method:'keys', press_enter:pressEnter, ...(result?.backend ? { backend:result.backend } : {}) });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
let prior = null;
|
|
301
|
+
let priorReadable = false;
|
|
302
|
+
try {
|
|
303
|
+
const previous = await clipboard({ action:'read' });
|
|
304
|
+
prior = previous.content?.[0]?.text ?? '';
|
|
305
|
+
priorReadable = true;
|
|
306
|
+
} catch {}
|
|
307
|
+
try {
|
|
308
|
+
await clipboard({ action:'write', text:value });
|
|
309
|
+
await sleep(50);
|
|
310
|
+
await keyboard({ ...resolvedArgs, shortcut:process.platform === 'darwin' ? 'CMD+V' : 'CTRL+V' });
|
|
311
|
+
await sleep(75);
|
|
312
|
+
if (pressEnter) await keyboard({ ...resolvedArgs, key:'ENTER' });
|
|
313
|
+
} finally {
|
|
314
|
+
if (priorReadable) {
|
|
315
|
+
try { await clipboard({ action:'write', text:prior }); } catch {}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return jsonResult({ length:value.length, method:'clipboard_paste', clipboard_restored:priorReadable, press_enter:pressEnter });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export async function uiElementCenter(id, field) {
|
|
322
|
+
const matches = await uiMatches({ id, limit: 1, max_nodes: 5000, max_depth: 32, refresh:true });
|
|
323
|
+
const node = matches[0];
|
|
324
|
+
if (!node) throw new Error(`${field} UI element was not found: ${id}`);
|
|
325
|
+
const x = Number(node.x), y = Number(node.y), width = Number(node.width), height = Number(node.height);
|
|
326
|
+
if (![x, y, width, height].every(Number.isFinite) || width <= 0 || height <= 0) {
|
|
327
|
+
throw new Error(`${field} UI element has no usable screen bounds: ${id}`);
|
|
328
|
+
}
|
|
329
|
+
return { x: Math.round(x + width / 2), y: Math.round(y + height / 2), node };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export async function dragDrop(args = {}) {
|
|
333
|
+
let fromX = Number(args.from_x), fromY = Number(args.from_y);
|
|
334
|
+
let toX = Number(args.to_x), toY = Number(args.to_y);
|
|
335
|
+
let fromNode = null;
|
|
336
|
+
let toNode = null;
|
|
337
|
+
if (!Number.isFinite(fromX) || !Number.isFinite(fromY)) {
|
|
338
|
+
const id = optionalString(args.from_id);
|
|
339
|
+
if (!id) throw new Error('Provide from_x/from_y or from_id');
|
|
340
|
+
const resolved = await uiElementCenter(id, 'from_id');
|
|
341
|
+
fromX = resolved.x; fromY = resolved.y; fromNode = resolved.node;
|
|
342
|
+
}
|
|
343
|
+
if (!Number.isFinite(toX) || !Number.isFinite(toY)) {
|
|
344
|
+
const id = optionalString(args.to_id);
|
|
345
|
+
if (!id) throw new Error('Provide to_x/to_y or to_id');
|
|
346
|
+
const resolved = await uiElementCenter(id, 'to_id');
|
|
347
|
+
toX = resolved.x; toY = resolved.y; toNode = resolved.node;
|
|
348
|
+
}
|
|
349
|
+
if (typeof adapter.dragDrop === 'function') {
|
|
350
|
+
const result = await adapter.dragDrop({ ...args, from_x: fromX, from_y: fromY, to_x: toX, to_y: toY });
|
|
351
|
+
if (!fromNode && !toNode) return result;
|
|
352
|
+
return jsonResult({ from: [fromX, fromY], to: [toX, toY], from_element: fromNode?.id || null, to_element: toNode?.id || null });
|
|
353
|
+
}
|
|
354
|
+
await pointer({ action: 'move', x: fromX, y: fromY, button: args.button });
|
|
355
|
+
await pointer({ action: 'down', x: fromX, y: fromY, button: args.button });
|
|
356
|
+
await sleep(clamp(args.hold_ms, 120, 0, 5000));
|
|
357
|
+
await pointer({ action: 'move', x: toX, y: toY, button: args.button });
|
|
358
|
+
await sleep(clamp(args.duration_ms, 120, 0, 5000));
|
|
359
|
+
await pointer({ action: 'up', x: toX, y: toY, button: args.button });
|
|
360
|
+
return jsonResult({ from: [fromX, fromY], to: [toX, toY], from_element: fromNode?.id || null, to_element: toNode?.id || null });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export async function launchApp(args = {}) {
|
|
364
|
+
const app = optionalString(args.app || args.path);
|
|
365
|
+
if (!app) throw new Error('app is required');
|
|
366
|
+
const argv = Array.isArray(args.args) ? args.args.map(String) : [];
|
|
367
|
+
const cwd = args.cwd != null ? await resolveSafePath(args.cwd, 'cwd') : null;
|
|
368
|
+
const policy = assertAllowedCommand([app, ...argv].join(' '));
|
|
369
|
+
const result = await adapter.launchApp(app, argv, { ...args, cwd });
|
|
370
|
+
const body = result?.content?.[0]?.text || (typeof result === 'string' ? result : `Launched ${app}.`);
|
|
371
|
+
|
|
372
|
+
const waitHint = optionalString(args.wait_for_window);
|
|
373
|
+
if (waitHint) {
|
|
374
|
+
const timeoutMs = clamp(args.wait_timeout_ms, 10_000, 100, 120_000);
|
|
375
|
+
const deadline = Date.now() + timeoutMs;
|
|
376
|
+
let match = null;
|
|
377
|
+
do {
|
|
378
|
+
const windows = resultValue(await listWindows({ include_monitor:false }));
|
|
379
|
+
match = Array.isArray(windows) ? windows.find(row =>
|
|
380
|
+
String(row.title || '').toLowerCase().includes(waitHint.toLowerCase())
|
|
381
|
+
|| String(row.app || '').toLowerCase().includes(waitHint.toLowerCase())
|
|
382
|
+
) : null;
|
|
383
|
+
if (match) break;
|
|
384
|
+
await sleep(100);
|
|
385
|
+
} while (Date.now() < deadline);
|
|
386
|
+
if (!match) throw new Error(`Application launched but no window matching "${waitHint}" appeared within ${timeoutMs} ms`);
|
|
387
|
+
return text(`${policy.note ? `${policy.note}\n` : ''}${body}\nWindow ready: ${match.title || match.app || match.id}`);
|
|
388
|
+
}
|
|
389
|
+
return text(`${policy.note ? `${policy.note}\n` : ''}${body}`);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export async function openPath(args = {}) {
|
|
393
|
+
const target = await resolveSafePath(args.path, 'path');
|
|
394
|
+
await adapter.openPath(target);
|
|
395
|
+
return text(`Opened ${target}.`);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export async function revealPath(args = {}) {
|
|
399
|
+
const target = await resolveSafePath(args.path, 'path');
|
|
400
|
+
await adapter.revealPath(target);
|
|
401
|
+
return text(`Revealed ${target}.`);
|
|
402
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
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, desktopSessionAvailable, 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
|
+
|
|
15
|
+
import { displayInventory, pointer, resultValue, screenshotRegion, sleep } from './desktop-core.mjs';
|
|
16
|
+
import { computerSnapshot } from './desktop-computer-snapshot.mjs';
|
|
17
|
+
|
|
18
|
+
export function screenshotImagePart(screenshotResult) {
|
|
19
|
+
return screenshotResult?.content?.find(part => part.type === 'image' && typeof part.data === 'string') || null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function ocrScreenshotResult(screenshotResult, args = {}) {
|
|
23
|
+
const imagePart = screenshotImagePart(screenshotResult);
|
|
24
|
+
if (!imagePart) return { available:false, reason:'no screenshot image is available for OCR' };
|
|
25
|
+
return ocrImage(Buffer.from(imagePart.data, 'base64'), {
|
|
26
|
+
language:args.ocr_language,
|
|
27
|
+
max_words:args.ocr_max_words,
|
|
28
|
+
psm:args.ocr_psm,
|
|
29
|
+
timeout_ms:args.ocr_timeout_ms,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function pngDimensions(imagePart) {
|
|
34
|
+
if (!imagePart || imagePart.mimeType !== 'image/png') return null;
|
|
35
|
+
let buffer;
|
|
36
|
+
try { buffer = Buffer.from(imagePart.data, 'base64'); } catch { return null; }
|
|
37
|
+
if (buffer.length < 24 || buffer.toString('ascii', 1, 4) !== 'PNG') return null;
|
|
38
|
+
const width = buffer.readUInt32BE(16);
|
|
39
|
+
const height = buffer.readUInt32BE(20);
|
|
40
|
+
return width > 0 && height > 0 ? { width, height } : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function virtualDesktopBounds(displays) {
|
|
44
|
+
if (!Array.isArray(displays) || !displays.length) return null;
|
|
45
|
+
const valid = displays.filter(display =>
|
|
46
|
+
[display.x, display.y, display.width, display.height].every(value => Number.isFinite(Number(value)))
|
|
47
|
+
&& Number(display.width) > 0
|
|
48
|
+
&& Number(display.height) > 0
|
|
49
|
+
);
|
|
50
|
+
if (!valid.length) return null;
|
|
51
|
+
const x = Math.min(...valid.map(display => Number(display.x)));
|
|
52
|
+
const y = Math.min(...valid.map(display => Number(display.y)));
|
|
53
|
+
const right = Math.max(...valid.map(display => Number(display.x) + Number(display.width)));
|
|
54
|
+
const bottom = Math.max(...valid.map(display => Number(display.y) + Number(display.height)));
|
|
55
|
+
return { x, y, width:right - x, height:bottom - y };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function mapOcrBoxToDesktop(box, imagePart, displays, captureBounds = null) {
|
|
59
|
+
const image = pngDimensions(imagePart);
|
|
60
|
+
const desktop = captureBounds || virtualDesktopBounds(displays);
|
|
61
|
+
const centerX = Number(box?.x) + Number(box?.width) / 2;
|
|
62
|
+
const centerY = Number(box?.y) + Number(box?.height) / 2;
|
|
63
|
+
if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) throw new Error('OCR match has invalid bounds');
|
|
64
|
+
if (!image || !desktop || desktop.width <= 0 || desktop.height <= 0) {
|
|
65
|
+
return { x:Math.round(centerX), y:Math.round(centerY), scale_x:1, scale_y:1, desktop_origin:[0,0] };
|
|
66
|
+
}
|
|
67
|
+
const scaleX = image.width / desktop.width;
|
|
68
|
+
const scaleY = image.height / desktop.height;
|
|
69
|
+
if (!Number.isFinite(scaleX) || !Number.isFinite(scaleY) || scaleX <= 0 || scaleY <= 0) {
|
|
70
|
+
throw new Error('Could not map OCR pixels to desktop coordinates');
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
x:Math.round(desktop.x + centerX / scaleX),
|
|
74
|
+
y:Math.round(desktop.y + centerY / scaleY),
|
|
75
|
+
scale_x:scaleX,
|
|
76
|
+
scale_y:scaleY,
|
|
77
|
+
desktop_origin:[desktop.x, desktop.y],
|
|
78
|
+
screenshot_size:[image.width, image.height],
|
|
79
|
+
desktop_size:[desktop.width, desktop.height],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function clickOcrText(args = {}) {
|
|
84
|
+
const needle = optionalString(args.ocr_text || args.name || args.browser_text);
|
|
85
|
+
if (!needle) throw new Error('ocr_text is required for OCR targeting');
|
|
86
|
+
|
|
87
|
+
let screenshot = null;
|
|
88
|
+
let captureBounds = null;
|
|
89
|
+
const explicitRegion = Array.isArray(args.ocr_region) && args.ocr_region.length === 4
|
|
90
|
+
? args.ocr_region.map(Number)
|
|
91
|
+
: null;
|
|
92
|
+
if (explicitRegion?.every(Number.isFinite)) {
|
|
93
|
+
const [left, top, right, bottom] = explicitRegion;
|
|
94
|
+
if (right <= left || bottom <= top) throw new Error('ocr_region must be [left,top,right,bottom] with positive area');
|
|
95
|
+
captureBounds = { x:Math.round(left), y:Math.round(top), width:Math.round(right-left), height:Math.round(bottom-top) };
|
|
96
|
+
screenshot = await screenshotRegion({ ...captureBounds, padding:0 });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Without an explicit OCR region, discover the current semantic foreground first. OCR on a tightly
|
|
100
|
+
// scoped active window is much more accurate than OCR on a dense whole-desktop screenshot and still
|
|
101
|
+
// falls back safely below.
|
|
102
|
+
if (!screenshot) try {
|
|
103
|
+
const state = resultValue(await computerSnapshot({
|
|
104
|
+
include_screenshot:false,
|
|
105
|
+
include_browser:false,
|
|
106
|
+
include_ocr:false,
|
|
107
|
+
max_ui_nodes:300,
|
|
108
|
+
max_ui_depth:8,
|
|
109
|
+
}));
|
|
110
|
+
captureBounds = activeWindowCaptureBounds(state?.active_window);
|
|
111
|
+
if (captureBounds) screenshot = await screenshotRegion({ ...captureBounds, padding:0 });
|
|
112
|
+
} catch {}
|
|
113
|
+
|
|
114
|
+
if (!screenshot) {
|
|
115
|
+
screenshot = await fileToolHandlers.take_screenshot({
|
|
116
|
+
keep:false,
|
|
117
|
+
...(runtimeConfig.allowedRoots[0] ? { directory:runtimeConfig.allowedRoots[0] } : {}),
|
|
118
|
+
});
|
|
119
|
+
captureBounds = null;
|
|
120
|
+
}
|
|
121
|
+
const deadline = Date.now() + clamp(args.timeout_ms, 1500, 300, 10_000);
|
|
122
|
+
let imagePart = null;
|
|
123
|
+
let ocr = null;
|
|
124
|
+
let match = null;
|
|
125
|
+
let attempts = 0;
|
|
126
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
127
|
+
attempts = attempt + 1;
|
|
128
|
+
imagePart = screenshotImagePart(screenshot);
|
|
129
|
+
ocr = await ocrScreenshotResult(screenshot, args);
|
|
130
|
+
if (!ocr.available) throw new Error(`OCR is unavailable: ${ocr.reason || 'unknown OCR error'}`);
|
|
131
|
+
match = findOcrText(ocr, needle);
|
|
132
|
+
if (match) break;
|
|
133
|
+
if (attempt >= 2 || Date.now() >= deadline) break;
|
|
134
|
+
await sleep(Math.min(150, Math.max(1, deadline - Date.now())));
|
|
135
|
+
if (captureBounds) {
|
|
136
|
+
screenshot = await screenshotRegion({ ...captureBounds, padding:0 });
|
|
137
|
+
} else {
|
|
138
|
+
screenshot = await fileToolHandlers.take_screenshot({
|
|
139
|
+
keep:false,
|
|
140
|
+
...(runtimeConfig.allowedRoots[0] ? { directory:runtimeConfig.allowedRoots[0] } : {}),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (!match) throw new Error(`OCR text not found after ${attempts} capture${attempts === 1 ? '' : 's'}: ${needle}`);
|
|
145
|
+
let displays = null;
|
|
146
|
+
try { displays = resultValue(await displayInventory()); } catch {}
|
|
147
|
+
const point = mapOcrBoxToDesktop(match, imagePart, displays, captureBounds);
|
|
148
|
+
const pointerResult = await pointer({ ...args, action:'click', x:point.x, y:point.y });
|
|
149
|
+
return jsonResult({
|
|
150
|
+
target:'ocr',
|
|
151
|
+
action:'click',
|
|
152
|
+
query:needle,
|
|
153
|
+
attempts,
|
|
154
|
+
match,
|
|
155
|
+
point:{ x:point.x, y:point.y },
|
|
156
|
+
mapping:{
|
|
157
|
+
scale_x:point.scale_x,
|
|
158
|
+
scale_y:point.scale_y,
|
|
159
|
+
desktop_origin:point.desktop_origin,
|
|
160
|
+
screenshot_size:point.screenshot_size || null,
|
|
161
|
+
desktop_size:point.desktop_size || null,
|
|
162
|
+
capture_bounds:captureBounds,
|
|
163
|
+
},
|
|
164
|
+
pointer:resultValue(pointerResult) || pointerResult?.content?.[0]?.text || null,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function browserTitleHint(activeWindow) {
|
|
169
|
+
const title = optionalString(activeWindow?.title);
|
|
170
|
+
if (!title) return null;
|
|
171
|
+
const stripped = title.replace(/\s+[-—]\s+(Google Chrome|Chromium|Microsoft Edge|Brave)(?:\s+Browser)?$/i, '').trim();
|
|
172
|
+
return stripped || null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function hasExplicitComputerScreenshotTarget(args = {}) {
|
|
176
|
+
return (Array.isArray(args.screenshot_region) && args.screenshot_region.length === 4)
|
|
177
|
+
|| Boolean(args.screenshot_window_id)
|
|
178
|
+
|| args.screenshot_monitor != null
|
|
179
|
+
|| args.screenshot_monitor_index != null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function activeWindowCaptureBounds(window) {
|
|
183
|
+
if (!window) return null;
|
|
184
|
+
const x = Number(window.x), y = Number(window.y), width = Number(window.width), height = Number(window.height);
|
|
185
|
+
if (![x,y,width,height].every(Number.isFinite) || width <= 0 || height <= 0) return null;
|
|
186
|
+
return { x:Math.round(x), y:Math.round(y), width:Math.round(width), height:Math.round(height) };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function computerScreenshotTask(args = {}) {
|
|
190
|
+
const region = Array.isArray(args.screenshot_region) ? args.screenshot_region.map(Number) : null;
|
|
191
|
+
const targeted = region
|
|
192
|
+
|| args.screenshot_window_id
|
|
193
|
+
|| args.screenshot_monitor != null
|
|
194
|
+
|| args.screenshot_monitor_index != null;
|
|
195
|
+
if (targeted) {
|
|
196
|
+
const targetArgs = {
|
|
197
|
+
padding:args.screenshot_padding,
|
|
198
|
+
...(region?.length === 4 ? { x:region[0], y:region[1], width:region[2] - region[0], height:region[3] - region[1] } : {}),
|
|
199
|
+
...(args.screenshot_window_id ? { window_id:args.screenshot_window_id } : {}),
|
|
200
|
+
...(args.screenshot_monitor != null ? { monitor:args.screenshot_monitor } : {}),
|
|
201
|
+
...(args.screenshot_monitor_index != null ? { monitor_index:args.screenshot_monitor_index } : {}),
|
|
202
|
+
};
|
|
203
|
+
return screenshotRegion(targetArgs);
|
|
204
|
+
}
|
|
205
|
+
return fileToolHandlers.take_screenshot({
|
|
206
|
+
keep:false,
|
|
207
|
+
...(runtimeConfig.allowedRoots[0] ? { directory:runtimeConfig.allowedRoots[0] } : {}),
|
|
208
|
+
});
|
|
209
|
+
}
|