@pulso/companion 0.2.2 → 0.2.3

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.
Files changed (2) hide show
  1. package/dist/index.js +1254 -158
  2. package/package.json +8 -2
package/dist/index.js CHANGED
@@ -3,17 +3,29 @@
3
3
  // src/index.ts
4
4
  import WebSocket from "ws";
5
5
  import { exec, execSync } from "child_process";
6
- import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, copyFileSync, renameSync } from "fs";
6
+ import {
7
+ readFileSync,
8
+ writeFileSync,
9
+ existsSync,
10
+ readdirSync,
11
+ statSync,
12
+ copyFileSync,
13
+ renameSync
14
+ } from "fs";
7
15
  import { homedir } from "os";
8
16
  import { join, resolve, basename, extname } from "path";
9
- var API_URL = process.env.PULSO_API_URL ?? process.argv.find((_, i, a) => a[i - 1] === "--api") ?? "https://pulso-api.vulk.workers.dev";
17
+ var API_URL = process.env.PULSO_API_URL ?? process.argv.find((_, i, a) => a[i - 1] === "--api") ?? "https://api.runpulso.com";
10
18
  var TOKEN = process.env.PULSO_TOKEN ?? process.argv.find((_, i, a) => a[i - 1] === "--token") ?? "";
11
19
  if (!TOKEN) {
12
20
  console.error("\u274C Missing token. Set PULSO_TOKEN or use --token <jwt>");
13
- console.error(" Get your token: log in to Pulso \u2192 Settings \u2192 Companion Token");
21
+ console.error(
22
+ " Get your token: log in to Pulso \u2192 Settings \u2192 Companion Token"
23
+ );
14
24
  process.exit(1);
15
25
  }
16
26
  var ACCESS_LEVEL = process.env.PULSO_ACCESS ?? process.argv.find((_, i, a) => a[i - 1] === "--access") ?? "sandboxed";
27
+ var WAKE_WORD_ENABLED = process.argv.includes("--wake-word");
28
+ var PICOVOICE_ACCESS_KEY = process.env.PICOVOICE_ACCESS_KEY ?? process.argv.find((_, i, a) => a[i - 1] === "--picovoice-key") ?? "";
17
29
  var WS_URL = API_URL.replace("https://", "wss://").replace("http://", "ws://") + "/ws/browser?token=" + TOKEN;
18
30
  var HOME = homedir();
19
31
  var RECONNECT_DELAY = 5e3;
@@ -31,10 +43,14 @@ function safePath(relative) {
31
43
  }
32
44
  function runAppleScript(script) {
33
45
  return new Promise((resolve2, reject) => {
34
- exec(`osascript -e '${script.replace(/'/g, "'\\''")}'`, { timeout: 15e3 }, (err, stdout, stderr) => {
35
- if (err) reject(new Error(stderr || err.message));
36
- else resolve2(stdout.trim());
37
- });
46
+ exec(
47
+ `osascript -e '${script.replace(/'/g, "'\\''")}'`,
48
+ { timeout: 15e3 },
49
+ (err, stdout, stderr) => {
50
+ if (err) reject(new Error(stderr || err.message));
51
+ else resolve2(stdout.trim());
52
+ }
53
+ );
38
54
  });
39
55
  }
40
56
  function runShell(cmd, timeout = 1e4) {
@@ -58,7 +74,9 @@ function runSwift(code, timeout = 1e4) {
58
74
  var SETUP_DONE_FILE = join(HOME, ".pulso-companion-setup");
59
75
  async function setupPermissions() {
60
76
  const isFirstRun = !existsSync(SETUP_DONE_FILE);
61
- console.log(isFirstRun ? "\u{1F510} First run \u2014 setting up permissions...\n" : "\u{1F510} Checking permissions...\n");
77
+ console.log(
78
+ isFirstRun ? "\u{1F510} First run \u2014 setting up permissions...\n" : "\u{1F510} Checking permissions...\n"
79
+ );
62
80
  const status = {};
63
81
  const browserDefaults = [
64
82
  ["Google Chrome", "com.google.Chrome", "AllowJavaScriptAppleEvents"],
@@ -68,15 +86,24 @@ async function setupPermissions() {
68
86
  ];
69
87
  for (const [name, domain, key] of browserDefaults) {
70
88
  try {
71
- execSync(`defaults write ${domain} ${key} -bool true 2>/dev/null`, { timeout: 3e3 });
72
- if (name === "Google Chrome" || name === "Safari") status[`${name} JS`] = "\u2705";
89
+ execSync(`defaults write ${domain} ${key} -bool true 2>/dev/null`, {
90
+ timeout: 3e3
91
+ });
92
+ if (name === "Google Chrome" || name === "Safari")
93
+ status[`${name} JS`] = "\u2705";
73
94
  } catch {
74
- if (name === "Google Chrome" || name === "Safari") status[`${name} JS`] = "\u26A0\uFE0F";
95
+ if (name === "Google Chrome" || name === "Safari")
96
+ status[`${name} JS`] = "\u26A0\uFE0F";
75
97
  }
76
98
  }
77
- for (const [browserName, processName] of [["Google Chrome", "Google Chrome"], ["Safari", "Safari"]]) {
99
+ for (const [browserName, processName] of [
100
+ ["Google Chrome", "Google Chrome"],
101
+ ["Safari", "Safari"]
102
+ ]) {
78
103
  try {
79
- const running = execSync(`pgrep -x "${processName}" 2>/dev/null`, { timeout: 2e3 }).toString().trim();
104
+ const running = execSync(`pgrep -x "${processName}" 2>/dev/null`, {
105
+ timeout: 2e3
106
+ }).toString().trim();
80
107
  if (!running) continue;
81
108
  const enableJsScript = browserName === "Google Chrome" ? `tell application "System Events"
82
109
  tell process "Google Chrome"
@@ -101,24 +128,38 @@ async function setupPermissions() {
101
128
  end if
102
129
  end tell
103
130
  end tell` : `return "skip_safari"`;
104
- const result = execSync(`osascript -e '${enableJsScript.replace(/'/g, "'\\''")}' 2>/dev/null`, { timeout: 1e4 }).toString().trim();
131
+ const result = execSync(
132
+ `osascript -e '${enableJsScript.replace(/'/g, "'\\''")}' 2>/dev/null`,
133
+ { timeout: 1e4 }
134
+ ).toString().trim();
105
135
  if (result === "enabled") {
106
- console.log(` \u2705 ${browserName}: JavaScript from Apple Events enabled via menu`);
136
+ console.log(
137
+ ` \u2705 ${browserName}: JavaScript from Apple Events enabled via menu`
138
+ );
107
139
  }
108
140
  } catch {
109
141
  }
110
142
  }
111
143
  try {
112
- execSync(`osascript -e 'tell application "System Events" to name of first process' 2>/dev/null`, { timeout: 5e3 });
144
+ execSync(
145
+ `osascript -e 'tell application "System Events" to name of first process' 2>/dev/null`,
146
+ { timeout: 5e3 }
147
+ );
113
148
  status["Accessibility"] = "\u2705";
114
149
  } catch (err) {
115
150
  const msg = err.message || "";
116
151
  if (msg.includes("not allowed") || msg.includes("assistive") || msg.includes("-1719")) {
117
152
  status["Accessibility"] = "\u26A0\uFE0F";
118
153
  if (isFirstRun) {
119
- console.log(" \u26A0\uFE0F Accessibility: macOS should show a permission dialog.");
120
- console.log(" If not, go to: System Settings \u2192 Privacy & Security \u2192 Accessibility");
121
- console.log(" Add your terminal app (Terminal, iTerm, Warp, etc.)\n");
154
+ console.log(
155
+ " \u26A0\uFE0F Accessibility: macOS should show a permission dialog."
156
+ );
157
+ console.log(
158
+ " If not, go to: System Settings \u2192 Privacy & Security \u2192 Accessibility"
159
+ );
160
+ console.log(
161
+ " Add your terminal app (Terminal, iTerm, Warp, etc.)\n"
162
+ );
122
163
  }
123
164
  } else {
124
165
  status["Accessibility"] = "\u2705";
@@ -135,8 +176,12 @@ async function setupPermissions() {
135
176
  } else {
136
177
  status["Screen Recording"] = "\u26A0\uFE0F";
137
178
  if (isFirstRun) {
138
- console.log(" \u26A0\uFE0F Screen Recording: macOS should show a permission dialog.");
139
- console.log(" If not, go to: System Settings \u2192 Privacy & Security \u2192 Screen Recording");
179
+ console.log(
180
+ " \u26A0\uFE0F Screen Recording: macOS should show a permission dialog."
181
+ );
182
+ console.log(
183
+ " If not, go to: System Settings \u2192 Privacy & Security \u2192 Screen Recording"
184
+ );
140
185
  console.log(" Add your terminal app\n");
141
186
  }
142
187
  }
@@ -146,8 +191,12 @@ async function setupPermissions() {
146
191
  } catch {
147
192
  status["Screen Recording"] = "\u26A0\uFE0F";
148
193
  if (isFirstRun) {
149
- console.log(" \u26A0\uFE0F Screen Recording: permission needed for screenshots.");
150
- console.log(" Go to: System Settings \u2192 Privacy & Security \u2192 Screen Recording\n");
194
+ console.log(
195
+ " \u26A0\uFE0F Screen Recording: permission needed for screenshots."
196
+ );
197
+ console.log(
198
+ " Go to: System Settings \u2192 Privacy & Security \u2192 Screen Recording\n"
199
+ );
151
200
  }
152
201
  }
153
202
  console.log(" Permission Status:");
@@ -164,9 +213,14 @@ async function setupPermissions() {
164
213
  }
165
214
  const needsManual = status["Accessibility"] === "\u26A0\uFE0F" || status["Screen Recording"] === "\u26A0\uFE0F";
166
215
  if (isFirstRun && needsManual) {
167
- console.log(" Opening System Settings for you to approve permissions...\n");
216
+ console.log(
217
+ " Opening System Settings for you to approve permissions...\n"
218
+ );
168
219
  try {
169
- execSync('open "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"', { timeout: 3e3 });
220
+ execSync(
221
+ 'open "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"',
222
+ { timeout: 3e3 }
223
+ );
170
224
  } catch {
171
225
  }
172
226
  }
@@ -194,6 +248,23 @@ async function spotifySearch(query) {
194
248
  }
195
249
  } catch {
196
250
  }
251
+ const songKeywords = /\b(song|track|canção|cancion|musica|música|remix|feat|ft\.|version|cover|live|acoustic)\b/i;
252
+ const hasMultipleWords = key.split(/\s+/).length >= 3;
253
+ const looksLikeArtist = !songKeywords.test(key) && !hasMultipleWords;
254
+ if (looksLikeArtist) {
255
+ const artistId = await searchForSpotifyArtist(query);
256
+ if (artistId) {
257
+ const result2 = {
258
+ uri: `spotify:artist:${artistId}`,
259
+ name: "Top Songs",
260
+ artist: query
261
+ };
262
+ searchCache.set(key, { ...result2, ts: Date.now() });
263
+ pushToServerCache(query, result2).catch(() => {
264
+ });
265
+ return result2;
266
+ }
267
+ }
197
268
  let trackIds = await searchBraveForSpotifyTracks(query);
198
269
  if (trackIds.length === 0) {
199
270
  trackIds = await searchDDGForSpotifyTracks(query);
@@ -213,6 +284,72 @@ async function spotifySearch(query) {
213
284
  });
214
285
  return result;
215
286
  }
287
+ async function searchForSpotifyArtist(query) {
288
+ const engines = [
289
+ {
290
+ name: "brave",
291
+ fn: () => searchWebForSpotifyIds(
292
+ query,
293
+ "artist",
294
+ `https://search.brave.com/search?q=${encodeURIComponent(`${query} artist site:open.spotify.com`)}&source=web`
295
+ )
296
+ },
297
+ {
298
+ name: "ddg",
299
+ fn: () => searchWebForSpotifyIds(
300
+ query,
301
+ "artist",
302
+ `https://html.duckduckgo.com/html/?q=${encodeURIComponent(`${query} artist site:open.spotify.com`)}`
303
+ )
304
+ }
305
+ ];
306
+ for (const engine of engines) {
307
+ try {
308
+ const ids = await engine.fn();
309
+ if (ids.length > 0) return ids[0];
310
+ } catch {
311
+ }
312
+ }
313
+ return null;
314
+ }
315
+ async function searchWebForSpotifyIds(query, type, url) {
316
+ try {
317
+ const controller = new AbortController();
318
+ const timeout = setTimeout(() => controller.abort(), 8e3);
319
+ const opts = {
320
+ headers: { "User-Agent": UA, Accept: "text/html" },
321
+ signal: controller.signal
322
+ };
323
+ if (url.includes("duckduckgo.com/html")) {
324
+ const searchUrl = new URL(url);
325
+ const q = searchUrl.searchParams.get("q") ?? query;
326
+ Object.assign(opts, {
327
+ method: "POST",
328
+ headers: {
329
+ ...opts.headers,
330
+ "Content-Type": "application/x-www-form-urlencoded"
331
+ },
332
+ body: `q=${encodeURIComponent(q)}`
333
+ });
334
+ url = "https://html.duckduckgo.com/html/";
335
+ }
336
+ const res = await fetch(url, opts);
337
+ clearTimeout(timeout);
338
+ if (!res.ok) return [];
339
+ const html = await res.text();
340
+ const ids = /* @__PURE__ */ new Set();
341
+ const pattern = new RegExp(
342
+ `open\\.spotify\\.com(?:/intl-[a-z]+)?/${type}/([a-zA-Z0-9]{22})`,
343
+ "g"
344
+ );
345
+ for (const m of html.matchAll(pattern)) {
346
+ ids.add(m[1]);
347
+ }
348
+ return [...ids];
349
+ } catch {
350
+ return [];
351
+ }
352
+ }
216
353
  async function searchBraveForSpotifyTracks(query) {
217
354
  try {
218
355
  const searchQuery = `spotify track ${query} site:open.spotify.com`;
@@ -227,7 +364,9 @@ async function searchBraveForSpotifyTracks(query) {
227
364
  if (!res.ok) return [];
228
365
  const html = await res.text();
229
366
  const trackIds = /* @__PURE__ */ new Set();
230
- for (const m of html.matchAll(/https?:\/\/open\.spotify\.com\/track\/([a-zA-Z0-9]{22})/g)) {
367
+ for (const m of html.matchAll(
368
+ /https?:\/\/open\.spotify\.com\/track\/([a-zA-Z0-9]{22})/g
369
+ )) {
231
370
  trackIds.add(m[1]);
232
371
  }
233
372
  return [...trackIds];
@@ -249,7 +388,9 @@ async function searchDDGForSpotifyTracks(query) {
249
388
  if (!res.ok) return [];
250
389
  const html = await res.text();
251
390
  const trackIds = /* @__PURE__ */ new Set();
252
- for (const m of html.matchAll(/open\.spotify\.com(?:\/intl-[a-z]+)?\/track\/([a-zA-Z0-9]{22})/g)) {
391
+ for (const m of html.matchAll(
392
+ /open\.spotify\.com(?:\/intl-[a-z]+)?\/track\/([a-zA-Z0-9]{22})/g
393
+ )) {
253
394
  trackIds.add(m[1]);
254
395
  }
255
396
  return [...trackIds];
@@ -275,7 +416,9 @@ async function searchStartpageForSpotifyTracks(query) {
275
416
  if (!res.ok) return [];
276
417
  const html = await res.text();
277
418
  const trackIds = /* @__PURE__ */ new Set();
278
- for (const m of html.matchAll(/open\.spotify\.com(?:\/intl-[a-z]+)?\/track\/([a-zA-Z0-9]{22})/g)) {
419
+ for (const m of html.matchAll(
420
+ /open\.spotify\.com(?:\/intl-[a-z]+)?\/track\/([a-zA-Z0-9]{22})/g
421
+ )) {
279
422
  trackIds.add(m[1]);
280
423
  }
281
424
  return [...trackIds];
@@ -294,7 +437,9 @@ async function getTrackMetadata(trackId) {
294
437
  clearTimeout(timeout);
295
438
  if (!res.ok) return null;
296
439
  const html = await res.text();
297
- const scriptMatch = html.match(/<script[^>]*>(\{"props":\{"pageProps".*?\})<\/script>/s);
440
+ const scriptMatch = html.match(
441
+ /<script[^>]*>(\{"props":\{"pageProps".*?\})<\/script>/s
442
+ );
298
443
  if (!scriptMatch) return null;
299
444
  const data = JSON.parse(scriptMatch[1]);
300
445
  const entity = data?.props?.pageProps?.state?.data?.entity;
@@ -346,7 +491,8 @@ async function handleCommand(command, params) {
346
491
  case "sys_notification": {
347
492
  const title = params.title;
348
493
  const message = params.message;
349
- if (!title || !message) return { success: false, error: "Missing title or message" };
494
+ if (!title || !message)
495
+ return { success: false, error: "Missing title or message" };
350
496
  await runAppleScript(
351
497
  `display notification "${message.replace(/"/g, '\\"')}" with title "${title.replace(/"/g, '\\"')}"`
352
498
  );
@@ -375,26 +521,48 @@ async function handleCommand(command, params) {
375
521
  await runAppleScript('tell application "Spotify" to next track');
376
522
  return { success: true, data: { action: "next" } };
377
523
  case "previous":
378
- await runAppleScript('tell application "Spotify" to previous track');
524
+ await runAppleScript(
525
+ 'tell application "Spotify" to previous track'
526
+ );
379
527
  return { success: true, data: { action: "previous" } };
380
528
  case "now_playing": {
381
- const name = await runAppleScript('tell application "Spotify" to name of current track');
382
- const artist = await runAppleScript('tell application "Spotify" to artist of current track');
383
- const album = await runAppleScript('tell application "Spotify" to album of current track');
384
- const state = await runAppleScript('tell application "Spotify" to player state as string');
385
- return { success: true, data: { track: name, artist, album, state } };
529
+ const name = await runAppleScript(
530
+ 'tell application "Spotify" to name of current track'
531
+ );
532
+ const artist = await runAppleScript(
533
+ 'tell application "Spotify" to artist of current track'
534
+ );
535
+ const album = await runAppleScript(
536
+ 'tell application "Spotify" to album of current track'
537
+ );
538
+ const state = await runAppleScript(
539
+ 'tell application "Spotify" to player state as string'
540
+ );
541
+ return {
542
+ success: true,
543
+ data: { track: name, artist, album, state }
544
+ };
386
545
  }
387
546
  case "search_play": {
388
547
  const query = params.query;
389
- if (!query) return { success: false, error: "Missing search query" };
548
+ if (!query)
549
+ return { success: false, error: "Missing search query" };
390
550
  const result = await spotifySearch(query);
391
551
  if (result) {
392
- await runAppleScript(`tell application "Spotify" to play track "${result.uri}"`);
552
+ await runAppleScript(
553
+ `tell application "Spotify" to play track "${result.uri}"`
554
+ );
393
555
  await new Promise((r) => setTimeout(r, 1500));
394
556
  try {
395
- const track = await runAppleScript('tell application "Spotify" to name of current track');
396
- const artist = await runAppleScript('tell application "Spotify" to artist of current track');
397
- const state = await runAppleScript('tell application "Spotify" to player state as string');
557
+ const track = await runAppleScript(
558
+ 'tell application "Spotify" to name of current track'
559
+ );
560
+ const artist = await runAppleScript(
561
+ 'tell application "Spotify" to artist of current track'
562
+ );
563
+ const state = await runAppleScript(
564
+ 'tell application "Spotify" to player state as string'
565
+ );
398
566
  return {
399
567
  success: true,
400
568
  data: {
@@ -405,10 +573,19 @@ async function handleCommand(command, params) {
405
573
  }
406
574
  };
407
575
  } catch {
408
- return { success: true, data: { searched: query, resolved: `${result.name} - ${result.artist}`, note: "Playing track" } };
576
+ return {
577
+ success: true,
578
+ data: {
579
+ searched: query,
580
+ resolved: `${result.name} - ${result.artist}`,
581
+ note: "Playing track"
582
+ }
583
+ };
409
584
  }
410
585
  }
411
- await runShell(`open "spotify:search:${encodeURIComponent(query)}"`);
586
+ await runShell(
587
+ `open "spotify:search:${encodeURIComponent(query)}"`
588
+ );
412
589
  return {
413
590
  success: true,
414
591
  data: {
@@ -420,34 +597,61 @@ async function handleCommand(command, params) {
420
597
  }
421
598
  case "volume": {
422
599
  const level = params.level;
423
- if (level === void 0 || level < 0 || level > 100) return { success: false, error: "Volume must be 0-100" };
424
- await runAppleScript(`tell application "Spotify" to set sound volume to ${level}`);
600
+ if (level === void 0 || level < 0 || level > 100)
601
+ return { success: false, error: "Volume must be 0-100" };
602
+ await runAppleScript(
603
+ `tell application "Spotify" to set sound volume to ${level}`
604
+ );
425
605
  return { success: true, data: { volume: level } };
426
606
  }
427
607
  case "shuffle": {
428
608
  const enabled = params.enabled;
429
- await runAppleScript(`tell application "Spotify" to set shuffling to ${enabled ? "true" : "false"}`);
609
+ await runAppleScript(
610
+ `tell application "Spotify" to set shuffling to ${enabled ? "true" : "false"}`
611
+ );
430
612
  return { success: true, data: { shuffling: enabled } };
431
613
  }
432
614
  case "repeat": {
433
615
  const mode = params.mode;
434
- if (!mode) return { success: false, error: "Missing mode (off, context, track)" };
435
- const repeatMap = { off: "off", context: "context", track: "track" };
616
+ if (!mode)
617
+ return {
618
+ success: false,
619
+ error: "Missing mode (off, context, track)"
620
+ };
621
+ const repeatMap = {
622
+ off: "off",
623
+ context: "context",
624
+ track: "track"
625
+ };
436
626
  const val = repeatMap[mode];
437
- if (!val) return { success: false, error: "Mode must be: off, context, or track" };
438
- await runAppleScript(`tell application "Spotify" to set repeating to ${val !== "off"}`);
627
+ if (!val)
628
+ return {
629
+ success: false,
630
+ error: "Mode must be: off, context, or track"
631
+ };
632
+ await runAppleScript(
633
+ `tell application "Spotify" to set repeating to ${val !== "off"}`
634
+ );
439
635
  return { success: true, data: { repeating: mode } };
440
636
  }
441
637
  default:
442
- return { success: false, error: `Unknown Spotify action: ${action}` };
638
+ return {
639
+ success: false,
640
+ error: `Unknown Spotify action: ${action}`
641
+ };
443
642
  }
444
643
  }
445
644
  case "sys_file_read": {
446
645
  const path = params.path;
447
646
  if (!path) return { success: false, error: "Missing file path" };
448
647
  const fullPath = safePath(path);
449
- if (!fullPath) return { success: false, error: `Access denied. Only files in ${SAFE_DIRS.join(", ")} are allowed.` };
450
- if (!existsSync(fullPath)) return { success: false, error: `File not found: ${path}` };
648
+ if (!fullPath)
649
+ return {
650
+ success: false,
651
+ error: `Access denied. Only files in ${SAFE_DIRS.join(", ")} are allowed.`
652
+ };
653
+ if (!existsSync(fullPath))
654
+ return { success: false, error: `File not found: ${path}` };
451
655
  const content = readFileSync(fullPath, "utf-8");
452
656
  return {
453
657
  success: true,
@@ -462,9 +666,14 @@ async function handleCommand(command, params) {
462
666
  case "sys_file_write": {
463
667
  const path = params.path;
464
668
  const content = params.content;
465
- if (!path || !content) return { success: false, error: "Missing path or content" };
669
+ if (!path || !content)
670
+ return { success: false, error: "Missing path or content" };
466
671
  const fullPath = safePath(path);
467
- if (!fullPath) return { success: false, error: `Access denied. Only files in ${SAFE_DIRS.join(", ")} are allowed.` };
672
+ if (!fullPath)
673
+ return {
674
+ success: false,
675
+ error: `Access denied. Only files in ${SAFE_DIRS.join(", ")} are allowed.`
676
+ };
468
677
  writeFileSync(fullPath, content, "utf-8");
469
678
  return { success: true, data: { path, written: content.length } };
470
679
  }
@@ -473,20 +682,33 @@ async function handleCommand(command, params) {
473
682
  const pngPath = `/tmp/pulso-ss-${ts}.png`;
474
683
  const jpgPath = `/tmp/pulso-ss-${ts}.jpg`;
475
684
  await runShell(`screencapture -x ${pngPath}`, 15e3);
476
- if (!existsSync(pngPath)) return { success: false, error: "Screenshot failed" };
685
+ if (!existsSync(pngPath))
686
+ return { success: false, error: "Screenshot failed" };
477
687
  try {
478
- await runShell(`sips --resampleWidth 1280 --setProperty format jpeg --setProperty formatOptions 60 ${pngPath} --out ${jpgPath}`, 1e4);
688
+ await runShell(
689
+ `sips --resampleWidth 1280 --setProperty format jpeg --setProperty formatOptions 60 ${pngPath} --out ${jpgPath}`,
690
+ 1e4
691
+ );
479
692
  } catch {
480
693
  const buf2 = readFileSync(pngPath);
481
694
  exec(`rm -f ${pngPath}`);
482
- return { success: true, data: { image: `data:image/png;base64,${buf2.toString("base64")}`, format: "png", note: "Full screen screenshot" } };
695
+ return {
696
+ success: true,
697
+ data: {
698
+ image: `data:image/png;base64,${buf2.toString("base64")}`,
699
+ format: "png",
700
+ note: "Full screen screenshot"
701
+ }
702
+ };
483
703
  }
484
704
  const buf = readFileSync(jpgPath);
485
705
  const base64 = buf.toString("base64");
486
706
  exec(`rm -f ${pngPath} ${jpgPath}`);
487
707
  let screenSize = "unknown";
488
708
  try {
489
- screenSize = await runShell(`system_profiler SPDisplaysDataType 2>/dev/null | grep Resolution | head -1 | sed 's/.*: //'`);
709
+ screenSize = await runShell(
710
+ `system_profiler SPDisplaysDataType 2>/dev/null | grep Resolution | head -1 | sed 's/.*: //'`
711
+ );
490
712
  } catch {
491
713
  }
492
714
  return {
@@ -505,7 +727,8 @@ async function handleCommand(command, params) {
505
727
  const x = Number(params.x);
506
728
  const y = Number(params.y);
507
729
  const button = params.button || "left";
508
- if (isNaN(x) || isNaN(y)) return { success: false, error: "Missing x, y coordinates" };
730
+ if (isNaN(x) || isNaN(y))
731
+ return { success: false, error: "Missing x, y coordinates" };
509
732
  const mouseType = button === "right" ? "rightMouseDown" : "leftMouseDown";
510
733
  const mouseTypeUp = button === "right" ? "rightMouseUp" : "leftMouseUp";
511
734
  const mouseButton = button === "right" ? ".right" : ".left";
@@ -524,7 +747,8 @@ print("clicked")`;
524
747
  case "sys_mouse_double_click": {
525
748
  const x = Number(params.x);
526
749
  const y = Number(params.y);
527
- if (isNaN(x) || isNaN(y)) return { success: false, error: "Missing x, y coordinates" };
750
+ if (isNaN(x) || isNaN(y))
751
+ return { success: false, error: "Missing x, y coordinates" };
528
752
  const swift = `
529
753
  import Cocoa
530
754
  let p = CGPoint(x: ${x}, y: ${y})
@@ -552,7 +776,8 @@ print("double-clicked")`;
552
776
  const y = Number(params.y) || 0;
553
777
  const scrollY = Number(params.scrollY) || 0;
554
778
  const scrollX = Number(params.scrollX) || 0;
555
- if (!scrollY && !scrollX) return { success: false, error: "Missing scrollY or scrollX" };
779
+ if (!scrollY && !scrollX)
780
+ return { success: false, error: "Missing scrollY or scrollX" };
556
781
  const swift = `
557
782
  import Cocoa
558
783
  let p = CGPoint(x: ${x}, y: ${y})
@@ -563,12 +788,17 @@ let scroll = CGEvent(scrollWheelEvent2Source: nil, units: .pixel, wheelCount: 2,
563
788
  scroll.post(tap: .cghidEventTap)
564
789
  print("scrolled")`;
565
790
  await runSwift(swift);
566
- return { success: true, data: { scrolled: { x, y, scrollY, scrollX } } };
791
+ return {
792
+ success: true,
793
+ data: { scrolled: { x, y, scrollY, scrollX } }
794
+ };
567
795
  }
568
796
  case "sys_keyboard_type": {
569
797
  const text = params.text;
570
798
  if (!text) return { success: false, error: "Missing text" };
571
- await runAppleScript(`tell application "System Events" to keystroke "${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`);
799
+ await runAppleScript(
800
+ `tell application "System Events" to keystroke "${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
801
+ );
572
802
  return { success: true, data: { typed: text.slice(0, 100) } };
573
803
  }
574
804
  case "sys_key_press": {
@@ -619,19 +849,27 @@ print("scrolled")`;
619
849
  const keyCode = keyCodeMap[key.toLowerCase()];
620
850
  if (keyCode !== void 0) {
621
851
  const using = modStr.length > 0 ? ` using {${modStr.join(", ")}}` : "";
622
- await runAppleScript(`tell application "System Events" to key code ${keyCode}${using}`);
852
+ await runAppleScript(
853
+ `tell application "System Events" to key code ${keyCode}${using}`
854
+ );
623
855
  } else if (key.length === 1) {
624
856
  const using = modStr.length > 0 ? ` using {${modStr.join(", ")}}` : "";
625
- await runAppleScript(`tell application "System Events" to keystroke "${key}"${using}`);
857
+ await runAppleScript(
858
+ `tell application "System Events" to keystroke "${key}"${using}`
859
+ );
626
860
  } else {
627
- return { success: false, error: `Unknown key: ${key}. Use single characters or: enter, tab, escape, delete, space, up, down, left, right, f1-f12, home, end, pageup, pagedown` };
861
+ return {
862
+ success: false,
863
+ error: `Unknown key: ${key}. Use single characters or: enter, tab, escape, delete, space, up, down, left, right, f1-f12, home, end, pageup, pagedown`
864
+ };
628
865
  }
629
866
  return { success: true, data: { pressed: key, modifiers } };
630
867
  }
631
868
  case "sys_mouse_move": {
632
869
  const x = Number(params.x);
633
870
  const y = Number(params.y);
634
- if (isNaN(x) || isNaN(y)) return { success: false, error: "Missing x, y coordinates" };
871
+ if (isNaN(x) || isNaN(y))
872
+ return { success: false, error: "Missing x, y coordinates" };
635
873
  const swift = `
636
874
  import Cocoa
637
875
  let p = CGPoint(x: ${x}, y: ${y})
@@ -646,7 +884,8 @@ print("moved")`;
646
884
  const fromY = Number(params.fromY);
647
885
  const toX = Number(params.toX);
648
886
  const toY = Number(params.toY);
649
- if ([fromX, fromY, toX, toY].some(isNaN)) return { success: false, error: "Missing fromX, fromY, toX, toY" };
887
+ if ([fromX, fromY, toX, toY].some(isNaN))
888
+ return { success: false, error: "Missing fromX, fromY, toX, toY" };
650
889
  const swift = `
651
890
  import Cocoa
652
891
  let from = CGPoint(x: ${fromX}, y: ${fromY})
@@ -666,7 +905,12 @@ let u = CGEvent(mouseEventSource: nil, mouseType: .leftMouseUp, mouseCursorPosit
666
905
  u.post(tap: .cghidEventTap)
667
906
  print("dragged")`;
668
907
  await runSwift(swift);
669
- return { success: true, data: { dragged: { from: { x: fromX, y: fromY }, to: { x: toX, y: toY } } } };
908
+ return {
909
+ success: true,
910
+ data: {
911
+ dragged: { from: { x: fromX, y: fromY }, to: { x: toX, y: toY } }
912
+ }
913
+ };
670
914
  }
671
915
  case "sys_get_cursor_position": {
672
916
  const swift = `
@@ -684,7 +928,13 @@ print("\\(x),\\(y)")`;
684
928
  }
685
929
  // ── Browser Automation ─────────────────────────────────
686
930
  case "sys_browser_list_tabs": {
687
- const browsers = ["Google Chrome", "Safari", "Arc", "Firefox", "Microsoft Edge"];
931
+ const browsers = [
932
+ "Google Chrome",
933
+ "Safari",
934
+ "Arc",
935
+ "Firefox",
936
+ "Microsoft Edge"
937
+ ];
688
938
  const allTabs = [];
689
939
  for (const browser of browsers) {
690
940
  try {
@@ -708,7 +958,12 @@ print("\\(x),\\(y)")`;
708
958
  const tabStr = rest.join("~~~");
709
959
  const pairs = tabStr.split("|||").filter(Boolean);
710
960
  for (let i = 0; i < pairs.length - 1; i += 2) {
711
- allTabs.push({ browser: "Safari", title: pairs[i], url: pairs[i + 1], active: pairs[i + 1] === activeURL.trim() });
961
+ allTabs.push({
962
+ browser: "Safari",
963
+ title: pairs[i],
964
+ url: pairs[i + 1],
965
+ active: pairs[i + 1] === activeURL.trim()
966
+ });
712
967
  }
713
968
  } else {
714
969
  const tabData = await runAppleScript(`
@@ -726,13 +981,21 @@ print("\\(x),\\(y)")`;
726
981
  const tabStr = rest.join("~~~");
727
982
  const pairs = tabStr.split("|||").filter(Boolean);
728
983
  for (let i = 0; i < pairs.length - 1; i += 2) {
729
- allTabs.push({ browser, title: pairs[i], url: pairs[i + 1], active: pairs[i + 1] === activeURL.trim() });
984
+ allTabs.push({
985
+ browser,
986
+ title: pairs[i],
987
+ url: pairs[i + 1],
988
+ active: pairs[i + 1] === activeURL.trim()
989
+ });
730
990
  }
731
991
  }
732
992
  } catch {
733
993
  }
734
994
  }
735
- return { success: true, data: { tabs: allTabs, count: allTabs.length } };
995
+ return {
996
+ success: true,
997
+ data: { tabs: allTabs, count: allTabs.length }
998
+ };
736
999
  }
737
1000
  case "sys_browser_navigate": {
738
1001
  const url = params.url;
@@ -760,7 +1023,10 @@ print("\\(x),\\(y)")`;
760
1023
  }
761
1024
  return { success: true, data: { navigated: url, browser } };
762
1025
  } catch (err) {
763
- return { success: false, error: `Failed to navigate: ${err.message}` };
1026
+ return {
1027
+ success: false,
1028
+ error: `Failed to navigate: ${err.message}`
1029
+ };
764
1030
  }
765
1031
  }
766
1032
  case "sys_browser_new_tab": {
@@ -783,7 +1049,10 @@ print("\\(x),\\(y)")`;
783
1049
  }
784
1050
  return { success: true, data: { opened: url, browser } };
785
1051
  } catch (err) {
786
- return { success: false, error: `Failed to open tab: ${err.message}` };
1052
+ return {
1053
+ success: false,
1054
+ error: `Failed to open tab: ${err.message}`
1055
+ };
787
1056
  }
788
1057
  }
789
1058
  case "sys_browser_read_page": {
@@ -809,32 +1078,53 @@ print("\\(x),\\(y)")`;
809
1078
  }
810
1079
  } catch {
811
1080
  try {
812
- const savedClipboard = await runShell("pbpaste 2>/dev/null || true");
813
- await runAppleScript(`tell application "${browser.replace(/"/g, '\\"')}" to activate`);
1081
+ const savedClipboard = await runShell(
1082
+ "pbpaste 2>/dev/null || true"
1083
+ );
1084
+ await runAppleScript(
1085
+ `tell application "${browser.replace(/"/g, '\\"')}" to activate`
1086
+ );
814
1087
  await new Promise((r) => setTimeout(r, 300));
815
- await runAppleScript('tell application "System Events" to keystroke "a" using command down');
1088
+ await runAppleScript(
1089
+ 'tell application "System Events" to keystroke "a" using command down'
1090
+ );
816
1091
  await new Promise((r) => setTimeout(r, 200));
817
- await runAppleScript('tell application "System Events" to keystroke "c" using command down');
1092
+ await runAppleScript(
1093
+ 'tell application "System Events" to keystroke "c" using command down'
1094
+ );
818
1095
  await new Promise((r) => setTimeout(r, 300));
819
1096
  content = await runShell("pbpaste");
820
1097
  method = "clipboard";
821
- await runAppleScript('tell application "System Events" to key code 53');
1098
+ await runAppleScript(
1099
+ 'tell application "System Events" to key code 53'
1100
+ );
822
1101
  if (savedClipboard && savedClipboard !== content) {
823
1102
  execSync(`echo ${JSON.stringify(savedClipboard)} | pbcopy`);
824
1103
  }
825
1104
  } catch (clipErr) {
826
- return { success: false, error: `Could not read page. Enable 'Allow JavaScript from Apple Events' in ${browser} or grant Accessibility permission. Error: ${clipErr.message}` };
1105
+ return {
1106
+ success: false,
1107
+ error: `Could not read page. Enable 'Allow JavaScript from Apple Events' in ${browser} or grant Accessibility permission. Error: ${clipErr.message}`
1108
+ };
827
1109
  }
828
1110
  }
829
1111
  let pageUrl = "";
830
1112
  let pageTitle = "";
831
1113
  try {
832
1114
  if (browser === "Safari") {
833
- pageUrl = await runAppleScript('tell application "Safari" to return URL of front document');
834
- pageTitle = await runAppleScript('tell application "Safari" to return name of front document');
1115
+ pageUrl = await runAppleScript(
1116
+ 'tell application "Safari" to return URL of front document'
1117
+ );
1118
+ pageTitle = await runAppleScript(
1119
+ 'tell application "Safari" to return name of front document'
1120
+ );
835
1121
  } else {
836
- pageUrl = await runAppleScript(`tell application "${browser.replace(/"/g, '\\"')}" to return URL of active tab of front window`);
837
- pageTitle = await runAppleScript(`tell application "${browser.replace(/"/g, '\\"')}" to return title of active tab of front window`);
1122
+ pageUrl = await runAppleScript(
1123
+ `tell application "${browser.replace(/"/g, '\\"')}" to return URL of active tab of front window`
1124
+ );
1125
+ pageTitle = await runAppleScript(
1126
+ `tell application "${browser.replace(/"/g, '\\"')}" to return title of active tab of front window`
1127
+ );
838
1128
  }
839
1129
  } catch {
840
1130
  }
@@ -857,13 +1147,20 @@ print("\\(x),\\(y)")`;
857
1147
  try {
858
1148
  let result;
859
1149
  if (browser === "Safari") {
860
- result = await runAppleScript(`tell application "Safari" to do JavaScript ${JSON.stringify(js)} in front document`);
1150
+ result = await runAppleScript(
1151
+ `tell application "Safari" to do JavaScript ${JSON.stringify(js)} in front document`
1152
+ );
861
1153
  } else {
862
- result = await runAppleScript(`tell application "${browser.replace(/"/g, '\\"')}" to execute javascript ${JSON.stringify(js)} in active tab of front window`);
1154
+ result = await runAppleScript(
1155
+ `tell application "${browser.replace(/"/g, '\\"')}" to execute javascript ${JSON.stringify(js)} in active tab of front window`
1156
+ );
863
1157
  }
864
1158
  return { success: true, data: { result: result.slice(0, 5e3) } };
865
1159
  } catch (err) {
866
- return { success: false, error: `JS execution failed (enable 'Allow JavaScript from Apple Events' in browser): ${err.message}` };
1160
+ return {
1161
+ success: false,
1162
+ error: `JS execution failed (enable 'Allow JavaScript from Apple Events' in browser): ${err.message}`
1163
+ };
867
1164
  }
868
1165
  }
869
1166
  // ── Email ──────────────────────────────────────────────
@@ -872,11 +1169,20 @@ print("\\(x),\\(y)")`;
872
1169
  const subject = params.subject;
873
1170
  const body = params.body;
874
1171
  const method = params.method || "mail";
875
- if (!to || !subject || !body) return { success: false, error: "Missing to, subject, or body" };
1172
+ if (!to || !subject || !body)
1173
+ return { success: false, error: "Missing to, subject, or body" };
876
1174
  if (method === "gmail") {
877
1175
  const gmailUrl = `https://mail.google.com/mail/u/0/?view=cm&fs=1&to=${encodeURIComponent(to)}&su=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
878
1176
  await runShell(`open "${gmailUrl}"`);
879
- return { success: true, data: { method: "gmail", to, subject, note: "Gmail compose opened. User needs to click Send." } };
1177
+ return {
1178
+ success: true,
1179
+ data: {
1180
+ method: "gmail",
1181
+ to,
1182
+ subject,
1183
+ note: "Gmail compose opened. User needs to click Send."
1184
+ }
1185
+ };
880
1186
  }
881
1187
  try {
882
1188
  await runAppleScript(`
@@ -887,11 +1193,22 @@ print("\\(x),\\(y)")`;
887
1193
  end tell
888
1194
  send newMessage
889
1195
  end tell`);
890
- return { success: true, data: { method: "mail", to, subject, sent: true } };
1196
+ return {
1197
+ success: true,
1198
+ data: { method: "mail", to, subject, sent: true }
1199
+ };
891
1200
  } catch (err) {
892
1201
  const gmailUrl = `https://mail.google.com/mail/u/0/?view=cm&fs=1&to=${encodeURIComponent(to)}&su=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
893
1202
  await runShell(`open "${gmailUrl}"`);
894
- return { success: true, data: { method: "gmail_fallback", to, subject, note: `Mail.app failed (${err.message}). Gmail compose opened instead.` } };
1203
+ return {
1204
+ success: true,
1205
+ data: {
1206
+ method: "gmail_fallback",
1207
+ to,
1208
+ subject,
1209
+ note: `Mail.app failed (${err.message}). Gmail compose opened instead.`
1210
+ }
1211
+ };
895
1212
  }
896
1213
  }
897
1214
  case "sys_run_shortcut": {
@@ -899,21 +1216,44 @@ print("\\(x),\\(y)")`;
899
1216
  const input = params.input;
900
1217
  if (!name) return { success: false, error: "Missing shortcut name" };
901
1218
  const inputFlag = input ? `--input-type text --input "${input.replace(/"/g, '\\"')}"` : "";
902
- const result = await runShell(`shortcuts run "${name.replace(/"/g, '\\"')}" ${inputFlag}`, 3e4);
903
- return { success: true, data: { shortcut: name, output: result || "Shortcut executed" } };
1219
+ const result = await runShell(
1220
+ `shortcuts run "${name.replace(/"/g, '\\"')}" ${inputFlag}`,
1221
+ 3e4
1222
+ );
1223
+ return {
1224
+ success: true,
1225
+ data: { shortcut: name, output: result || "Shortcut executed" }
1226
+ };
904
1227
  }
905
1228
  // ── Shell Execution ─────────────────────────────────────
906
1229
  case "sys_shell": {
907
1230
  const cmd = params.command;
908
1231
  if (!cmd) return { success: false, error: "Missing command" };
909
- const blocked = ["rm -rf /", "mkfs", "dd if=", "> /dev/", ":(){ :|:& };:"];
910
- if (blocked.some((b) => cmd.includes(b))) return { success: false, error: "Command blocked for safety" };
1232
+ const blocked = [
1233
+ "rm -rf /",
1234
+ "mkfs",
1235
+ "dd if=",
1236
+ "> /dev/",
1237
+ ":(){ :|:& };:"
1238
+ ];
1239
+ if (blocked.some((b) => cmd.includes(b)))
1240
+ return { success: false, error: "Command blocked for safety" };
911
1241
  const timeout = Number(params.timeout) || 15e3;
912
1242
  try {
913
1243
  const output = await runShell(cmd, timeout);
914
- return { success: true, data: { command: cmd, output: output.slice(0, 1e4), truncated: output.length > 1e4 } };
1244
+ return {
1245
+ success: true,
1246
+ data: {
1247
+ command: cmd,
1248
+ output: output.slice(0, 1e4),
1249
+ truncated: output.length > 1e4
1250
+ }
1251
+ };
915
1252
  } catch (err) {
916
- return { success: false, error: `Shell error: ${err.message.slice(0, 2e3)}` };
1253
+ return {
1254
+ success: false,
1255
+ error: `Shell error: ${err.message.slice(0, 2e3)}`
1256
+ };
917
1257
  }
918
1258
  }
919
1259
  // ── Calendar ────────────────────────────────────────────
@@ -937,9 +1277,17 @@ print("\\(x),\\(y)")`;
937
1277
  const raw = await runAppleScript(script);
938
1278
  const events = raw.split("\n").filter(Boolean).map((line) => {
939
1279
  const [cal, summary, start, end] = line.split(" | ");
940
- return { calendar: cal?.trim(), summary: summary?.trim(), start: start?.trim(), end: end?.trim() };
1280
+ return {
1281
+ calendar: cal?.trim(),
1282
+ summary: summary?.trim(),
1283
+ start: start?.trim(),
1284
+ end: end?.trim()
1285
+ };
941
1286
  });
942
- return { success: true, data: { events, count: events.length, daysAhead: days } };
1287
+ return {
1288
+ success: true,
1289
+ data: { events, count: events.length, daysAhead: days }
1290
+ };
943
1291
  }
944
1292
  case "sys_calendar_create": {
945
1293
  const summary = params.summary || params.title;
@@ -947,7 +1295,8 @@ print("\\(x),\\(y)")`;
947
1295
  const endStr = params.end;
948
1296
  const calendar = params.calendar || "";
949
1297
  const notes = params.notes || "";
950
- if (!summary || !startStr) return { success: false, error: "Missing summary or start time" };
1298
+ if (!summary || !startStr)
1299
+ return { success: false, error: "Missing summary or start time" };
951
1300
  const calTarget = calendar ? `calendar "${calendar.replace(/"/g, '\\"')}"` : "default calendar";
952
1301
  const endPart = endStr ? `set end date of newEvent to date "${endStr}"` : "";
953
1302
  const notesPart = notes ? `set description of newEvent to "${notes.replace(/"/g, '\\"')}"` : "";
@@ -959,7 +1308,10 @@ print("\\(x),\\(y)")`;
959
1308
  ${notesPart}
960
1309
  end tell
961
1310
  end tell`);
962
- return { success: true, data: { created: summary, start: startStr, end: endStr || "auto" } };
1311
+ return {
1312
+ success: true,
1313
+ data: { created: summary, start: startStr, end: endStr || "auto" }
1314
+ };
963
1315
  }
964
1316
  // ── Reminders ───────────────────────────────────────────
965
1317
  case "sys_reminder_list": {
@@ -983,18 +1335,33 @@ print("\\(x),\\(y)")`;
983
1335
  const raw = await runAppleScript(script);
984
1336
  const reminders = raw.split("\n").filter(Boolean).map((line) => {
985
1337
  const parts = line.split(" | ");
986
- return parts.length === 3 ? { list: parts[0]?.trim(), name: parts[1]?.trim(), due: parts[2]?.trim() } : { name: parts[0]?.trim(), due: parts[1]?.trim() };
1338
+ return parts.length === 3 ? {
1339
+ list: parts[0]?.trim(),
1340
+ name: parts[1]?.trim(),
1341
+ due: parts[2]?.trim()
1342
+ } : { name: parts[0]?.trim(), due: parts[1]?.trim() };
987
1343
  });
988
- return { success: true, data: { reminders, count: reminders.length } };
1344
+ return {
1345
+ success: true,
1346
+ data: { reminders, count: reminders.length }
1347
+ };
989
1348
  } catch {
990
- return { success: true, data: { reminders: [], count: 0, note: "No reminders or Reminders app not accessible" } };
1349
+ return {
1350
+ success: true,
1351
+ data: {
1352
+ reminders: [],
1353
+ count: 0,
1354
+ note: "No reminders or Reminders app not accessible"
1355
+ }
1356
+ };
991
1357
  }
992
1358
  }
993
1359
  case "sys_reminder_create": {
994
1360
  const reminderName = params.name;
995
1361
  const dueDate = params.due;
996
1362
  const listName2 = params.list || "Reminders";
997
- if (!reminderName) return { success: false, error: "Missing reminder name" };
1363
+ if (!reminderName)
1364
+ return { success: false, error: "Missing reminder name" };
998
1365
  const duePart = dueDate ? `, due date:date "${dueDate}"` : "";
999
1366
  await runAppleScript(`
1000
1367
  tell application "Reminders"
@@ -1002,20 +1369,34 @@ print("\\(x),\\(y)")`;
1002
1369
  make new reminder with properties {name:"${reminderName.replace(/"/g, '\\"')}"${duePart}}
1003
1370
  end tell
1004
1371
  end tell`);
1005
- return { success: true, data: { created: reminderName, due: dueDate || "none", list: listName2 } };
1372
+ return {
1373
+ success: true,
1374
+ data: {
1375
+ created: reminderName,
1376
+ due: dueDate || "none",
1377
+ list: listName2
1378
+ }
1379
+ };
1006
1380
  }
1007
1381
  // ── iMessage ────────────────────────────────────────────
1008
1382
  case "sys_imessage_send": {
1009
1383
  const to2 = params.to;
1010
1384
  const msg = params.message;
1011
- if (!to2 || !msg) return { success: false, error: "Missing 'to' (phone/email) or 'message'" };
1385
+ if (!to2 || !msg)
1386
+ return {
1387
+ success: false,
1388
+ error: "Missing 'to' (phone/email) or 'message'"
1389
+ };
1012
1390
  await runAppleScript(`
1013
1391
  tell application "Messages"
1014
1392
  set targetService to 1st account whose service type = iMessage
1015
1393
  set targetBuddy to participant "${to2.replace(/"/g, '\\"')}" of targetService
1016
1394
  send "${msg.replace(/"/g, '\\"')}" to targetBuddy
1017
1395
  end tell`);
1018
- return { success: true, data: { sent: true, to: to2, message: msg.slice(0, 100) } };
1396
+ return {
1397
+ success: true,
1398
+ data: { sent: true, to: to2, message: msg.slice(0, 100) }
1399
+ };
1019
1400
  }
1020
1401
  // ── System Info ─────────────────────────────────────────
1021
1402
  case "sys_system_info": {
@@ -1026,7 +1407,9 @@ print("\\(x),\\(y)")`;
1026
1407
  info.battery = "N/A";
1027
1408
  }
1028
1409
  try {
1029
- info.wifi = (await runShell("networksetup -getairportnetwork en0 2>/dev/null | cut -d: -f2")).trim();
1410
+ info.wifi = (await runShell(
1411
+ "networksetup -getairportnetwork en0 2>/dev/null | cut -d: -f2"
1412
+ )).trim();
1030
1413
  } catch {
1031
1414
  info.wifi = "N/A";
1032
1415
  }
@@ -1041,7 +1424,9 @@ print("\\(x),\\(y)")`;
1041
1424
  info.ip_public = "N/A";
1042
1425
  }
1043
1426
  try {
1044
- info.disk = (await runShell(`df -h / | tail -1 | awk '{print $3 " used / " $2 " total (" $5 " used)"}'`)).trim();
1427
+ info.disk = (await runShell(
1428
+ `df -h / | tail -1 | awk '{print $3 " used / " $2 " total (" $5 " used)"}'`
1429
+ )).trim();
1045
1430
  } catch {
1046
1431
  info.disk = "N/A";
1047
1432
  }
@@ -1080,7 +1465,9 @@ print("\\(x),\\(y)")`;
1080
1465
  await runAppleScript(`set volume output volume ${vol}`);
1081
1466
  return { success: true, data: { volume: vol } };
1082
1467
  }
1083
- const raw2 = await runAppleScript("output volume of (get volume settings)");
1468
+ const raw2 = await runAppleScript(
1469
+ "output volume of (get volume settings)"
1470
+ );
1084
1471
  return { success: true, data: { volume: Number(raw2) || 0 } };
1085
1472
  }
1086
1473
  // ── Brightness ──────────────────────────────────────────
@@ -1088,14 +1475,27 @@ print("\\(x),\\(y)")`;
1088
1475
  const level2 = params.level;
1089
1476
  if (level2 !== void 0) {
1090
1477
  const br = Math.max(0, Math.min(1, Number(level2)));
1091
- await runShell(`brightness ${br} 2>/dev/null || osascript -e 'tell application "System Events" to tell appearance preferences to set dark mode to ${br < 0.3}'`);
1478
+ await runShell(
1479
+ `brightness ${br} 2>/dev/null || osascript -e 'tell application "System Events" to tell appearance preferences to set dark mode to ${br < 0.3}'`
1480
+ );
1092
1481
  return { success: true, data: { brightness: br } };
1093
1482
  }
1094
1483
  try {
1095
- const raw3 = await runShell("brightness -l 2>/dev/null | grep brightness | head -1 | awk '{print $NF}'");
1096
- return { success: true, data: { brightness: parseFloat(raw3) || 0.5 } };
1484
+ const raw3 = await runShell(
1485
+ "brightness -l 2>/dev/null | grep brightness | head -1 | awk '{print $NF}'"
1486
+ );
1487
+ return {
1488
+ success: true,
1489
+ data: { brightness: parseFloat(raw3) || 0.5 }
1490
+ };
1097
1491
  } catch {
1098
- return { success: true, data: { brightness: "unknown", note: "Install 'brightness' via brew for control" } };
1492
+ return {
1493
+ success: true,
1494
+ data: {
1495
+ brightness: "unknown",
1496
+ note: "Install 'brightness' via brew for control"
1497
+ }
1498
+ };
1099
1499
  }
1100
1500
  }
1101
1501
  // ── Do Not Disturb ──────────────────────────────────────
@@ -1103,49 +1503,79 @@ print("\\(x),\\(y)")`;
1103
1503
  const enabled = params.enabled;
1104
1504
  if (enabled !== void 0) {
1105
1505
  try {
1106
- await runShell(`shortcuts run "Toggle Do Not Disturb" 2>/dev/null || osascript -e 'do shell script "defaults write com.apple.ncprefs dnd_prefs -data 0"'`);
1107
- return { success: true, data: { dnd: enabled, note: "DND toggled" } };
1506
+ await runShell(
1507
+ `shortcuts run "Toggle Do Not Disturb" 2>/dev/null || osascript -e 'do shell script "defaults write com.apple.ncprefs dnd_prefs -data 0"'`
1508
+ );
1509
+ return {
1510
+ success: true,
1511
+ data: { dnd: enabled, note: "DND toggled" }
1512
+ };
1108
1513
  } catch {
1109
- return { success: true, data: { dnd: enabled, note: "Set DND manually in Control Center" } };
1514
+ return {
1515
+ success: true,
1516
+ data: {
1517
+ dnd: enabled,
1518
+ note: "Set DND manually in Control Center"
1519
+ }
1520
+ };
1110
1521
  }
1111
1522
  }
1112
- return { success: true, data: { note: "Pass enabled: true/false to toggle DND" } };
1523
+ return {
1524
+ success: true,
1525
+ data: { note: "Pass enabled: true/false to toggle DND" }
1526
+ };
1113
1527
  }
1114
1528
  // ── File Management ─────────────────────────────────────
1115
1529
  case "sys_file_list": {
1116
1530
  const dirPath = params.path || "Desktop";
1117
1531
  const fullDir = safePath(dirPath);
1118
- if (!fullDir) return { success: false, error: `Access denied: ${dirPath}` };
1119
- if (!existsSync(fullDir)) return { success: false, error: `Directory not found: ${dirPath}` };
1532
+ if (!fullDir)
1533
+ return { success: false, error: `Access denied: ${dirPath}` };
1534
+ if (!existsSync(fullDir))
1535
+ return { success: false, error: `Directory not found: ${dirPath}` };
1120
1536
  const entries = readdirSync(fullDir).map((name) => {
1121
1537
  try {
1122
1538
  const st = statSync(join(fullDir, name));
1123
- return { name, type: st.isDirectory() ? "dir" : "file", size: st.size, modified: st.mtime.toISOString() };
1539
+ return {
1540
+ name,
1541
+ type: st.isDirectory() ? "dir" : "file",
1542
+ size: st.size,
1543
+ modified: st.mtime.toISOString()
1544
+ };
1124
1545
  } catch {
1125
1546
  return { name, type: "unknown", size: 0, modified: "" };
1126
1547
  }
1127
1548
  });
1128
- return { success: true, data: { path: dirPath, entries, count: entries.length } };
1549
+ return {
1550
+ success: true,
1551
+ data: { path: dirPath, entries, count: entries.length }
1552
+ };
1129
1553
  }
1130
1554
  case "sys_file_move": {
1131
1555
  const src = params.from;
1132
1556
  const dst = params.to;
1133
- if (!src || !dst) return { success: false, error: "Missing from/to paths" };
1557
+ if (!src || !dst)
1558
+ return { success: false, error: "Missing from/to paths" };
1134
1559
  const fullSrc = safePath(src);
1135
1560
  const fullDst = safePath(dst);
1136
- if (!fullSrc || !fullDst) return { success: false, error: "Access denied" };
1137
- if (!existsSync(fullSrc)) return { success: false, error: `Source not found: ${src}` };
1561
+ if (!fullSrc || !fullDst)
1562
+ return { success: false, error: "Access denied" };
1563
+ if (!existsSync(fullSrc))
1564
+ return { success: false, error: `Source not found: ${src}` };
1138
1565
  renameSync(fullSrc, fullDst);
1139
1566
  return { success: true, data: { moved: src, to: dst } };
1140
1567
  }
1141
1568
  case "sys_file_copy": {
1142
1569
  const src2 = params.from;
1143
1570
  const dst2 = params.to;
1144
- if (!src2 || !dst2) return { success: false, error: "Missing from/to paths" };
1571
+ if (!src2 || !dst2)
1572
+ return { success: false, error: "Missing from/to paths" };
1145
1573
  const fullSrc2 = safePath(src2);
1146
1574
  const fullDst2 = safePath(dst2);
1147
- if (!fullSrc2 || !fullDst2) return { success: false, error: "Access denied" };
1148
- if (!existsSync(fullSrc2)) return { success: false, error: `Source not found: ${src2}` };
1575
+ if (!fullSrc2 || !fullDst2)
1576
+ return { success: false, error: "Access denied" };
1577
+ if (!existsSync(fullSrc2))
1578
+ return { success: false, error: `Source not found: ${src2}` };
1149
1579
  copyFileSync(fullSrc2, fullDst2);
1150
1580
  return { success: true, data: { copied: src2, to: dst2 } };
1151
1581
  }
@@ -1154,16 +1584,23 @@ print("\\(x),\\(y)")`;
1154
1584
  if (!target) return { success: false, error: "Missing path" };
1155
1585
  const fullTarget = safePath(target);
1156
1586
  if (!fullTarget) return { success: false, error: "Access denied" };
1157
- if (!existsSync(fullTarget)) return { success: false, error: `Not found: ${target}` };
1158
- await runShell(`osascript -e 'tell application "Finder" to delete POSIX file "${fullTarget}"'`);
1159
- return { success: true, data: { deleted: target, method: "moved_to_trash" } };
1587
+ if (!existsSync(fullTarget))
1588
+ return { success: false, error: `Not found: ${target}` };
1589
+ await runShell(
1590
+ `osascript -e 'tell application "Finder" to delete POSIX file "${fullTarget}"'`
1591
+ );
1592
+ return {
1593
+ success: true,
1594
+ data: { deleted: target, method: "moved_to_trash" }
1595
+ };
1160
1596
  }
1161
1597
  case "sys_file_info": {
1162
1598
  const fPath = params.path;
1163
1599
  if (!fPath) return { success: false, error: "Missing path" };
1164
1600
  const fullF = safePath(fPath);
1165
1601
  if (!fullF) return { success: false, error: "Access denied" };
1166
- if (!existsSync(fullF)) return { success: false, error: `Not found: ${fPath}` };
1602
+ if (!existsSync(fullF))
1603
+ return { success: false, error: `Not found: ${fPath}` };
1167
1604
  const st = statSync(fullF);
1168
1605
  return {
1169
1606
  success: true,
@@ -1186,9 +1623,15 @@ print("\\(x),\\(y)")`;
1186
1623
  if (!dlUrl) return { success: false, error: "Missing URL" };
1187
1624
  const fullDl = safePath(dlDest);
1188
1625
  if (!fullDl) return { success: false, error: "Access denied" };
1189
- await runShell(`curl -sL -o "${fullDl}" "${dlUrl.replace(/"/g, '\\"')}"`, 6e4);
1626
+ await runShell(
1627
+ `curl -sL -o "${fullDl}" "${dlUrl.replace(/"/g, '\\"')}"`,
1628
+ 6e4
1629
+ );
1190
1630
  const size = existsSync(fullDl) ? statSync(fullDl).size : 0;
1191
- return { success: true, data: { downloaded: dlUrl, saved: dlDest, size } };
1631
+ return {
1632
+ success: true,
1633
+ data: { downloaded: dlUrl, saved: dlDest, size }
1634
+ };
1192
1635
  }
1193
1636
  // ── Window Management ───────────────────────────────────
1194
1637
  case "sys_window_list": {
@@ -1205,7 +1648,12 @@ print("\\(x),\\(y)")`;
1205
1648
  end tell`);
1206
1649
  const windows = raw4.split("\n").filter(Boolean).map((line) => {
1207
1650
  const [app, title, pos, sz] = line.split(" | ");
1208
- return { app: app?.trim(), title: title?.trim(), position: pos?.trim(), size: sz?.trim() };
1651
+ return {
1652
+ app: app?.trim(),
1653
+ title: title?.trim(),
1654
+ position: pos?.trim(),
1655
+ size: sz?.trim()
1656
+ };
1209
1657
  });
1210
1658
  return { success: true, data: { windows, count: windows.length } };
1211
1659
  }
@@ -1228,7 +1676,11 @@ print("\\(x),\\(y)")`;
1228
1676
  if (!app2) return { success: false, error: "Missing app name" };
1229
1677
  const posPart = x !== void 0 && y !== void 0 ? `set position of window 1 to {${x}, ${y}}` : "";
1230
1678
  const sizePart = w !== void 0 && h !== void 0 ? `set size of window 1 to {${w}, ${h}}` : "";
1231
- if (!posPart && !sizePart) return { success: false, error: "Provide x,y for position and/or width,height for size" };
1679
+ if (!posPart && !sizePart)
1680
+ return {
1681
+ success: false,
1682
+ error: "Provide x,y for position and/or width,height for size"
1683
+ };
1232
1684
  await runAppleScript(`
1233
1685
  tell application "System Events"
1234
1686
  tell process "${app2.replace(/"/g, '\\"')}"
@@ -1236,7 +1688,14 @@ print("\\(x),\\(y)")`;
1236
1688
  ${sizePart}
1237
1689
  end tell
1238
1690
  end tell`);
1239
- return { success: true, data: { app: app2, position: posPart ? { x, y } : "unchanged", size: sizePart ? { width: w, height: h } : "unchanged" } };
1691
+ return {
1692
+ success: true,
1693
+ data: {
1694
+ app: app2,
1695
+ position: posPart ? { x, y } : "unchanged",
1696
+ size: sizePart ? { width: w, height: h } : "unchanged"
1697
+ }
1698
+ };
1240
1699
  }
1241
1700
  // ── Apple Notes ─────────────────────────────────────────
1242
1701
  case "sys_notes_list": {
@@ -1252,7 +1711,11 @@ print("\\(x),\\(y)")`;
1252
1711
  end tell`);
1253
1712
  const notes = raw5.split("\n").filter(Boolean).map((line) => {
1254
1713
  const [id, name, date] = line.split(" | ");
1255
- return { id: id?.trim(), name: name?.trim(), modified: date?.trim() };
1714
+ return {
1715
+ id: id?.trim(),
1716
+ name: name?.trim(),
1717
+ modified: date?.trim()
1718
+ };
1256
1719
  });
1257
1720
  return { success: true, data: { notes, count: notes.length } };
1258
1721
  }
@@ -1292,9 +1755,16 @@ print("\\(x),\\(y)")`;
1292
1755
  end tell`);
1293
1756
  const contacts = raw6.split("\n").filter(Boolean).map((line) => {
1294
1757
  const [name, email, phone] = line.split(" | ");
1295
- return { name: name?.trim(), email: email?.trim(), phone: phone?.trim() };
1758
+ return {
1759
+ name: name?.trim(),
1760
+ email: email?.trim(),
1761
+ phone: phone?.trim()
1762
+ };
1296
1763
  });
1297
- return { success: true, data: { contacts, count: contacts.length, query: query2 } };
1764
+ return {
1765
+ success: true,
1766
+ data: { contacts, count: contacts.length, query: query2 }
1767
+ };
1298
1768
  }
1299
1769
  // ── OCR (Vision framework) ──────────────────────────────
1300
1770
  case "sys_ocr": {
@@ -1302,7 +1772,8 @@ print("\\(x),\\(y)")`;
1302
1772
  if (!imgPath) return { success: false, error: "Missing image path" };
1303
1773
  const fullImg = imgPath.startsWith("/tmp/") ? imgPath : safePath(imgPath);
1304
1774
  if (!fullImg) return { success: false, error: "Access denied" };
1305
- if (!existsSync(fullImg)) return { success: false, error: `Image not found: ${imgPath}` };
1775
+ if (!existsSync(fullImg))
1776
+ return { success: false, error: `Image not found: ${imgPath}` };
1306
1777
  const swiftOcr = `
1307
1778
  import Foundation
1308
1779
  import Vision
@@ -1324,10 +1795,311 @@ let text = results.compactMap { $0.topCandidates(1).first?.string }.joined(separ
1324
1795
  print(text)`;
1325
1796
  try {
1326
1797
  const ocrText = await runSwift(swiftOcr, 3e4);
1327
- return { success: true, data: { text: ocrText.slice(0, 1e4), length: ocrText.length, path: imgPath } };
1798
+ return {
1799
+ success: true,
1800
+ data: {
1801
+ text: ocrText.slice(0, 1e4),
1802
+ length: ocrText.length,
1803
+ path: imgPath
1804
+ }
1805
+ };
1328
1806
  } catch (err) {
1329
- return { success: false, error: `OCR failed: ${err.message}` };
1807
+ return {
1808
+ success: false,
1809
+ error: `OCR failed: ${err.message}`
1810
+ };
1811
+ }
1812
+ }
1813
+ // ── Philips Hue (Local Bridge API) ─────────────────────
1814
+ // Commands are prefixed with sys_hue_ (mapped from hue_ in chat.ts → system_hue_ → sys_hue_)
1815
+ case "sys_hue_lights_on": {
1816
+ const light = params.light;
1817
+ if (!light)
1818
+ return { success: false, error: "Missing light ID or name" };
1819
+ const hueConfig = await getHueConfig();
1820
+ if (!hueConfig)
1821
+ return {
1822
+ success: false,
1823
+ error: "Philips Hue not configured. Set HUE_BRIDGE_IP and HUE_USERNAME environment variables, or configure in Pulso Settings."
1824
+ };
1825
+ const lightId = await resolveHueLight(hueConfig, light);
1826
+ if (!lightId)
1827
+ return {
1828
+ success: false,
1829
+ error: `Light '${light}' not found on Hue bridge`
1830
+ };
1831
+ const state = { on: true };
1832
+ if (params.brightness !== void 0)
1833
+ state.bri = Math.max(1, Math.min(254, Number(params.brightness)));
1834
+ if (params.color) {
1835
+ const rgb = parseColorCompanion(params.color);
1836
+ if (rgb) {
1837
+ const [x, y] = rgbToXyCompanion(...rgb);
1838
+ state.xy = [x, y];
1839
+ }
1840
+ }
1841
+ const hueRes = await hueRequest(
1842
+ hueConfig,
1843
+ `lights/${lightId}/state`,
1844
+ "PUT",
1845
+ state
1846
+ );
1847
+ return {
1848
+ success: true,
1849
+ data: { light: lightId, action: "on", ...state, response: hueRes }
1850
+ };
1851
+ }
1852
+ case "sys_hue_lights_off": {
1853
+ const light = params.light;
1854
+ if (!light)
1855
+ return { success: false, error: "Missing light ID or name" };
1856
+ const hueConfig = await getHueConfig();
1857
+ if (!hueConfig)
1858
+ return { success: false, error: "Philips Hue not configured." };
1859
+ const lightId = await resolveHueLight(hueConfig, light);
1860
+ if (!lightId)
1861
+ return { success: false, error: `Light '${light}' not found` };
1862
+ const hueRes = await hueRequest(
1863
+ hueConfig,
1864
+ `lights/${lightId}/state`,
1865
+ "PUT",
1866
+ { on: false }
1867
+ );
1868
+ return {
1869
+ success: true,
1870
+ data: { light: lightId, action: "off", response: hueRes }
1871
+ };
1872
+ }
1873
+ case "sys_hue_lights_color": {
1874
+ const light = params.light;
1875
+ const color = params.color;
1876
+ if (!light || !color)
1877
+ return { success: false, error: "Missing light or color" };
1878
+ const hueConfig = await getHueConfig();
1879
+ if (!hueConfig)
1880
+ return { success: false, error: "Philips Hue not configured." };
1881
+ const lightId = await resolveHueLight(hueConfig, light);
1882
+ if (!lightId)
1883
+ return { success: false, error: `Light '${light}' not found` };
1884
+ const rgb = parseColorCompanion(color);
1885
+ if (!rgb)
1886
+ return { success: false, error: `Unrecognized color: ${color}` };
1887
+ const [x, y] = rgbToXyCompanion(...rgb);
1888
+ const hueRes = await hueRequest(
1889
+ hueConfig,
1890
+ `lights/${lightId}/state`,
1891
+ "PUT",
1892
+ { on: true, xy: [x, y] }
1893
+ );
1894
+ return {
1895
+ success: true,
1896
+ data: { light: lightId, color, xy: [x, y], response: hueRes }
1897
+ };
1898
+ }
1899
+ case "sys_hue_lights_brightness": {
1900
+ const light = params.light;
1901
+ const brightness = Number(params.brightness);
1902
+ if (!light || isNaN(brightness))
1903
+ return { success: false, error: "Missing light or brightness" };
1904
+ const hueConfig = await getHueConfig();
1905
+ if (!hueConfig)
1906
+ return { success: false, error: "Philips Hue not configured." };
1907
+ const lightId = await resolveHueLight(hueConfig, light);
1908
+ if (!lightId)
1909
+ return { success: false, error: `Light '${light}' not found` };
1910
+ const bri = Math.max(1, Math.min(254, brightness));
1911
+ const hueRes = await hueRequest(
1912
+ hueConfig,
1913
+ `lights/${lightId}/state`,
1914
+ "PUT",
1915
+ { on: true, bri }
1916
+ );
1917
+ return {
1918
+ success: true,
1919
+ data: { light: lightId, brightness: bri, response: hueRes }
1920
+ };
1921
+ }
1922
+ case "sys_hue_lights_scene": {
1923
+ const scene = params.scene;
1924
+ const group = params.group || "0";
1925
+ if (!scene) return { success: false, error: "Missing scene name" };
1926
+ const hueConfig = await getHueConfig();
1927
+ if (!hueConfig)
1928
+ return { success: false, error: "Philips Hue not configured." };
1929
+ const scenes = await hueRequest(hueConfig, "scenes", "GET");
1930
+ let sceneId = null;
1931
+ for (const [id, s] of Object.entries(scenes)) {
1932
+ if (s.name?.toLowerCase() === scene.toLowerCase()) {
1933
+ sceneId = id;
1934
+ break;
1935
+ }
1936
+ }
1937
+ if (!sceneId)
1938
+ return {
1939
+ success: false,
1940
+ error: `Scene '${scene}' not found. Available: ${Object.values(
1941
+ scenes
1942
+ ).map((s) => s.name).join(", ")}`
1943
+ };
1944
+ const hueRes = await hueRequest(
1945
+ hueConfig,
1946
+ `groups/${group}/action`,
1947
+ "PUT",
1948
+ { scene: sceneId }
1949
+ );
1950
+ return {
1951
+ success: true,
1952
+ data: { scene, sceneId, group, response: hueRes }
1953
+ };
1954
+ }
1955
+ case "sys_hue_lights_list": {
1956
+ const hueConfig = await getHueConfig();
1957
+ if (!hueConfig)
1958
+ return { success: false, error: "Philips Hue not configured." };
1959
+ const [lights, groups, scenes] = await Promise.all([
1960
+ hueRequest(hueConfig, "lights", "GET"),
1961
+ hueRequest(hueConfig, "groups", "GET"),
1962
+ hueRequest(hueConfig, "scenes", "GET")
1963
+ ]);
1964
+ return {
1965
+ success: true,
1966
+ data: {
1967
+ lights: Object.entries(lights).map(([id, l]) => ({
1968
+ id,
1969
+ name: l.name,
1970
+ on: l.state?.on,
1971
+ brightness: l.state?.bri,
1972
+ type: l.type
1973
+ })),
1974
+ groups: Object.entries(groups).map(([id, g]) => ({
1975
+ id,
1976
+ name: g.name,
1977
+ type: g.type,
1978
+ lightCount: g.lights?.length
1979
+ })),
1980
+ scenes: Object.entries(scenes).map(([id, s]) => ({
1981
+ id,
1982
+ name: s.name,
1983
+ group: s.group
1984
+ }))
1985
+ }
1986
+ };
1987
+ }
1988
+ // ── Sonos (Local HTTP API) ──────────────────────────────
1989
+ case "sys_sonos_play": {
1990
+ const room = params.room;
1991
+ if (!room) return { success: false, error: "Missing room name" };
1992
+ const sonosUrl = getSonosApiUrl();
1993
+ if (!sonosUrl)
1994
+ return {
1995
+ success: false,
1996
+ error: "Sonos not configured. Set SONOS_API_URL environment variable (e.g., http://localhost:5005)."
1997
+ };
1998
+ const res = await sonosRequest(
1999
+ sonosUrl,
2000
+ `${encodeURIComponent(room)}/play`
2001
+ );
2002
+ return { success: true, data: { room, action: "play", response: res } };
2003
+ }
2004
+ case "sys_sonos_pause": {
2005
+ const room = params.room;
2006
+ if (!room) return { success: false, error: "Missing room name" };
2007
+ const sonosUrl = getSonosApiUrl();
2008
+ if (!sonosUrl)
2009
+ return { success: false, error: "Sonos not configured." };
2010
+ const res = await sonosRequest(
2011
+ sonosUrl,
2012
+ `${encodeURIComponent(room)}/pause`
2013
+ );
2014
+ return {
2015
+ success: true,
2016
+ data: { room, action: "pause", response: res }
2017
+ };
2018
+ }
2019
+ case "sys_sonos_volume": {
2020
+ const room = params.room;
2021
+ const level = Number(params.level);
2022
+ if (!room || isNaN(level))
2023
+ return { success: false, error: "Missing room or level" };
2024
+ const sonosUrl = getSonosApiUrl();
2025
+ if (!sonosUrl)
2026
+ return { success: false, error: "Sonos not configured." };
2027
+ const vol = Math.max(0, Math.min(100, level));
2028
+ const res = await sonosRequest(
2029
+ sonosUrl,
2030
+ `${encodeURIComponent(room)}/volume/${vol}`
2031
+ );
2032
+ return { success: true, data: { room, volume: vol, response: res } };
2033
+ }
2034
+ case "sys_sonos_play_uri": {
2035
+ const room = params.room;
2036
+ const uri = params.uri;
2037
+ if (!room || !uri)
2038
+ return { success: false, error: "Missing room or URI" };
2039
+ const sonosUrl = getSonosApiUrl();
2040
+ if (!sonosUrl)
2041
+ return { success: false, error: "Sonos not configured." };
2042
+ if (uri.startsWith("spotify:")) {
2043
+ const res2 = await sonosRequest(
2044
+ sonosUrl,
2045
+ `${encodeURIComponent(room)}/spotify/now/${encodeURIComponent(uri)}`
2046
+ );
2047
+ return {
2048
+ success: true,
2049
+ data: { room, uri, type: "spotify", response: res2 }
2050
+ };
1330
2051
  }
2052
+ const res = await sonosRequest(
2053
+ sonosUrl,
2054
+ `${encodeURIComponent(room)}/setavtransporturi/${encodeURIComponent(uri)}`
2055
+ );
2056
+ return { success: true, data: { room, uri, response: res } };
2057
+ }
2058
+ case "sys_sonos_rooms": {
2059
+ const sonosUrl = getSonosApiUrl();
2060
+ if (!sonosUrl)
2061
+ return { success: false, error: "Sonos not configured." };
2062
+ const res = await sonosRequest(sonosUrl, "zones");
2063
+ return { success: true, data: res };
2064
+ }
2065
+ case "sys_sonos_next": {
2066
+ const room = params.room;
2067
+ if (!room) return { success: false, error: "Missing room name" };
2068
+ const sonosUrl = getSonosApiUrl();
2069
+ if (!sonosUrl)
2070
+ return { success: false, error: "Sonos not configured." };
2071
+ const res = await sonosRequest(
2072
+ sonosUrl,
2073
+ `${encodeURIComponent(room)}/next`
2074
+ );
2075
+ return { success: true, data: { room, action: "next", response: res } };
2076
+ }
2077
+ case "sys_sonos_previous": {
2078
+ const room = params.room;
2079
+ if (!room) return { success: false, error: "Missing room name" };
2080
+ const sonosUrl = getSonosApiUrl();
2081
+ if (!sonosUrl)
2082
+ return { success: false, error: "Sonos not configured." };
2083
+ const res = await sonosRequest(
2084
+ sonosUrl,
2085
+ `${encodeURIComponent(room)}/previous`
2086
+ );
2087
+ return {
2088
+ success: true,
2089
+ data: { room, action: "previous", response: res }
2090
+ };
2091
+ }
2092
+ case "sys_sonos_now_playing": {
2093
+ const room = params.room;
2094
+ if (!room) return { success: false, error: "Missing room name" };
2095
+ const sonosUrl = getSonosApiUrl();
2096
+ if (!sonosUrl)
2097
+ return { success: false, error: "Sonos not configured." };
2098
+ const res = await sonosRequest(
2099
+ sonosUrl,
2100
+ `${encodeURIComponent(room)}/state`
2101
+ );
2102
+ return { success: true, data: { room, ...res } };
1331
2103
  }
1332
2104
  default:
1333
2105
  return { success: false, error: `Unknown command: ${command}` };
@@ -1336,6 +2108,130 @@ print(text)`;
1336
2108
  return { success: false, error: err.message };
1337
2109
  }
1338
2110
  }
2111
+ var HUE_CONFIG_FILE = join(HOME, ".pulso-hue-config.json");
2112
+ async function getHueConfig() {
2113
+ const ip = process.env.HUE_BRIDGE_IP;
2114
+ const user = process.env.HUE_USERNAME;
2115
+ if (ip && user) return { bridgeIp: ip, username: user };
2116
+ try {
2117
+ if (existsSync(HUE_CONFIG_FILE)) {
2118
+ const data = JSON.parse(readFileSync(HUE_CONFIG_FILE, "utf-8"));
2119
+ if (data.bridgeIp && data.username) return data;
2120
+ }
2121
+ } catch {
2122
+ }
2123
+ return null;
2124
+ }
2125
+ async function hueRequest(config, path, method, body) {
2126
+ const url = `http://${config.bridgeIp}/api/${config.username}/${path}`;
2127
+ const controller = new AbortController();
2128
+ const timeout = setTimeout(() => controller.abort(), 1e4);
2129
+ try {
2130
+ const opts = {
2131
+ method,
2132
+ signal: controller.signal,
2133
+ headers: { "Content-Type": "application/json" }
2134
+ };
2135
+ if (body && method !== "GET") opts.body = JSON.stringify(body);
2136
+ const res = await fetch(url, opts);
2137
+ clearTimeout(timeout);
2138
+ return await res.json();
2139
+ } catch (err) {
2140
+ clearTimeout(timeout);
2141
+ throw new Error(
2142
+ `Hue bridge unreachable at ${config.bridgeIp}: ${err.message}`
2143
+ );
2144
+ }
2145
+ }
2146
+ var hueLightCache = null;
2147
+ var hueLightCacheTs = 0;
2148
+ async function resolveHueLight(config, lightRef) {
2149
+ if (/^\d+$/.test(lightRef)) return lightRef;
2150
+ if (!hueLightCache || Date.now() - hueLightCacheTs > 3e5) {
2151
+ try {
2152
+ const lights = await hueRequest(config, "lights", "GET");
2153
+ hueLightCache = /* @__PURE__ */ new Map();
2154
+ for (const [id, light] of Object.entries(lights)) {
2155
+ hueLightCache.set(light.name.toLowerCase(), id);
2156
+ }
2157
+ hueLightCacheTs = Date.now();
2158
+ } catch {
2159
+ return null;
2160
+ }
2161
+ }
2162
+ return hueLightCache.get(lightRef.toLowerCase()) ?? null;
2163
+ }
2164
+ var CSS_COLORS_COMPANION = {
2165
+ red: [255, 0, 0],
2166
+ green: [0, 128, 0],
2167
+ blue: [0, 0, 255],
2168
+ yellow: [255, 255, 0],
2169
+ orange: [255, 165, 0],
2170
+ purple: [128, 0, 128],
2171
+ pink: [255, 192, 203],
2172
+ white: [255, 255, 255],
2173
+ cyan: [0, 255, 255],
2174
+ magenta: [255, 0, 255],
2175
+ lime: [0, 255, 0],
2176
+ teal: [0, 128, 128],
2177
+ coral: [255, 127, 80],
2178
+ salmon: [250, 128, 114],
2179
+ gold: [255, 215, 0],
2180
+ lavender: [230, 230, 250],
2181
+ turquoise: [64, 224, 208],
2182
+ warmwhite: [255, 200, 150],
2183
+ coolwhite: [200, 220, 255]
2184
+ };
2185
+ function parseColorCompanion(color) {
2186
+ if (color.startsWith("#")) {
2187
+ const hex = color.slice(1);
2188
+ if (hex.length === 6) {
2189
+ return [
2190
+ parseInt(hex.slice(0, 2), 16),
2191
+ parseInt(hex.slice(2, 4), 16),
2192
+ parseInt(hex.slice(4, 6), 16)
2193
+ ];
2194
+ }
2195
+ }
2196
+ return CSS_COLORS_COMPANION[color.toLowerCase()] ?? null;
2197
+ }
2198
+ function rgbToXyCompanion(r, g, b) {
2199
+ let rr = r / 255;
2200
+ let gg = g / 255;
2201
+ let bb = b / 255;
2202
+ rr = rr > 0.04045 ? Math.pow((rr + 0.055) / 1.055, 2.4) : rr / 12.92;
2203
+ gg = gg > 0.04045 ? Math.pow((gg + 0.055) / 1.055, 2.4) : gg / 12.92;
2204
+ bb = bb > 0.04045 ? Math.pow((bb + 0.055) / 1.055, 2.4) : bb / 12.92;
2205
+ const X = rr * 0.664511 + gg * 0.154324 + bb * 0.162028;
2206
+ const Y = rr * 0.283881 + gg * 0.668433 + bb * 0.047685;
2207
+ const Z = rr * 88e-6 + gg * 0.07231 + bb * 0.986039;
2208
+ const sum = X + Y + Z;
2209
+ if (sum === 0) return [0.3127, 0.329];
2210
+ return [X / sum, Y / sum];
2211
+ }
2212
+ function getSonosApiUrl() {
2213
+ return process.env.SONOS_API_URL || null;
2214
+ }
2215
+ async function sonosRequest(baseUrl, path) {
2216
+ const url = `${baseUrl.replace(/\/$/, "")}/${path}`;
2217
+ const controller = new AbortController();
2218
+ const timeout = setTimeout(() => controller.abort(), 1e4);
2219
+ try {
2220
+ const res = await fetch(url, { signal: controller.signal });
2221
+ clearTimeout(timeout);
2222
+ const text = await res.text();
2223
+ try {
2224
+ return JSON.parse(text);
2225
+ } catch {
2226
+ return { raw: text };
2227
+ }
2228
+ } catch (err) {
2229
+ clearTimeout(timeout);
2230
+ throw new Error(
2231
+ `Sonos API unreachable at ${baseUrl}: ${err.message}`
2232
+ );
2233
+ }
2234
+ }
1339
2235
  var ws = null;
1340
2236
  var reconnectTimer = null;
1341
2237
  var heartbeatTimer = null;
@@ -1348,12 +2244,16 @@ function connect() {
1348
2244
  ws.on("open", () => {
1349
2245
  reconnectAttempts = 0;
1350
2246
  console.log("\u2705 Connected to Pulso!");
1351
- console.log(`\u{1F5A5}\uFE0F Companion is active \u2014 ${ACCESS_LEVEL === "full" ? "full device access" : "sandboxed mode"}`);
2247
+ console.log(
2248
+ `\u{1F5A5}\uFE0F Companion is active \u2014 ${ACCESS_LEVEL === "full" ? "full device access" : "sandboxed mode"}`
2249
+ );
1352
2250
  console.log("");
1353
2251
  console.log(" Available capabilities:");
1354
2252
  console.log(" \u2022 Open apps & URLs");
1355
2253
  console.log(" \u2022 Control Spotify & media");
1356
- console.log(` \u2022 Read/write files ${ACCESS_LEVEL === "full" ? "(full device)" : "(Documents, Desktop, Downloads)"}`);
2254
+ console.log(
2255
+ ` \u2022 Read/write files ${ACCESS_LEVEL === "full" ? "(full device)" : "(Documents, Desktop, Downloads)"}`
2256
+ );
1357
2257
  console.log(" \u2022 Clipboard access");
1358
2258
  console.log(" \u2022 Screenshots");
1359
2259
  console.log(" \u2022 Text-to-speech");
@@ -1362,8 +2262,12 @@ function connect() {
1362
2262
  console.log(" \u2022 Terminal commands");
1363
2263
  console.log(" \u2022 System notifications");
1364
2264
  console.log(" \u2022 macOS Shortcuts");
2265
+ console.log(" \u2022 Smart Home: Philips Hue (if configured)");
2266
+ console.log(" \u2022 Smart Home: Sonos (if configured)");
1365
2267
  console.log("");
1366
- console.log(` Access: ${ACCESS_LEVEL === "full" ? "\u{1F513} Full (unrestricted)" : "\u{1F512} Sandboxed (safe dirs only)"}`);
2268
+ console.log(
2269
+ ` Access: ${ACCESS_LEVEL === "full" ? "\u{1F513} Full (unrestricted)" : "\u{1F512} Sandboxed (safe dirs only)"}`
2270
+ );
1367
2271
  console.log(" Waiting for commands from Pulso agent...");
1368
2272
  ws.send(JSON.stringify({ type: "extension_ready" }));
1369
2273
  if (heartbeatTimer) clearInterval(heartbeatTimer);
@@ -1384,10 +2288,16 @@ function connect() {
1384
2288
  return;
1385
2289
  }
1386
2290
  if (msg.id && msg.command) {
1387
- console.log(`
1388
- \u26A1 Command: ${msg.command}`, msg.params ? JSON.stringify(msg.params).slice(0, 200) : "");
2291
+ console.log(
2292
+ `
2293
+ \u26A1 Command: ${msg.command}`,
2294
+ msg.params ? JSON.stringify(msg.params).slice(0, 200) : ""
2295
+ );
1389
2296
  const result = await handleCommand(msg.command, msg.params ?? {});
1390
- console.log(` \u2192 ${result.success ? "\u2705" : "\u274C"}`, result.success ? JSON.stringify(result.data).slice(0, 200) : result.error);
2297
+ console.log(
2298
+ ` \u2192 ${result.success ? "\u2705" : "\u274C"}`,
2299
+ result.success ? JSON.stringify(result.data).slice(0, 200) : result.error
2300
+ );
1391
2301
  ws.send(JSON.stringify({ id: msg.id, result }));
1392
2302
  return;
1393
2303
  }
@@ -1405,9 +2315,15 @@ function connect() {
1405
2315
  heartbeatTimer = null;
1406
2316
  }
1407
2317
  if (reasonStr === "New connection from same user") {
1408
- console.log("\n\u26A0\uFE0F Another Pulso Companion instance is already connected.");
1409
- console.log(" This instance will exit. Only one companion per account is supported.");
1410
- console.log(" If this is unexpected, close other terminals running Pulso Companion.\n");
2318
+ console.log(
2319
+ "\n\u26A0\uFE0F Another Pulso Companion instance is already connected."
2320
+ );
2321
+ console.log(
2322
+ " This instance will exit. Only one companion per account is supported."
2323
+ );
2324
+ console.log(
2325
+ " If this is unexpected, close other terminals running Pulso Companion.\n"
2326
+ );
1411
2327
  process.exit(0);
1412
2328
  return;
1413
2329
  }
@@ -1420,29 +2336,209 @@ function connect() {
1420
2336
  function scheduleReconnect() {
1421
2337
  if (reconnectTimer) return;
1422
2338
  reconnectAttempts++;
1423
- const delay = Math.min(RECONNECT_DELAY * Math.pow(2, reconnectAttempts - 1), 6e4);
1424
- console.log(` Reconnecting in ${(delay / 1e3).toFixed(0)}s... (attempt ${reconnectAttempts})`);
2339
+ const delay = Math.min(
2340
+ RECONNECT_DELAY * Math.pow(2, reconnectAttempts - 1),
2341
+ 6e4
2342
+ );
2343
+ console.log(
2344
+ ` Reconnecting in ${(delay / 1e3).toFixed(0)}s... (attempt ${reconnectAttempts})`
2345
+ );
1425
2346
  reconnectTimer = setTimeout(() => {
1426
2347
  reconnectTimer = null;
1427
2348
  connect();
1428
2349
  }, delay);
1429
2350
  }
2351
+ var wakeWordActive = false;
2352
+ async function startWakeWordDetection() {
2353
+ if (!WAKE_WORD_ENABLED) return;
2354
+ if (!PICOVOICE_ACCESS_KEY) {
2355
+ console.log(" \u26A0\uFE0F Wake word enabled but no Picovoice key provided.");
2356
+ console.log(" Get a free key at https://console.picovoice.ai/");
2357
+ console.log(
2358
+ " Use: --picovoice-key <key> or PICOVOICE_ACCESS_KEY env var\n"
2359
+ );
2360
+ return;
2361
+ }
2362
+ try {
2363
+ const { Porcupine, BuiltinKeyword } = await import("@picovoice/porcupine-node");
2364
+ const { PvRecorder } = await import("@picovoice/pvrecorder-node");
2365
+ const customKeywordPath = join(HOME, ".pulso-wake-word.ppn");
2366
+ const useCustom = existsSync(customKeywordPath);
2367
+ let porcupine;
2368
+ if (useCustom) {
2369
+ porcupine = new Porcupine(
2370
+ PICOVOICE_ACCESS_KEY,
2371
+ [customKeywordPath],
2372
+ [0.5]
2373
+ // sensitivity
2374
+ );
2375
+ console.log(
2376
+ " \u{1F3A4} Wake word: custom model loaded from ~/.pulso-wake-word.ppn"
2377
+ );
2378
+ } else {
2379
+ porcupine = new Porcupine(
2380
+ PICOVOICE_ACCESS_KEY,
2381
+ [BuiltinKeyword.HEY_GOOGLE],
2382
+ // Placeholder — replace with custom "Hey Pulso"
2383
+ [0.5]
2384
+ );
2385
+ console.log(
2386
+ ' \u{1F3A4} Wake word: using "Hey Google" as placeholder (create custom "Hey Pulso" at console.picovoice.ai)'
2387
+ );
2388
+ }
2389
+ const frameLength = porcupine.frameLength;
2390
+ const sampleRate = porcupine.sampleRate;
2391
+ const devices = PvRecorder.getAvailableDevices();
2392
+ console.log(` \u{1F3A4} Audio devices: ${devices.length} found (using default)`);
2393
+ const recorder = new PvRecorder(frameLength, 0);
2394
+ recorder.start();
2395
+ wakeWordActive = true;
2396
+ console.log(" \u{1F3A4} Wake word detection ACTIVE \u2014 listening...\n");
2397
+ const listen = async () => {
2398
+ while (wakeWordActive) {
2399
+ try {
2400
+ const pcm = await recorder.read();
2401
+ const keywordIndex = porcupine.process(pcm);
2402
+ if (keywordIndex >= 0) {
2403
+ console.log("\n \u{1F5E3}\uFE0F Wake word detected!");
2404
+ exec("afplay /System/Library/Sounds/Tink.aiff");
2405
+ const audioChunks = [];
2406
+ let silenceFrames = 0;
2407
+ const MAX_SILENCE_FRAMES = Math.ceil(
2408
+ 1.5 * sampleRate / frameLength
2409
+ );
2410
+ const MAX_RECORD_FRAMES = Math.ceil(
2411
+ 10 * sampleRate / frameLength
2412
+ );
2413
+ let totalFrames = 0;
2414
+ console.log(" \u{1F3A4} Listening for command...");
2415
+ while (totalFrames < MAX_RECORD_FRAMES) {
2416
+ const frame = await recorder.read();
2417
+ audioChunks.push(new Int16Array(frame));
2418
+ totalFrames++;
2419
+ let sum = 0;
2420
+ for (let i = 0; i < frame.length; i++) {
2421
+ sum += frame[i] * frame[i];
2422
+ }
2423
+ const rms = Math.sqrt(sum / frame.length);
2424
+ if (rms < 200) {
2425
+ silenceFrames++;
2426
+ if (silenceFrames >= MAX_SILENCE_FRAMES && totalFrames > 5) {
2427
+ break;
2428
+ }
2429
+ } else {
2430
+ silenceFrames = 0;
2431
+ }
2432
+ }
2433
+ console.log(
2434
+ ` \u{1F3A4} Captured ${(totalFrames * frameLength / sampleRate).toFixed(1)}s of audio`
2435
+ );
2436
+ const totalSamples = audioChunks.reduce((s, c) => s + c.length, 0);
2437
+ const wavBuffer = createWavBuffer(
2438
+ audioChunks,
2439
+ sampleRate,
2440
+ totalSamples
2441
+ );
2442
+ if (ws && ws.readyState === WebSocket.OPEN) {
2443
+ const base64Audio = Buffer.from(wavBuffer).toString("base64");
2444
+ ws.send(
2445
+ JSON.stringify({
2446
+ type: "wake_word_audio",
2447
+ audio: base64Audio,
2448
+ format: "wav",
2449
+ sampleRate,
2450
+ durationMs: Math.round(
2451
+ totalFrames * frameLength / sampleRate * 1e3
2452
+ )
2453
+ })
2454
+ );
2455
+ console.log(" \u{1F4E4} Audio sent to server for processing");
2456
+ exec("afplay /System/Library/Sounds/Pop.aiff");
2457
+ } else {
2458
+ console.log(" \u26A0\uFE0F Not connected to server \u2014 audio discarded");
2459
+ }
2460
+ }
2461
+ } catch (err) {
2462
+ if (wakeWordActive) {
2463
+ console.error(" \u274C Wake word error:", err.message);
2464
+ await new Promise((r) => setTimeout(r, 1e3));
2465
+ }
2466
+ }
2467
+ }
2468
+ recorder.stop();
2469
+ recorder.release();
2470
+ porcupine.release();
2471
+ };
2472
+ listen().catch((err) => {
2473
+ console.error(" \u274C Wake word listener crashed:", err.message);
2474
+ wakeWordActive = false;
2475
+ });
2476
+ } catch (err) {
2477
+ const msg = err.message;
2478
+ if (msg.includes("Cannot find module") || msg.includes("MODULE_NOT_FOUND")) {
2479
+ console.log(" \u26A0\uFE0F Wake word packages not installed. Run:");
2480
+ console.log(
2481
+ " npm install @picovoice/porcupine-node @picovoice/pvrecorder-node\n"
2482
+ );
2483
+ } else {
2484
+ console.error(" \u274C Wake word init failed:", msg);
2485
+ }
2486
+ }
2487
+ }
2488
+ function createWavBuffer(chunks, sampleRate, totalSamples) {
2489
+ const bytesPerSample = 2;
2490
+ const numChannels = 1;
2491
+ const dataSize = totalSamples * bytesPerSample;
2492
+ const buffer = new ArrayBuffer(44 + dataSize);
2493
+ const view = new DataView(buffer);
2494
+ writeString(view, 0, "RIFF");
2495
+ view.setUint32(4, 36 + dataSize, true);
2496
+ writeString(view, 8, "WAVE");
2497
+ writeString(view, 12, "fmt ");
2498
+ view.setUint32(16, 16, true);
2499
+ view.setUint16(20, 1, true);
2500
+ view.setUint16(22, numChannels, true);
2501
+ view.setUint32(24, sampleRate, true);
2502
+ view.setUint32(28, sampleRate * numChannels * bytesPerSample, true);
2503
+ view.setUint16(32, numChannels * bytesPerSample, true);
2504
+ view.setUint16(34, bytesPerSample * 8, true);
2505
+ writeString(view, 36, "data");
2506
+ view.setUint32(40, dataSize, true);
2507
+ let offset = 44;
2508
+ for (const chunk of chunks) {
2509
+ for (let i = 0; i < chunk.length; i++) {
2510
+ view.setInt16(offset, chunk[i], true);
2511
+ offset += 2;
2512
+ }
2513
+ }
2514
+ return buffer;
2515
+ }
2516
+ function writeString(view, offset, str) {
2517
+ for (let i = 0; i < str.length; i++) {
2518
+ view.setUint8(offset + i, str.charCodeAt(i));
2519
+ }
2520
+ }
1430
2521
  console.log("");
1431
2522
  console.log(" \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557");
1432
- console.log(" \u2551 \u{1FAC0} Pulso Mac Companion v0.2.2 \u2551");
2523
+ console.log(" \u2551 \u{1FAC0} Pulso Mac Companion v0.2.3 \u2551");
1433
2524
  console.log(" \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D");
1434
2525
  console.log("");
1435
2526
  setupPermissions().then(() => {
1436
2527
  connect();
2528
+ if (WAKE_WORD_ENABLED) {
2529
+ startWakeWordDetection();
2530
+ }
1437
2531
  }).catch(() => {
1438
2532
  connect();
1439
2533
  });
1440
2534
  process.on("SIGINT", () => {
1441
2535
  console.log("\n\u{1F44B} Shutting down Pulso Companion...");
2536
+ wakeWordActive = false;
1442
2537
  ws?.close(1e3, "User shutdown");
1443
2538
  process.exit(0);
1444
2539
  });
1445
2540
  process.on("SIGTERM", () => {
2541
+ wakeWordActive = false;
1446
2542
  ws?.close(1e3, "Process terminated");
1447
2543
  process.exit(0);
1448
2544
  });