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 CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "promethios-bridge",
3
- "version": "1.5.1",
4
- "description": "Run Promethios agent frameworks locally on your computer with full file, terminal, browser access, and Native Framework Mode (run OpenClaw and other frameworks in their native interface via the bridge).",
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, // 'win32' | 'darwin' | 'linux'
336
- shell, // 'cmd' | 'zsh' | 'bash' etc.
337
- homeDir, // e.g. 'C:\\Users\\ted' or '/Users/ted'
338
- username, // e.g. 'ted'
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 automatically
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 -g playwright', { stdio: 'inherit' });
134
- execSync('npx playwright install chromium', { stdio: 'inherit' });
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
- return path.join(require('os').homedir(), 'Desktop');
862
+ _resolvedDesktopPath = path.join(require('os').homedir(), 'Desktop');
863
+ console.log(`[Executor] Desktop path resolved (non-Windows): ${_resolvedDesktopPath}`);
864
+ return _resolvedDesktopPath;
614
865
  }
615
- // Try registry first (most reliable — works even with custom Desktop locations)
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: 3000 }
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)) return 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 { /* registry query failed, fall through */ }
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)) return 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
- return path.join(userProfile, 'Desktop');
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) {