promethios-bridge 1.5.1 → 1.7.0
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/package.json +9 -5
- package/src/bridge.js +136 -4
- package/src/contextCapture.js +324 -0
- package/src/executor.js +277 -10
package/package.json
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "promethios-bridge",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Run Promethios agent frameworks locally on your computer with full file, terminal, browser access, and Native Framework Mode
|
|
3
|
+
"version": "1.7.0",
|
|
4
|
+
"description": "Run Promethios agent frameworks locally on your computer with full file, terminal, browser access, ambient context capture, and the always-on-top floating chat overlay. Native Framework Mode supports OpenClaw and other frameworks via the bridge.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"promethios-bridge": "src/cli.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node src/cli.js",
|
|
11
|
-
"dev": "node src/cli.js --dev"
|
|
11
|
+
"dev": "node src/cli.js --dev",
|
|
12
|
+
"postinstall": "node node_modules/playwright/cli.js install chromium || npx playwright install chromium || true"
|
|
12
13
|
},
|
|
13
14
|
"keywords": [
|
|
14
15
|
"promethios",
|
|
@@ -19,7 +20,9 @@
|
|
|
19
20
|
"framework",
|
|
20
21
|
"openclaw",
|
|
21
22
|
"native",
|
|
22
|
-
"clawhub"
|
|
23
|
+
"clawhub",
|
|
24
|
+
"overlay",
|
|
25
|
+
"ambient"
|
|
23
26
|
],
|
|
24
27
|
"author": "Promethios <hello@promethios.ai>",
|
|
25
28
|
"license": "MIT",
|
|
@@ -50,7 +53,8 @@
|
|
|
50
53
|
"playwright": "^1.42.0"
|
|
51
54
|
},
|
|
52
55
|
"optionalDependencies": {
|
|
53
|
-
"playwright": "^1.42.0"
|
|
56
|
+
"playwright": "^1.42.0",
|
|
57
|
+
"electron": "^29.0.0"
|
|
54
58
|
},
|
|
55
59
|
"engines": {
|
|
56
60
|
"node": ">=18.0.0"
|
package/src/bridge.js
CHANGED
|
@@ -20,9 +20,15 @@ const chalk = require('chalk');
|
|
|
20
20
|
const ora = require('ora');
|
|
21
21
|
const fetch = require('node-fetch');
|
|
22
22
|
const { executeLocalTool } = require('./executor');
|
|
23
|
+
const { captureContext } = require('./contextCapture');
|
|
24
|
+
|
|
25
|
+
// Optional: Electron overlay window (gracefully skipped if not installed)
|
|
26
|
+
let launchOverlay = null;
|
|
27
|
+
try { launchOverlay = require('promethios-overlay/src/launcher').launchOverlay; } catch { /* overlay not installed */ }
|
|
23
28
|
|
|
24
29
|
const HEARTBEAT_INTERVAL = 30_000; // 30s
|
|
25
30
|
const POLL_INTERVAL = 1_000; // 1s — poll for pending tool calls
|
|
31
|
+
const CONTEXT_PUSH_INTERVAL = 5_000; // 5s — push ambient context snapshot
|
|
26
32
|
|
|
27
33
|
async function startBridge({ setupToken, apiBase, port, dev }) {
|
|
28
34
|
const log = dev
|
|
@@ -169,6 +175,21 @@ async function startBridge({ setupToken, apiBase, port, dev }) {
|
|
|
169
175
|
console.log(chalk.gray(' Press Ctrl+C to disconnect.'));
|
|
170
176
|
console.log('');
|
|
171
177
|
|
|
178
|
+
// ── Step 4c: Launch Electron overlay window (if available) ──────────────────
|
|
179
|
+
// The overlay is an always-on-top floating chat pill that works across all
|
|
180
|
+
// apps on Windows, Mac, and Linux. It auto-launches alongside the bridge.
|
|
181
|
+
// Users can toggle watching on/off from the pill without restarting the bridge.
|
|
182
|
+
if (launchOverlay) {
|
|
183
|
+
try {
|
|
184
|
+
launchOverlay({ authToken, apiBase, dev });
|
|
185
|
+
console.log(chalk.cyan(' ⬡ Promethios overlay launched — floating chat is ready'));
|
|
186
|
+
console.log(chalk.gray(' Hotkey: Ctrl+Shift+P (Win/Linux) or Cmd+Shift+P (Mac)'));
|
|
187
|
+
console.log('');
|
|
188
|
+
} catch (err) {
|
|
189
|
+
log('Overlay launch failed (non-critical):', err.message);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
172
193
|
// Heartbeat loop
|
|
173
194
|
const heartbeatTimer = setInterval(async () => {
|
|
174
195
|
try {
|
|
@@ -182,6 +203,27 @@ async function startBridge({ setupToken, apiBase, port, dev }) {
|
|
|
182
203
|
}
|
|
183
204
|
}, HEARTBEAT_INTERVAL);
|
|
184
205
|
|
|
206
|
+
// ── Step 4b: Ambient context push loop ──────────────────────────────────────
|
|
207
|
+
// Every 5 seconds, capture what the user is working on (active window, browser
|
|
208
|
+
// URL, clipboard) and push it to the API. The agent uses this to give
|
|
209
|
+
// context-aware help without the user having to explain what they're doing.
|
|
210
|
+
const contextPushLoop = async () => {
|
|
211
|
+
while (pollActive) {
|
|
212
|
+
try {
|
|
213
|
+
const snapshot = await captureContext(process.platform, dev);
|
|
214
|
+
await fetch(`${apiBase}/api/local-bridge/context`, {
|
|
215
|
+
method: 'POST',
|
|
216
|
+
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
|
|
217
|
+
body: JSON.stringify(snapshot),
|
|
218
|
+
});
|
|
219
|
+
log('Context pushed:', snapshot.active_app, snapshot.active_url || '');
|
|
220
|
+
} catch (err) {
|
|
221
|
+
log('Context push failed:', err.message);
|
|
222
|
+
}
|
|
223
|
+
await sleep(CONTEXT_PUSH_INTERVAL);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
185
227
|
// ── Step 5: Poll for pending tool calls (Firestore relay) ─────────────────
|
|
186
228
|
// The cloud backend queues tool calls in Firestore when the agent uses
|
|
187
229
|
// local_shell, local_file_read, or local_file_write. We poll for them here,
|
|
@@ -219,6 +261,11 @@ async function startBridge({ setupToken, apiBase, port, dev }) {
|
|
|
219
261
|
console.error(chalk.red(' ✗ Poll loop crashed:'), err.message);
|
|
220
262
|
});
|
|
221
263
|
|
|
264
|
+
// Start context push loop in background
|
|
265
|
+
contextPushLoop().catch(err => {
|
|
266
|
+
log('Context push loop crashed:', err.message);
|
|
267
|
+
});
|
|
268
|
+
|
|
222
269
|
// Graceful shutdown
|
|
223
270
|
const shutdown = async (signal) => {
|
|
224
271
|
console.log('');
|
|
@@ -321,6 +368,27 @@ async function registerBridge({ authToken, apiBase, callbackUrl, port, dev }) {
|
|
|
321
368
|
const homeDir = osModule.homedir();
|
|
322
369
|
const username = osModule.userInfo().username;
|
|
323
370
|
|
|
371
|
+
// ── Desktop path probe ────────────────────────────────────────────────────
|
|
372
|
+
// On Windows, the visual Desktop can be redirected anywhere by OneDrive,
|
|
373
|
+
// Group Policy, or manual folder redirection. We probe the real path at
|
|
374
|
+
// connect time so the agent always uses the correct location.
|
|
375
|
+
// Resolution order (first that exists wins):
|
|
376
|
+
// 1. Windows registry: HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders\Desktop
|
|
377
|
+
// This is the authoritative source — it reflects OneDrive redirection,
|
|
378
|
+
// GPO folder redirection, and any custom Desktop path.
|
|
379
|
+
// 2. %USERPROFILE%\OneDrive\Desktop (common OneDrive default)
|
|
380
|
+
// 3. %USERPROFILE%\Desktop (standard local Desktop)
|
|
381
|
+
let desktopPath = null;
|
|
382
|
+
if (platform === 'win32') {
|
|
383
|
+
desktopPath = await probeWindowsDesktopPath(homeDir, dev);
|
|
384
|
+
} else if (platform === 'darwin') {
|
|
385
|
+
desktopPath = `${homeDir}/Desktop`;
|
|
386
|
+
} else {
|
|
387
|
+
desktopPath = `${homeDir}/Desktop`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (dev) console.log('[debug] Resolved desktopPath:', desktopPath);
|
|
391
|
+
|
|
324
392
|
const res = await fetch(`${apiBase}/api/local-bridge/register`, {
|
|
325
393
|
method: 'POST',
|
|
326
394
|
headers: {
|
|
@@ -332,10 +400,11 @@ async function registerBridge({ authToken, apiBase, callbackUrl, port, dev }) {
|
|
|
332
400
|
callbackUrl,
|
|
333
401
|
capabilities,
|
|
334
402
|
bridgeVersion: require('../package.json').version,
|
|
335
|
-
os: platform,
|
|
336
|
-
shell,
|
|
337
|
-
homeDir,
|
|
338
|
-
username,
|
|
403
|
+
os: platform, // 'win32' | 'darwin' | 'linux'
|
|
404
|
+
shell, // 'cmd' | 'zsh' | 'bash' etc.
|
|
405
|
+
homeDir, // e.g. 'C:\\Users\\ted' or '/Users/ted'
|
|
406
|
+
username, // e.g. 'ted'
|
|
407
|
+
desktopPath, // resolved real Desktop path — may differ from homeDir\Desktop
|
|
339
408
|
}),
|
|
340
409
|
});
|
|
341
410
|
|
|
@@ -345,6 +414,69 @@ async function registerBridge({ authToken, apiBase, callbackUrl, port, dev }) {
|
|
|
345
414
|
}
|
|
346
415
|
}
|
|
347
416
|
|
|
417
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
418
|
+
// Probe the real Windows Desktop path for any user configuration
|
|
419
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
420
|
+
async function probeWindowsDesktopPath(homeDir, dev) {
|
|
421
|
+
const { execSync } = require('child_process');
|
|
422
|
+
const fs = require('fs');
|
|
423
|
+
const path = require('path');
|
|
424
|
+
const log = dev ? (...a) => console.log('[debug]', ...a) : () => {};
|
|
425
|
+
|
|
426
|
+
// Strategy 1: Read the Windows registry — the authoritative source.
|
|
427
|
+
// The 'User Shell Folders' key stores the ACTUAL Desktop path after any
|
|
428
|
+
// OneDrive or GPO folder redirection. It may contain %USERPROFILE% which
|
|
429
|
+
// we expand manually.
|
|
430
|
+
try {
|
|
431
|
+
const regOutput = execSync(
|
|
432
|
+
'reg query "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders" /v Desktop',
|
|
433
|
+
{ encoding: 'utf8', timeout: 5000 }
|
|
434
|
+
);
|
|
435
|
+
// Output format: " Desktop REG_EXPAND_SZ C:\Users\ted\OneDrive\Desktop"
|
|
436
|
+
const match = regOutput.match(/Desktop\s+REG_EXPAND_SZ\s+(.+)/);
|
|
437
|
+
if (match) {
|
|
438
|
+
let regPath = match[1].trim();
|
|
439
|
+
// Expand %USERPROFILE% manually (Windows env vars in registry values)
|
|
440
|
+
regPath = regPath.replace(/%USERPROFILE%/gi, homeDir);
|
|
441
|
+
regPath = regPath.replace(/%USERNAME%/gi, path.basename(homeDir));
|
|
442
|
+
log('Registry Desktop path:', regPath);
|
|
443
|
+
if (fs.existsSync(regPath)) {
|
|
444
|
+
return regPath;
|
|
445
|
+
}
|
|
446
|
+
log('Registry path does not exist on disk, falling back:', regPath);
|
|
447
|
+
}
|
|
448
|
+
} catch (e) {
|
|
449
|
+
log('Registry query failed:', e.message);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Strategy 2: PowerShell [Environment]::GetFolderPath('Desktop')
|
|
453
|
+
// This resolves the shell namespace Desktop correctly including OneDrive.
|
|
454
|
+
try {
|
|
455
|
+
const psOutput = execSync(
|
|
456
|
+
'powershell -NoProfile -Command "[Environment]::GetFolderPath(\'Desktop\')"',
|
|
457
|
+
{ encoding: 'utf8', timeout: 8000 }
|
|
458
|
+
).trim();
|
|
459
|
+
if (psOutput && fs.existsSync(psOutput)) {
|
|
460
|
+
log('PowerShell Desktop path:', psOutput);
|
|
461
|
+
return psOutput;
|
|
462
|
+
}
|
|
463
|
+
} catch (e) {
|
|
464
|
+
log('PowerShell Desktop probe failed:', e.message);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Strategy 3: Check common OneDrive Desktop path
|
|
468
|
+
const oneDriveDesktop = path.join(homeDir, 'OneDrive', 'Desktop');
|
|
469
|
+
if (fs.existsSync(oneDriveDesktop)) {
|
|
470
|
+
log('OneDrive Desktop found:', oneDriveDesktop);
|
|
471
|
+
return oneDriveDesktop;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Strategy 4: Standard local Desktop fallback
|
|
475
|
+
const localDesktop = path.join(homeDir, 'Desktop');
|
|
476
|
+
log('Falling back to local Desktop:', localDesktop);
|
|
477
|
+
return localDesktop;
|
|
478
|
+
}
|
|
479
|
+
|
|
348
480
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
349
481
|
// What this bridge can do (declared to the cloud for permission manifest display)
|
|
350
482
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Promethios Local Bridge — Ambient Context Capture
|
|
3
|
+
*
|
|
4
|
+
* Captures what the user is currently working on:
|
|
5
|
+
* - Active window title + process name
|
|
6
|
+
* - Active browser URL (Chrome, Edge, Firefox via accessibility / window title)
|
|
7
|
+
* - Selected / clipboard text (last copied text)
|
|
8
|
+
* - Active form field content (via clipboard snapshot)
|
|
9
|
+
*
|
|
10
|
+
* Designed to run every 5 seconds and push a lightweight context snapshot
|
|
11
|
+
* to the Promethios API so the AI always knows what the user is doing.
|
|
12
|
+
*
|
|
13
|
+
* Platform support:
|
|
14
|
+
* - Windows: PowerShell Get-Process / UIAutomation / Get-Clipboard
|
|
15
|
+
* - macOS: AppleScript / pbpaste
|
|
16
|
+
* - Linux: xdotool / xclip (if available)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { execSync, exec } = require('child_process');
|
|
20
|
+
|
|
21
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
// Main capture function — returns a context snapshot object
|
|
23
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
24
|
+
async function captureContext(platform, dev) {
|
|
25
|
+
const log = dev ? (...a) => console.log('[context]', ...a) : () => {};
|
|
26
|
+
|
|
27
|
+
const snapshot = {
|
|
28
|
+
captured_at: new Date().toISOString(),
|
|
29
|
+
active_window: null, // { title, process }
|
|
30
|
+
active_url: null, // string — browser URL if active
|
|
31
|
+
active_app: null, // friendly app name
|
|
32
|
+
clipboard_text: null, // last copied text (truncated to 500 chars)
|
|
33
|
+
browser_tab_title: null, // browser tab title if browser is active
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
if (platform === 'win32') {
|
|
38
|
+
await captureWindows(snapshot, log);
|
|
39
|
+
} else if (platform === 'darwin') {
|
|
40
|
+
await captureMac(snapshot, log);
|
|
41
|
+
} else {
|
|
42
|
+
await captureLinux(snapshot, log);
|
|
43
|
+
}
|
|
44
|
+
} catch (err) {
|
|
45
|
+
log('Context capture error:', err.message);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return snapshot;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
52
|
+
// Windows capture via PowerShell
|
|
53
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
54
|
+
async function captureWindows(snapshot, log) {
|
|
55
|
+
// Active window title + process name
|
|
56
|
+
try {
|
|
57
|
+
const psScript = `
|
|
58
|
+
Add-Type @"
|
|
59
|
+
using System;
|
|
60
|
+
using System.Runtime.InteropServices;
|
|
61
|
+
using System.Text;
|
|
62
|
+
public class Win32 {
|
|
63
|
+
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
|
64
|
+
[DllImport("user32.dll")] public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
|
|
65
|
+
[DllImport("user32.dll")] public static extern int GetWindowThreadProcessId(IntPtr hWnd, out int lpdwProcessId);
|
|
66
|
+
}
|
|
67
|
+
"@
|
|
68
|
+
$hwnd = [Win32]::GetForegroundWindow()
|
|
69
|
+
$sb = New-Object System.Text.StringBuilder 512
|
|
70
|
+
[Win32]::GetWindowText($hwnd, $sb, 512) | Out-Null
|
|
71
|
+
$pid = 0
|
|
72
|
+
[Win32]::GetWindowThreadProcessId($hwnd, [ref]$pid) | Out-Null
|
|
73
|
+
$proc = Get-Process -Id $pid -ErrorAction SilentlyContinue
|
|
74
|
+
$result = @{
|
|
75
|
+
title = $sb.ToString()
|
|
76
|
+
process = if ($proc) { $proc.Name } else { "unknown" }
|
|
77
|
+
} | ConvertTo-Json -Compress
|
|
78
|
+
Write-Output $result
|
|
79
|
+
`.trim();
|
|
80
|
+
|
|
81
|
+
const output = execSync(
|
|
82
|
+
`powershell -NoProfile -NonInteractive -Command "${psScript.replace(/"/g, '\\"').replace(/\n/g, ' ')}"`,
|
|
83
|
+
{ encoding: 'utf8', timeout: 4000, windowsHide: true }
|
|
84
|
+
).trim();
|
|
85
|
+
|
|
86
|
+
const parsed = JSON.parse(output);
|
|
87
|
+
snapshot.active_window = { title: parsed.title, process: parsed.process };
|
|
88
|
+
snapshot.active_app = friendlyAppName(parsed.process, parsed.title);
|
|
89
|
+
log('Active window:', snapshot.active_window);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
log('Windows active window capture failed:', err.message);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Extract browser URL from window title (works for Chrome, Edge, Firefox)
|
|
95
|
+
// Chrome/Edge format: "<page title> - <browser name>"
|
|
96
|
+
// Firefox format: "<page title> — Mozilla Firefox"
|
|
97
|
+
if (snapshot.active_window) {
|
|
98
|
+
const url = extractUrlFromBrowserTitle(snapshot.active_window.title, snapshot.active_window.process);
|
|
99
|
+
if (url) {
|
|
100
|
+
snapshot.active_url = url;
|
|
101
|
+
snapshot.browser_tab_title = extractTabTitle(snapshot.active_window.title, snapshot.active_window.process);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Clipboard text (last copied)
|
|
106
|
+
try {
|
|
107
|
+
const clip = execSync(
|
|
108
|
+
'powershell -NoProfile -NonInteractive -Command "Get-Clipboard -ErrorAction SilentlyContinue"',
|
|
109
|
+
{ encoding: 'utf8', timeout: 2000, windowsHide: true }
|
|
110
|
+
).trim();
|
|
111
|
+
if (clip && clip.length > 0 && clip.length < 5000) {
|
|
112
|
+
snapshot.clipboard_text = clip.slice(0, 500);
|
|
113
|
+
log('Clipboard:', snapshot.clipboard_text.slice(0, 80));
|
|
114
|
+
}
|
|
115
|
+
} catch (err) {
|
|
116
|
+
log('Clipboard capture failed:', err.message);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
121
|
+
// macOS capture via AppleScript
|
|
122
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
123
|
+
async function captureMac(snapshot, log) {
|
|
124
|
+
// Active app + window title
|
|
125
|
+
try {
|
|
126
|
+
const script = `
|
|
127
|
+
tell application "System Events"
|
|
128
|
+
set frontApp to first application process whose frontmost is true
|
|
129
|
+
set appName to name of frontApp
|
|
130
|
+
set windowTitle to ""
|
|
131
|
+
try
|
|
132
|
+
set windowTitle to name of front window of frontApp
|
|
133
|
+
end try
|
|
134
|
+
return appName & "|" & windowTitle
|
|
135
|
+
end tell`;
|
|
136
|
+
const output = execSync(`osascript -e '${script.replace(/'/g, "\\'")}'`,
|
|
137
|
+
{ encoding: 'utf8', timeout: 4000 }).trim();
|
|
138
|
+
const [appName, windowTitle] = output.split('|');
|
|
139
|
+
snapshot.active_window = { title: windowTitle || '', process: appName || '' };
|
|
140
|
+
snapshot.active_app = appName;
|
|
141
|
+
log('Active app:', appName, 'Window:', windowTitle);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
log('macOS active window capture failed:', err.message);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Browser URL via AppleScript (Chrome, Safari, Firefox)
|
|
147
|
+
if (snapshot.active_window) {
|
|
148
|
+
const proc = (snapshot.active_window.process || '').toLowerCase();
|
|
149
|
+
try {
|
|
150
|
+
let urlScript = '';
|
|
151
|
+
if (proc.includes('chrome') || proc.includes('chromium')) {
|
|
152
|
+
urlScript = 'tell application "Google Chrome" to return URL of active tab of front window';
|
|
153
|
+
} else if (proc.includes('safari')) {
|
|
154
|
+
urlScript = 'tell application "Safari" to return URL of current tab of front window';
|
|
155
|
+
} else if (proc.includes('firefox')) {
|
|
156
|
+
urlScript = 'tell application "Firefox" to return URL of active tab of front window';
|
|
157
|
+
} else if (proc.includes('edge')) {
|
|
158
|
+
urlScript = 'tell application "Microsoft Edge" to return URL of active tab of front window';
|
|
159
|
+
}
|
|
160
|
+
if (urlScript) {
|
|
161
|
+
const url = execSync(`osascript -e '${urlScript}'`,
|
|
162
|
+
{ encoding: 'utf8', timeout: 3000 }).trim();
|
|
163
|
+
snapshot.active_url = url;
|
|
164
|
+
snapshot.browser_tab_title = snapshot.active_window.title;
|
|
165
|
+
log('Browser URL:', url);
|
|
166
|
+
}
|
|
167
|
+
} catch (err) {
|
|
168
|
+
log('macOS browser URL capture failed:', err.message);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Clipboard
|
|
173
|
+
try {
|
|
174
|
+
const clip = execSync('pbpaste', { encoding: 'utf8', timeout: 2000 }).trim();
|
|
175
|
+
if (clip && clip.length > 0 && clip.length < 5000) {
|
|
176
|
+
snapshot.clipboard_text = clip.slice(0, 500);
|
|
177
|
+
}
|
|
178
|
+
} catch (err) {
|
|
179
|
+
log('macOS clipboard capture failed:', err.message);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
184
|
+
// Linux capture via xdotool / xclip (best-effort)
|
|
185
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
186
|
+
async function captureLinux(snapshot, log) {
|
|
187
|
+
try {
|
|
188
|
+
const title = execSync('xdotool getactivewindow getwindowname 2>/dev/null',
|
|
189
|
+
{ encoding: 'utf8', timeout: 2000 }).trim();
|
|
190
|
+
snapshot.active_window = { title, process: 'unknown' };
|
|
191
|
+
snapshot.active_app = title;
|
|
192
|
+
} catch (err) {
|
|
193
|
+
log('Linux active window capture failed:', err.message);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const clip = execSync('xclip -selection clipboard -o 2>/dev/null || xsel --clipboard --output 2>/dev/null',
|
|
198
|
+
{ encoding: 'utf8', timeout: 2000 }).trim();
|
|
199
|
+
if (clip && clip.length > 0 && clip.length < 5000) {
|
|
200
|
+
snapshot.clipboard_text = clip.slice(0, 500);
|
|
201
|
+
}
|
|
202
|
+
} catch (err) {
|
|
203
|
+
log('Linux clipboard capture failed:', err.message);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
208
|
+
// Helpers
|
|
209
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Try to extract a URL from a browser window title.
|
|
213
|
+
* Chrome/Edge show: "<page title> - Google Chrome"
|
|
214
|
+
* Firefox shows: "<page title> — Mozilla Firefox"
|
|
215
|
+
* We can't get the actual URL from the title alone, but we can detect
|
|
216
|
+
* that a browser is active and parse the tab title.
|
|
217
|
+
*
|
|
218
|
+
* For richer URL capture on Windows, we use the UIAutomation address bar
|
|
219
|
+
* approach via PowerShell — but that's expensive, so we only do it when
|
|
220
|
+
* the active process is a known browser.
|
|
221
|
+
*/
|
|
222
|
+
function extractUrlFromBrowserTitle(title, process) {
|
|
223
|
+
const proc = (process || '').toLowerCase();
|
|
224
|
+
const isBrowser = proc.includes('chrome') || proc.includes('msedge') ||
|
|
225
|
+
proc.includes('firefox') || proc.includes('opera') || proc.includes('brave');
|
|
226
|
+
if (!isBrowser) return null;
|
|
227
|
+
|
|
228
|
+
// Try to get URL from Chrome/Edge via PowerShell UIAutomation (Windows only)
|
|
229
|
+
// This reads the address bar text directly — works even for SPAs
|
|
230
|
+
if (proc.includes('chrome') || proc.includes('msedge')) {
|
|
231
|
+
try {
|
|
232
|
+
const psScript = `
|
|
233
|
+
Add-Type -AssemblyName UIAutomationClient
|
|
234
|
+
Add-Type -AssemblyName UIAutomationTypes
|
|
235
|
+
$proc = Get-Process -Name "${proc.includes('msedge') ? 'msedge' : 'chrome'}" -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
236
|
+
if ($proc) {
|
|
237
|
+
$root = [System.Windows.Automation.AutomationElement]::FromHandle($proc.MainWindowHandle)
|
|
238
|
+
$cond = New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::NameProperty, "Address and search bar")
|
|
239
|
+
$bar = $root.FindFirst([System.Windows.Automation.TreeScope]::Descendants, $cond)
|
|
240
|
+
if ($bar) {
|
|
241
|
+
$vp = $bar.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern)
|
|
242
|
+
Write-Output $vp.Current.Value
|
|
243
|
+
}
|
|
244
|
+
}`.trim();
|
|
245
|
+
const url = execSync(
|
|
246
|
+
`powershell -NoProfile -NonInteractive -Command "${psScript.replace(/"/g, '\\"').replace(/\n/g, ' ')}"`,
|
|
247
|
+
{ encoding: 'utf8', timeout: 5000, windowsHide: true }
|
|
248
|
+
).trim();
|
|
249
|
+
if (url && (url.startsWith('http') || url.startsWith('chrome'))) {
|
|
250
|
+
return url;
|
|
251
|
+
}
|
|
252
|
+
} catch (err) {
|
|
253
|
+
// UIAutomation not available — fall back to title parsing
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return null; // URL not extractable from title alone
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function extractTabTitle(windowTitle, process) {
|
|
261
|
+
const proc = (process || '').toLowerCase();
|
|
262
|
+
// Strip browser suffix from title
|
|
263
|
+
return windowTitle
|
|
264
|
+
.replace(/ - Google Chrome$/, '')
|
|
265
|
+
.replace(/ - Microsoft Edge$/, '')
|
|
266
|
+
.replace(/ — Mozilla Firefox$/, '')
|
|
267
|
+
.replace(/ - Brave$/, '')
|
|
268
|
+
.replace(/ - Opera$/, '')
|
|
269
|
+
.trim();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function friendlyAppName(processName, windowTitle) {
|
|
273
|
+
const proc = (processName || '').toLowerCase();
|
|
274
|
+
if (proc.includes('chrome')) return 'Google Chrome';
|
|
275
|
+
if (proc.includes('msedge')) return 'Microsoft Edge';
|
|
276
|
+
if (proc.includes('firefox')) return 'Firefox';
|
|
277
|
+
if (proc.includes('brave')) return 'Brave Browser';
|
|
278
|
+
if (proc.includes('code')) return 'VS Code';
|
|
279
|
+
if (proc.includes('excel')) return 'Microsoft Excel';
|
|
280
|
+
if (proc.includes('word')) return 'Microsoft Word';
|
|
281
|
+
if (proc.includes('outlook')) return 'Microsoft Outlook';
|
|
282
|
+
if (proc.includes('slack')) return 'Slack';
|
|
283
|
+
if (proc.includes('teams')) return 'Microsoft Teams';
|
|
284
|
+
if (proc.includes('zoom')) return 'Zoom';
|
|
285
|
+
if (proc.includes('notepad')) return 'Notepad';
|
|
286
|
+
if (proc.includes('explorer')) return 'File Explorer';
|
|
287
|
+
if (proc.includes('cmd') || proc.includes('powershell') || proc.includes('windowsterminal')) return 'Terminal';
|
|
288
|
+
return processName || 'Unknown';
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
292
|
+
// Format context snapshot as a human-readable string for the agent system prompt
|
|
293
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
294
|
+
function formatContextForPrompt(snapshot) {
|
|
295
|
+
if (!snapshot) return '';
|
|
296
|
+
|
|
297
|
+
const lines = [];
|
|
298
|
+
lines.push('── USER CONTEXT (live, captured just now) ──────────────────');
|
|
299
|
+
|
|
300
|
+
if (snapshot.active_app) {
|
|
301
|
+
lines.push(`Active app: ${snapshot.active_app}`);
|
|
302
|
+
}
|
|
303
|
+
if (snapshot.active_window && snapshot.active_window.title) {
|
|
304
|
+
lines.push(`Window title: ${snapshot.active_window.title}`);
|
|
305
|
+
}
|
|
306
|
+
if (snapshot.active_url) {
|
|
307
|
+
lines.push(`Browser URL: ${snapshot.active_url}`);
|
|
308
|
+
}
|
|
309
|
+
if (snapshot.browser_tab_title && snapshot.browser_tab_title !== snapshot.active_window?.title) {
|
|
310
|
+
lines.push(`Tab: ${snapshot.browser_tab_title}`);
|
|
311
|
+
}
|
|
312
|
+
if (snapshot.clipboard_text) {
|
|
313
|
+
lines.push(`Clipboard: "${snapshot.clipboard_text.slice(0, 200)}${snapshot.clipboard_text.length > 200 ? '...' : ''}"`);
|
|
314
|
+
}
|
|
315
|
+
if (snapshot.captured_at) {
|
|
316
|
+
lines.push(`Captured: ${snapshot.captured_at}`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
lines.push('────────────────────────────────────────────────────────────');
|
|
320
|
+
|
|
321
|
+
return lines.join('\n');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
module.exports = { captureContext, formatContextForPrompt };
|
package/src/executor.js
CHANGED
|
@@ -15,11 +15,46 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
const fs = require('fs').promises;
|
|
18
|
+
const fsSync = require('fs');
|
|
18
19
|
const path = require('path');
|
|
20
|
+
const os = require('os');
|
|
19
21
|
const { execSync, exec } = require('child_process');
|
|
20
22
|
const { promisify } = require('util');
|
|
21
23
|
const execAsync = promisify(exec);
|
|
22
24
|
|
|
25
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
// Custom Tool Registry
|
|
27
|
+
// Tools the agent invents at runtime are stored here and persisted to disk.
|
|
28
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
const CUSTOM_TOOLS_DIR = path.join(os.homedir(), '.promethios');
|
|
31
|
+
const CUSTOM_TOOLS_FILE = path.join(CUSTOM_TOOLS_DIR, 'custom_tools.json');
|
|
32
|
+
|
|
33
|
+
// In-memory registry: { [toolName]: { name, description, parameters, type, implementation, createdAt } }
|
|
34
|
+
if (!global.__customToolRegistry) {
|
|
35
|
+
global.__customToolRegistry = {};
|
|
36
|
+
// Load persisted tools on first require
|
|
37
|
+
try {
|
|
38
|
+
if (fsSync.existsSync(CUSTOM_TOOLS_FILE)) {
|
|
39
|
+
const saved = JSON.parse(fsSync.readFileSync(CUSTOM_TOOLS_FILE, 'utf8'));
|
|
40
|
+
Object.assign(global.__customToolRegistry, saved);
|
|
41
|
+
const count = Object.keys(saved).length;
|
|
42
|
+
if (count > 0) console.log(`[Bridge] Loaded ${count} custom tool(s) from ${CUSTOM_TOOLS_FILE}`);
|
|
43
|
+
}
|
|
44
|
+
} catch (e) {
|
|
45
|
+
console.warn('[Bridge] Could not load custom tools:', e.message);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function saveCustomToolRegistry() {
|
|
50
|
+
try {
|
|
51
|
+
fsSync.mkdirSync(CUSTOM_TOOLS_DIR, { recursive: true });
|
|
52
|
+
fsSync.writeFileSync(CUSTOM_TOOLS_FILE, JSON.stringify(global.__customToolRegistry, null, 2), 'utf8');
|
|
53
|
+
} catch (e) {
|
|
54
|
+
console.warn('[Bridge] Could not save custom tools:', e.message);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
23
58
|
async function executeLocalTool({ toolName, args, frameworkId, dev }) {
|
|
24
59
|
const log = dev ? (...a) => console.log('[executor]', ...a) : () => {};
|
|
25
60
|
|
|
@@ -45,6 +80,79 @@ async function executeLocalTool({ toolName, args, frameworkId, dev }) {
|
|
|
45
80
|
if (toolName === 'local_browser_control') {
|
|
46
81
|
return executeLocalTool({ toolName: 'browser_control', args, frameworkId, dev });
|
|
47
82
|
}
|
|
83
|
+
if (toolName === 'local_create_folder') {
|
|
84
|
+
return executeLocalTool({ toolName: 'create_folder', args, frameworkId, dev });
|
|
85
|
+
}
|
|
86
|
+
if (toolName === 'local_move') {
|
|
87
|
+
return executeLocalTool({ toolName: 'move_path', args, frameworkId, dev });
|
|
88
|
+
}
|
|
89
|
+
if (toolName === 'local_copy') {
|
|
90
|
+
return executeLocalTool({ toolName: 'copy_path', args, frameworkId, dev });
|
|
91
|
+
}
|
|
92
|
+
if (toolName === 'local_delete') {
|
|
93
|
+
return executeLocalTool({ toolName: 'delete_path', args, frameworkId, dev });
|
|
94
|
+
}
|
|
95
|
+
if (toolName === 'local_define_tool') {
|
|
96
|
+
return executeLocalTool({ toolName: 'define_tool', args, frameworkId, dev });
|
|
97
|
+
}
|
|
98
|
+
if (toolName === 'local_list_tools') {
|
|
99
|
+
return executeLocalTool({ toolName: 'list_tools', args, frameworkId, dev });
|
|
100
|
+
}
|
|
101
|
+
if (toolName === 'local_remove_tool') {
|
|
102
|
+
return executeLocalTool({ toolName: 'remove_tool', args, frameworkId, dev });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Dynamic dispatch: check custom tool registry before the built-in switch ──
|
|
106
|
+
if (global.__customToolRegistry[toolName]) {
|
|
107
|
+
const toolDef = global.__customToolRegistry[toolName];
|
|
108
|
+
log(`Dispatching custom tool: ${toolName} (type=${toolDef.type})`);
|
|
109
|
+
|
|
110
|
+
if (toolDef.type === 'shell') {
|
|
111
|
+
// Level 1: shell template — substitute {param} placeholders with args values
|
|
112
|
+
let cmd = toolDef.implementation;
|
|
113
|
+
for (const [key, val] of Object.entries(args || {})) {
|
|
114
|
+
// Escape the value for shell safety (basic quoting)
|
|
115
|
+
const safe = String(val).replace(/"/g, '\\"');
|
|
116
|
+
cmd = cmd.replace(new RegExp(`\\{${key}\\}`, 'g'), safe);
|
|
117
|
+
}
|
|
118
|
+
log(`Custom shell tool cmd: ${cmd}`);
|
|
119
|
+
const timeoutMs = 30_000;
|
|
120
|
+
const { stdout, stderr } = await execAsync(cmd, {
|
|
121
|
+
timeout: timeoutMs,
|
|
122
|
+
cwd: args.cwd ? resolveSafePath(args.cwd) : os.homedir(),
|
|
123
|
+
maxBuffer: 1024 * 1024,
|
|
124
|
+
});
|
|
125
|
+
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0, toolName };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (toolDef.type === 'js') {
|
|
129
|
+
// Level 2: JS function body — run in vm sandbox
|
|
130
|
+
const vm = require('vm');
|
|
131
|
+
const context = vm.createContext({
|
|
132
|
+
args: { ...args },
|
|
133
|
+
fetch,
|
|
134
|
+
require,
|
|
135
|
+
process: { env: process.env, platform: process.platform },
|
|
136
|
+
console,
|
|
137
|
+
Buffer,
|
|
138
|
+
URL,
|
|
139
|
+
URLSearchParams,
|
|
140
|
+
crypto: require('crypto'),
|
|
141
|
+
fs: require('fs').promises,
|
|
142
|
+
path: require('path'),
|
|
143
|
+
os: require('os'),
|
|
144
|
+
execAsync,
|
|
145
|
+
resolveSafePath,
|
|
146
|
+
result: undefined,
|
|
147
|
+
});
|
|
148
|
+
const script = new vm.Script(`(async () => { ${toolDef.implementation} })().then(r => { result = r; });`);
|
|
149
|
+
await script.runInContext(context);
|
|
150
|
+
await new Promise(r => setTimeout(r, 200));
|
|
151
|
+
return context.result !== undefined ? context.result : { success: true, toolName };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
throw new Error(`Custom tool "${toolName}" has unknown type: ${toolDef.type}. Expected 'shell' or 'js'.`);
|
|
155
|
+
}
|
|
48
156
|
|
|
49
157
|
// ── local_execute is the built-in tool injected by the backend when the bridge
|
|
50
158
|
// is connected. It uses an `action` field to dispatch to the right handler.
|
|
@@ -126,12 +234,17 @@ async function executeLocalTool({ toolName, args, frameworkId, dev }) {
|
|
|
126
234
|
try {
|
|
127
235
|
playwright = require('playwright');
|
|
128
236
|
} catch (e) {
|
|
129
|
-
// Playwright not installed — install it
|
|
237
|
+
// Playwright not installed — install it locally into the bridge's own node_modules
|
|
238
|
+
// so require() can find it without relying on global npm or npx resolution.
|
|
130
239
|
const chalk = require('chalk');
|
|
240
|
+
const bridgeDir = path.join(__dirname, '..');
|
|
131
241
|
console.log(chalk.yellow('\n Playwright not found — installing automatically (one-time setup, ~2 min)...\n'));
|
|
132
242
|
try {
|
|
133
|
-
execSync('npm install -
|
|
134
|
-
|
|
243
|
+
execSync('npm install playwright --save-optional', { stdio: 'inherit', cwd: bridgeDir });
|
|
244
|
+
// Use the locally installed playwright binary directly to avoid npx resolution issues
|
|
245
|
+
const isWin = process.platform === 'win32';
|
|
246
|
+
const playwrightBin = path.join(bridgeDir, 'node_modules', '.bin', isWin ? 'playwright.cmd' : 'playwright');
|
|
247
|
+
execSync(`"${playwrightBin}" install chromium`, { stdio: 'inherit', cwd: bridgeDir });
|
|
135
248
|
playwright = require('playwright');
|
|
136
249
|
console.log(chalk.green('\n Playwright installed. Browser automation is ready.\n'));
|
|
137
250
|
} catch (installErr) {
|
|
@@ -410,6 +523,68 @@ async function executeLocalTool({ toolName, args, frameworkId, dev }) {
|
|
|
410
523
|
};
|
|
411
524
|
}
|
|
412
525
|
|
|
526
|
+
// ── File management ────────────────────────────────────────────────────────────
|
|
527
|
+
case 'create_folder': {
|
|
528
|
+
const folderPath = resolveSafePath(args.path);
|
|
529
|
+
log('create_folder', folderPath);
|
|
530
|
+
await fs.mkdir(folderPath, { recursive: true });
|
|
531
|
+
return { success: true, path: folderPath, created: true };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
case 'move_path': {
|
|
535
|
+
const srcPath = resolveSafePath(args.src);
|
|
536
|
+
const dstPath = resolveSafePath(args.dst);
|
|
537
|
+
log('move_path', srcPath, '->', dstPath);
|
|
538
|
+
// Ensure destination parent directory exists
|
|
539
|
+
await fs.mkdir(path.dirname(dstPath), { recursive: true });
|
|
540
|
+
await fs.rename(srcPath, dstPath);
|
|
541
|
+
return { success: true, src: srcPath, dst: dstPath };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
case 'copy_path': {
|
|
545
|
+
const srcPath = resolveSafePath(args.src);
|
|
546
|
+
const dstPath = resolveSafePath(args.dst);
|
|
547
|
+
log('copy_path', srcPath, '->', dstPath);
|
|
548
|
+
// Ensure destination parent directory exists
|
|
549
|
+
await fs.mkdir(path.dirname(dstPath), { recursive: true });
|
|
550
|
+
// Check if source is a directory
|
|
551
|
+
const srcStat = await fs.stat(srcPath);
|
|
552
|
+
if (srcStat.isDirectory()) {
|
|
553
|
+
// Recursive directory copy
|
|
554
|
+
async function copyDir(src, dst) {
|
|
555
|
+
await fs.mkdir(dst, { recursive: true });
|
|
556
|
+
const entries = await fs.readdir(src, { withFileTypes: true });
|
|
557
|
+
for (const entry of entries) {
|
|
558
|
+
const srcEntry = path.join(src, entry.name);
|
|
559
|
+
const dstEntry = path.join(dst, entry.name);
|
|
560
|
+
if (entry.isDirectory()) {
|
|
561
|
+
await copyDir(srcEntry, dstEntry);
|
|
562
|
+
} else {
|
|
563
|
+
await fs.copyFile(srcEntry, dstEntry);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
await copyDir(srcPath, dstPath);
|
|
568
|
+
return { success: true, src: srcPath, dst: dstPath, type: 'directory' };
|
|
569
|
+
} else {
|
|
570
|
+
await fs.copyFile(srcPath, dstPath);
|
|
571
|
+
return { success: true, src: srcPath, dst: dstPath, type: 'file' };
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
case 'delete_path': {
|
|
576
|
+
const targetPath = resolveSafePath(args.path);
|
|
577
|
+
log('delete_path', targetPath);
|
|
578
|
+
const targetStat = await fs.stat(targetPath);
|
|
579
|
+
if (targetStat.isDirectory()) {
|
|
580
|
+
await fs.rm(targetPath, { recursive: true, force: true });
|
|
581
|
+
return { success: true, path: targetPath, type: 'directory' };
|
|
582
|
+
} else {
|
|
583
|
+
await fs.unlink(targetPath);
|
|
584
|
+
return { success: true, path: targetPath, type: 'file' };
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
413
588
|
// ── Browser ────────────────────────────────────────────────────────────────────
|
|
414
589
|
case 'open_browser': {
|
|
415
590
|
const url = args.url;
|
|
@@ -553,6 +728,75 @@ async function executeLocalTool({ toolName, args, frameworkId, dev }) {
|
|
|
553
728
|
}
|
|
554
729
|
}
|
|
555
730
|
|
|
731
|
+
// ── Dynamic tool synthesis ───────────────────────────────────────────────────────────────
|
|
732
|
+
case 'define_tool': {
|
|
733
|
+
const { name: tName, description: tDesc, parameters: tParams, type: tType, implementation: tImpl } = args;
|
|
734
|
+
if (!tName) throw new Error('name is required');
|
|
735
|
+
if (!tType || !['shell', 'js'].includes(tType)) throw new Error('type must be "shell" or "js"');
|
|
736
|
+
if (!tImpl) throw new Error('implementation is required');
|
|
737
|
+
// Validate tool name: lowercase alphanumeric + underscores only
|
|
738
|
+
if (!/^[a-z][a-z0-9_]*$/.test(tName)) {
|
|
739
|
+
throw new Error(`Tool name "${tName}" is invalid. Use lowercase letters, numbers, and underscores only (must start with a letter).`);
|
|
740
|
+
}
|
|
741
|
+
// Prevent overwriting built-in tools
|
|
742
|
+
const RESERVED = ['local_shell','local_file_read','local_file_write','local_file_read_binary',
|
|
743
|
+
'local_file_upload_to_thread','local_browser_control','local_create_folder','local_move',
|
|
744
|
+
'local_copy','local_delete','local_define_tool','local_list_tools','local_remove_tool'];
|
|
745
|
+
if (RESERVED.includes(tName)) throw new Error(`Cannot redefine built-in tool: ${tName}`);
|
|
746
|
+
|
|
747
|
+
const toolEntry = {
|
|
748
|
+
name: tName,
|
|
749
|
+
description: tDesc || '',
|
|
750
|
+
parameters: tParams || { type: 'object', properties: {}, required: [] },
|
|
751
|
+
type: tType,
|
|
752
|
+
implementation: tImpl,
|
|
753
|
+
createdAt: new Date().toISOString(),
|
|
754
|
+
};
|
|
755
|
+
global.__customToolRegistry[tName] = toolEntry;
|
|
756
|
+
saveCustomToolRegistry();
|
|
757
|
+
log(`Defined custom tool: ${tName} (type=${tType})`);
|
|
758
|
+
return {
|
|
759
|
+
success: true,
|
|
760
|
+
toolName: tName,
|
|
761
|
+
type: tType,
|
|
762
|
+
message: `Tool "${tName}" registered successfully. You can now call it directly by name.`,
|
|
763
|
+
totalCustomTools: Object.keys(global.__customToolRegistry).length,
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
case 'list_tools': {
|
|
768
|
+
const tools = Object.values(global.__customToolRegistry);
|
|
769
|
+
if (tools.length === 0) {
|
|
770
|
+
return { tools: [], message: 'No custom tools defined yet. Use local_define_tool to create one.' };
|
|
771
|
+
}
|
|
772
|
+
return {
|
|
773
|
+
tools: tools.map(t => ({
|
|
774
|
+
name: t.name,
|
|
775
|
+
description: t.description,
|
|
776
|
+
type: t.type,
|
|
777
|
+
parameters: t.parameters,
|
|
778
|
+
createdAt: t.createdAt,
|
|
779
|
+
})),
|
|
780
|
+
count: tools.length,
|
|
781
|
+
message: `${tools.length} custom tool(s) available: ${tools.map(t => t.name).join(', ')}`,
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
case 'remove_tool': {
|
|
786
|
+
const { name: rName } = args;
|
|
787
|
+
if (!rName) throw new Error('name is required');
|
|
788
|
+
if (!global.__customToolRegistry[rName]) {
|
|
789
|
+
return { success: false, message: `Tool "${rName}" not found in custom registry.` };
|
|
790
|
+
}
|
|
791
|
+
delete global.__customToolRegistry[rName];
|
|
792
|
+
saveCustomToolRegistry();
|
|
793
|
+
return {
|
|
794
|
+
success: true,
|
|
795
|
+
message: `Tool "${rName}" removed from registry.`,
|
|
796
|
+
totalCustomTools: Object.keys(global.__customToolRegistry).length,
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
|
|
556
800
|
// ── Custom tool (developer-written code from framework definition) ────
|
|
557
801
|
case 'custom':
|
|
558
802
|
default: {
|
|
@@ -608,33 +852,56 @@ async function executeLocalTool({ toolName, args, frameworkId, dev }) {
|
|
|
608
852
|
*
|
|
609
853
|
* On macOS/Linux the standard ~/Desktop is used.
|
|
610
854
|
*/
|
|
855
|
+
// Cache the resolved Desktop path so registry queries don't repeat every call
|
|
856
|
+
let _resolvedDesktopPath = null;
|
|
857
|
+
|
|
611
858
|
function resolveDesktopPath() {
|
|
859
|
+
if (_resolvedDesktopPath) return _resolvedDesktopPath;
|
|
860
|
+
|
|
612
861
|
if (process.platform !== 'win32') {
|
|
613
|
-
|
|
862
|
+
_resolvedDesktopPath = path.join(require('os').homedir(), 'Desktop');
|
|
863
|
+
console.log(`[Executor] Desktop path resolved (non-Windows): ${_resolvedDesktopPath}`);
|
|
864
|
+
return _resolvedDesktopPath;
|
|
614
865
|
}
|
|
615
|
-
|
|
866
|
+
|
|
867
|
+
// Try registry first (most reliable — works even with custom Desktop locations,
|
|
868
|
+
// including OneDrive Desktop Backup which silently redirects the Desktop folder)
|
|
616
869
|
try {
|
|
617
870
|
const { execSync } = require('child_process');
|
|
618
871
|
const regOut = execSync(
|
|
619
872
|
'reg query "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders" /v Desktop',
|
|
620
|
-
{ encoding: 'utf8', timeout:
|
|
873
|
+
{ encoding: 'utf8', timeout: 5000 }
|
|
621
874
|
);
|
|
622
875
|
const match = regOut.match(/Desktop\s+REG_(?:SZ|EXPAND_SZ)\s+(.+)/i);
|
|
623
876
|
if (match) {
|
|
624
877
|
// Expand environment variables like %USERPROFILE%
|
|
625
878
|
let desktopPath = match[1].trim();
|
|
626
879
|
desktopPath = desktopPath.replace(/%([^%]+)%/g, (_, varName) => process.env[varName] || `%${varName}%`);
|
|
627
|
-
if (require('fs').existsSync(desktopPath))
|
|
880
|
+
if (require('fs').existsSync(desktopPath)) {
|
|
881
|
+
_resolvedDesktopPath = desktopPath;
|
|
882
|
+
console.log(`[Executor] Desktop path resolved via registry: ${_resolvedDesktopPath}`);
|
|
883
|
+
return _resolvedDesktopPath;
|
|
884
|
+
} else {
|
|
885
|
+
console.warn(`[Executor] Registry Desktop path does not exist on disk: ${desktopPath} — falling through`);
|
|
886
|
+
}
|
|
628
887
|
}
|
|
629
|
-
} catch {
|
|
888
|
+
} catch (regErr) {
|
|
889
|
+
console.warn(`[Executor] Registry Desktop query failed (${regErr.message}) — falling through to OneDrive check`);
|
|
890
|
+
}
|
|
630
891
|
|
|
631
892
|
// Fallback: check OneDrive Desktop first (most common on Windows 11)
|
|
632
893
|
const userProfile = process.env.USERPROFILE || require('os').homedir();
|
|
633
894
|
const oneDriveDesktop = path.join(userProfile, 'OneDrive', 'Desktop');
|
|
634
|
-
if (require('fs').existsSync(oneDriveDesktop))
|
|
895
|
+
if (require('fs').existsSync(oneDriveDesktop)) {
|
|
896
|
+
_resolvedDesktopPath = oneDriveDesktop;
|
|
897
|
+
console.log(`[Executor] Desktop path resolved via OneDrive fallback: ${_resolvedDesktopPath}`);
|
|
898
|
+
return _resolvedDesktopPath;
|
|
899
|
+
}
|
|
635
900
|
|
|
636
901
|
// Final fallback: local Desktop
|
|
637
|
-
|
|
902
|
+
_resolvedDesktopPath = path.join(userProfile, 'Desktop');
|
|
903
|
+
console.log(`[Executor] Desktop path resolved via local fallback: ${_resolvedDesktopPath}`);
|
|
904
|
+
return _resolvedDesktopPath;
|
|
638
905
|
}
|
|
639
906
|
|
|
640
907
|
function resolveSafePath(inputPath) {
|