@pulso/companion 0.2.1 → 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 +1278 -157
  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,7 +248,27 @@ 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);
269
+ if (trackIds.length === 0) {
270
+ trackIds = await searchDDGForSpotifyTracks(query);
271
+ }
198
272
  if (trackIds.length === 0) {
199
273
  trackIds = await searchStartpageForSpotifyTracks(query);
200
274
  }
@@ -210,6 +284,72 @@ async function spotifySearch(query) {
210
284
  });
211
285
  return result;
212
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
+ }
213
353
  async function searchBraveForSpotifyTracks(query) {
214
354
  try {
215
355
  const searchQuery = `spotify track ${query} site:open.spotify.com`;
@@ -224,7 +364,33 @@ async function searchBraveForSpotifyTracks(query) {
224
364
  if (!res.ok) return [];
225
365
  const html = await res.text();
226
366
  const trackIds = /* @__PURE__ */ new Set();
227
- 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
+ )) {
370
+ trackIds.add(m[1]);
371
+ }
372
+ return [...trackIds];
373
+ } catch {
374
+ return [];
375
+ }
376
+ }
377
+ async function searchDDGForSpotifyTracks(query) {
378
+ try {
379
+ const searchQuery = `spotify track ${query} site:open.spotify.com`;
380
+ const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(searchQuery)}`;
381
+ const controller = new AbortController();
382
+ const timeout = setTimeout(() => controller.abort(), 8e3);
383
+ const res = await fetch(url, {
384
+ headers: { "User-Agent": UA, Accept: "text/html" },
385
+ signal: controller.signal
386
+ });
387
+ clearTimeout(timeout);
388
+ if (!res.ok) return [];
389
+ const html = await res.text();
390
+ const trackIds = /* @__PURE__ */ new Set();
391
+ for (const m of html.matchAll(
392
+ /open\.spotify\.com(?:\/intl-[a-z]+)?\/track\/([a-zA-Z0-9]{22})/g
393
+ )) {
228
394
  trackIds.add(m[1]);
229
395
  }
230
396
  return [...trackIds];
@@ -250,7 +416,9 @@ async function searchStartpageForSpotifyTracks(query) {
250
416
  if (!res.ok) return [];
251
417
  const html = await res.text();
252
418
  const trackIds = /* @__PURE__ */ new Set();
253
- 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
+ )) {
254
422
  trackIds.add(m[1]);
255
423
  }
256
424
  return [...trackIds];
@@ -269,7 +437,9 @@ async function getTrackMetadata(trackId) {
269
437
  clearTimeout(timeout);
270
438
  if (!res.ok) return null;
271
439
  const html = await res.text();
272
- const scriptMatch = html.match(/<script[^>]*>(\{"props":\{"pageProps".*?\})<\/script>/s);
440
+ const scriptMatch = html.match(
441
+ /<script[^>]*>(\{"props":\{"pageProps".*?\})<\/script>/s
442
+ );
273
443
  if (!scriptMatch) return null;
274
444
  const data = JSON.parse(scriptMatch[1]);
275
445
  const entity = data?.props?.pageProps?.state?.data?.entity;
@@ -321,7 +491,8 @@ async function handleCommand(command, params) {
321
491
  case "sys_notification": {
322
492
  const title = params.title;
323
493
  const message = params.message;
324
- if (!title || !message) return { success: false, error: "Missing title or message" };
494
+ if (!title || !message)
495
+ return { success: false, error: "Missing title or message" };
325
496
  await runAppleScript(
326
497
  `display notification "${message.replace(/"/g, '\\"')}" with title "${title.replace(/"/g, '\\"')}"`
327
498
  );
@@ -350,26 +521,48 @@ async function handleCommand(command, params) {
350
521
  await runAppleScript('tell application "Spotify" to next track');
351
522
  return { success: true, data: { action: "next" } };
352
523
  case "previous":
353
- await runAppleScript('tell application "Spotify" to previous track');
524
+ await runAppleScript(
525
+ 'tell application "Spotify" to previous track'
526
+ );
354
527
  return { success: true, data: { action: "previous" } };
355
528
  case "now_playing": {
356
- const name = await runAppleScript('tell application "Spotify" to name of current track');
357
- const artist = await runAppleScript('tell application "Spotify" to artist of current track');
358
- const album = await runAppleScript('tell application "Spotify" to album of current track');
359
- const state = await runAppleScript('tell application "Spotify" to player state as string');
360
- 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
+ };
361
545
  }
362
546
  case "search_play": {
363
547
  const query = params.query;
364
- if (!query) return { success: false, error: "Missing search query" };
548
+ if (!query)
549
+ return { success: false, error: "Missing search query" };
365
550
  const result = await spotifySearch(query);
366
551
  if (result) {
367
- await runAppleScript(`tell application "Spotify" to play track "${result.uri}"`);
552
+ await runAppleScript(
553
+ `tell application "Spotify" to play track "${result.uri}"`
554
+ );
368
555
  await new Promise((r) => setTimeout(r, 1500));
369
556
  try {
370
- const track = await runAppleScript('tell application "Spotify" to name of current track');
371
- const artist = await runAppleScript('tell application "Spotify" to artist of current track');
372
- 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
+ );
373
566
  return {
374
567
  success: true,
375
568
  data: {
@@ -380,10 +573,19 @@ async function handleCommand(command, params) {
380
573
  }
381
574
  };
382
575
  } catch {
383
- 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
+ };
384
584
  }
385
585
  }
386
- await runShell(`open "spotify:search:${encodeURIComponent(query)}"`);
586
+ await runShell(
587
+ `open "spotify:search:${encodeURIComponent(query)}"`
588
+ );
387
589
  return {
388
590
  success: true,
389
591
  data: {
@@ -395,34 +597,61 @@ async function handleCommand(command, params) {
395
597
  }
396
598
  case "volume": {
397
599
  const level = params.level;
398
- if (level === void 0 || level < 0 || level > 100) return { success: false, error: "Volume must be 0-100" };
399
- 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
+ );
400
605
  return { success: true, data: { volume: level } };
401
606
  }
402
607
  case "shuffle": {
403
608
  const enabled = params.enabled;
404
- 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
+ );
405
612
  return { success: true, data: { shuffling: enabled } };
406
613
  }
407
614
  case "repeat": {
408
615
  const mode = params.mode;
409
- if (!mode) return { success: false, error: "Missing mode (off, context, track)" };
410
- 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
+ };
411
626
  const val = repeatMap[mode];
412
- if (!val) return { success: false, error: "Mode must be: off, context, or track" };
413
- 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
+ );
414
635
  return { success: true, data: { repeating: mode } };
415
636
  }
416
637
  default:
417
- return { success: false, error: `Unknown Spotify action: ${action}` };
638
+ return {
639
+ success: false,
640
+ error: `Unknown Spotify action: ${action}`
641
+ };
418
642
  }
419
643
  }
420
644
  case "sys_file_read": {
421
645
  const path = params.path;
422
646
  if (!path) return { success: false, error: "Missing file path" };
423
647
  const fullPath = safePath(path);
424
- if (!fullPath) return { success: false, error: `Access denied. Only files in ${SAFE_DIRS.join(", ")} are allowed.` };
425
- 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}` };
426
655
  const content = readFileSync(fullPath, "utf-8");
427
656
  return {
428
657
  success: true,
@@ -437,9 +666,14 @@ async function handleCommand(command, params) {
437
666
  case "sys_file_write": {
438
667
  const path = params.path;
439
668
  const content = params.content;
440
- if (!path || !content) return { success: false, error: "Missing path or content" };
669
+ if (!path || !content)
670
+ return { success: false, error: "Missing path or content" };
441
671
  const fullPath = safePath(path);
442
- 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
+ };
443
677
  writeFileSync(fullPath, content, "utf-8");
444
678
  return { success: true, data: { path, written: content.length } };
445
679
  }
@@ -448,20 +682,33 @@ async function handleCommand(command, params) {
448
682
  const pngPath = `/tmp/pulso-ss-${ts}.png`;
449
683
  const jpgPath = `/tmp/pulso-ss-${ts}.jpg`;
450
684
  await runShell(`screencapture -x ${pngPath}`, 15e3);
451
- if (!existsSync(pngPath)) return { success: false, error: "Screenshot failed" };
685
+ if (!existsSync(pngPath))
686
+ return { success: false, error: "Screenshot failed" };
452
687
  try {
453
- 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
+ );
454
692
  } catch {
455
693
  const buf2 = readFileSync(pngPath);
456
694
  exec(`rm -f ${pngPath}`);
457
- 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
+ };
458
703
  }
459
704
  const buf = readFileSync(jpgPath);
460
705
  const base64 = buf.toString("base64");
461
706
  exec(`rm -f ${pngPath} ${jpgPath}`);
462
707
  let screenSize = "unknown";
463
708
  try {
464
- 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
+ );
465
712
  } catch {
466
713
  }
467
714
  return {
@@ -480,7 +727,8 @@ async function handleCommand(command, params) {
480
727
  const x = Number(params.x);
481
728
  const y = Number(params.y);
482
729
  const button = params.button || "left";
483
- 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" };
484
732
  const mouseType = button === "right" ? "rightMouseDown" : "leftMouseDown";
485
733
  const mouseTypeUp = button === "right" ? "rightMouseUp" : "leftMouseUp";
486
734
  const mouseButton = button === "right" ? ".right" : ".left";
@@ -499,7 +747,8 @@ print("clicked")`;
499
747
  case "sys_mouse_double_click": {
500
748
  const x = Number(params.x);
501
749
  const y = Number(params.y);
502
- 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" };
503
752
  const swift = `
504
753
  import Cocoa
505
754
  let p = CGPoint(x: ${x}, y: ${y})
@@ -527,7 +776,8 @@ print("double-clicked")`;
527
776
  const y = Number(params.y) || 0;
528
777
  const scrollY = Number(params.scrollY) || 0;
529
778
  const scrollX = Number(params.scrollX) || 0;
530
- if (!scrollY && !scrollX) return { success: false, error: "Missing scrollY or scrollX" };
779
+ if (!scrollY && !scrollX)
780
+ return { success: false, error: "Missing scrollY or scrollX" };
531
781
  const swift = `
532
782
  import Cocoa
533
783
  let p = CGPoint(x: ${x}, y: ${y})
@@ -538,12 +788,17 @@ let scroll = CGEvent(scrollWheelEvent2Source: nil, units: .pixel, wheelCount: 2,
538
788
  scroll.post(tap: .cghidEventTap)
539
789
  print("scrolled")`;
540
790
  await runSwift(swift);
541
- return { success: true, data: { scrolled: { x, y, scrollY, scrollX } } };
791
+ return {
792
+ success: true,
793
+ data: { scrolled: { x, y, scrollY, scrollX } }
794
+ };
542
795
  }
543
796
  case "sys_keyboard_type": {
544
797
  const text = params.text;
545
798
  if (!text) return { success: false, error: "Missing text" };
546
- 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
+ );
547
802
  return { success: true, data: { typed: text.slice(0, 100) } };
548
803
  }
549
804
  case "sys_key_press": {
@@ -594,19 +849,27 @@ print("scrolled")`;
594
849
  const keyCode = keyCodeMap[key.toLowerCase()];
595
850
  if (keyCode !== void 0) {
596
851
  const using = modStr.length > 0 ? ` using {${modStr.join(", ")}}` : "";
597
- 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
+ );
598
855
  } else if (key.length === 1) {
599
856
  const using = modStr.length > 0 ? ` using {${modStr.join(", ")}}` : "";
600
- await runAppleScript(`tell application "System Events" to keystroke "${key}"${using}`);
857
+ await runAppleScript(
858
+ `tell application "System Events" to keystroke "${key}"${using}`
859
+ );
601
860
  } else {
602
- 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
+ };
603
865
  }
604
866
  return { success: true, data: { pressed: key, modifiers } };
605
867
  }
606
868
  case "sys_mouse_move": {
607
869
  const x = Number(params.x);
608
870
  const y = Number(params.y);
609
- 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" };
610
873
  const swift = `
611
874
  import Cocoa
612
875
  let p = CGPoint(x: ${x}, y: ${y})
@@ -621,7 +884,8 @@ print("moved")`;
621
884
  const fromY = Number(params.fromY);
622
885
  const toX = Number(params.toX);
623
886
  const toY = Number(params.toY);
624
- 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" };
625
889
  const swift = `
626
890
  import Cocoa
627
891
  let from = CGPoint(x: ${fromX}, y: ${fromY})
@@ -641,7 +905,12 @@ let u = CGEvent(mouseEventSource: nil, mouseType: .leftMouseUp, mouseCursorPosit
641
905
  u.post(tap: .cghidEventTap)
642
906
  print("dragged")`;
643
907
  await runSwift(swift);
644
- 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
+ };
645
914
  }
646
915
  case "sys_get_cursor_position": {
647
916
  const swift = `
@@ -659,7 +928,13 @@ print("\\(x),\\(y)")`;
659
928
  }
660
929
  // ── Browser Automation ─────────────────────────────────
661
930
  case "sys_browser_list_tabs": {
662
- 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
+ ];
663
938
  const allTabs = [];
664
939
  for (const browser of browsers) {
665
940
  try {
@@ -683,7 +958,12 @@ print("\\(x),\\(y)")`;
683
958
  const tabStr = rest.join("~~~");
684
959
  const pairs = tabStr.split("|||").filter(Boolean);
685
960
  for (let i = 0; i < pairs.length - 1; i += 2) {
686
- 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
+ });
687
967
  }
688
968
  } else {
689
969
  const tabData = await runAppleScript(`
@@ -701,13 +981,21 @@ print("\\(x),\\(y)")`;
701
981
  const tabStr = rest.join("~~~");
702
982
  const pairs = tabStr.split("|||").filter(Boolean);
703
983
  for (let i = 0; i < pairs.length - 1; i += 2) {
704
- 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
+ });
705
990
  }
706
991
  }
707
992
  } catch {
708
993
  }
709
994
  }
710
- return { success: true, data: { tabs: allTabs, count: allTabs.length } };
995
+ return {
996
+ success: true,
997
+ data: { tabs: allTabs, count: allTabs.length }
998
+ };
711
999
  }
712
1000
  case "sys_browser_navigate": {
713
1001
  const url = params.url;
@@ -735,7 +1023,10 @@ print("\\(x),\\(y)")`;
735
1023
  }
736
1024
  return { success: true, data: { navigated: url, browser } };
737
1025
  } catch (err) {
738
- return { success: false, error: `Failed to navigate: ${err.message}` };
1026
+ return {
1027
+ success: false,
1028
+ error: `Failed to navigate: ${err.message}`
1029
+ };
739
1030
  }
740
1031
  }
741
1032
  case "sys_browser_new_tab": {
@@ -758,7 +1049,10 @@ print("\\(x),\\(y)")`;
758
1049
  }
759
1050
  return { success: true, data: { opened: url, browser } };
760
1051
  } catch (err) {
761
- return { success: false, error: `Failed to open tab: ${err.message}` };
1052
+ return {
1053
+ success: false,
1054
+ error: `Failed to open tab: ${err.message}`
1055
+ };
762
1056
  }
763
1057
  }
764
1058
  case "sys_browser_read_page": {
@@ -784,32 +1078,53 @@ print("\\(x),\\(y)")`;
784
1078
  }
785
1079
  } catch {
786
1080
  try {
787
- const savedClipboard = await runShell("pbpaste 2>/dev/null || true");
788
- 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
+ );
789
1087
  await new Promise((r) => setTimeout(r, 300));
790
- 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
+ );
791
1091
  await new Promise((r) => setTimeout(r, 200));
792
- 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
+ );
793
1095
  await new Promise((r) => setTimeout(r, 300));
794
1096
  content = await runShell("pbpaste");
795
1097
  method = "clipboard";
796
- await runAppleScript('tell application "System Events" to key code 53');
1098
+ await runAppleScript(
1099
+ 'tell application "System Events" to key code 53'
1100
+ );
797
1101
  if (savedClipboard && savedClipboard !== content) {
798
1102
  execSync(`echo ${JSON.stringify(savedClipboard)} | pbcopy`);
799
1103
  }
800
1104
  } catch (clipErr) {
801
- 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
+ };
802
1109
  }
803
1110
  }
804
1111
  let pageUrl = "";
805
1112
  let pageTitle = "";
806
1113
  try {
807
1114
  if (browser === "Safari") {
808
- pageUrl = await runAppleScript('tell application "Safari" to return URL of front document');
809
- 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
+ );
810
1121
  } else {
811
- pageUrl = await runAppleScript(`tell application "${browser.replace(/"/g, '\\"')}" to return URL of active tab of front window`);
812
- 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
+ );
813
1128
  }
814
1129
  } catch {
815
1130
  }
@@ -832,13 +1147,20 @@ print("\\(x),\\(y)")`;
832
1147
  try {
833
1148
  let result;
834
1149
  if (browser === "Safari") {
835
- 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
+ );
836
1153
  } else {
837
- 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
+ );
838
1157
  }
839
1158
  return { success: true, data: { result: result.slice(0, 5e3) } };
840
1159
  } catch (err) {
841
- 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
+ };
842
1164
  }
843
1165
  }
844
1166
  // ── Email ──────────────────────────────────────────────
@@ -847,11 +1169,20 @@ print("\\(x),\\(y)")`;
847
1169
  const subject = params.subject;
848
1170
  const body = params.body;
849
1171
  const method = params.method || "mail";
850
- 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" };
851
1174
  if (method === "gmail") {
852
1175
  const gmailUrl = `https://mail.google.com/mail/u/0/?view=cm&fs=1&to=${encodeURIComponent(to)}&su=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
853
1176
  await runShell(`open "${gmailUrl}"`);
854
- 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
+ };
855
1186
  }
856
1187
  try {
857
1188
  await runAppleScript(`
@@ -862,11 +1193,22 @@ print("\\(x),\\(y)")`;
862
1193
  end tell
863
1194
  send newMessage
864
1195
  end tell`);
865
- return { success: true, data: { method: "mail", to, subject, sent: true } };
1196
+ return {
1197
+ success: true,
1198
+ data: { method: "mail", to, subject, sent: true }
1199
+ };
866
1200
  } catch (err) {
867
1201
  const gmailUrl = `https://mail.google.com/mail/u/0/?view=cm&fs=1&to=${encodeURIComponent(to)}&su=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
868
1202
  await runShell(`open "${gmailUrl}"`);
869
- 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
+ };
870
1212
  }
871
1213
  }
872
1214
  case "sys_run_shortcut": {
@@ -874,21 +1216,44 @@ print("\\(x),\\(y)")`;
874
1216
  const input = params.input;
875
1217
  if (!name) return { success: false, error: "Missing shortcut name" };
876
1218
  const inputFlag = input ? `--input-type text --input "${input.replace(/"/g, '\\"')}"` : "";
877
- const result = await runShell(`shortcuts run "${name.replace(/"/g, '\\"')}" ${inputFlag}`, 3e4);
878
- 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
+ };
879
1227
  }
880
1228
  // ── Shell Execution ─────────────────────────────────────
881
1229
  case "sys_shell": {
882
1230
  const cmd = params.command;
883
1231
  if (!cmd) return { success: false, error: "Missing command" };
884
- const blocked = ["rm -rf /", "mkfs", "dd if=", "> /dev/", ":(){ :|:& };:"];
885
- 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" };
886
1241
  const timeout = Number(params.timeout) || 15e3;
887
1242
  try {
888
1243
  const output = await runShell(cmd, timeout);
889
- 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
+ };
890
1252
  } catch (err) {
891
- 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
+ };
892
1257
  }
893
1258
  }
894
1259
  // ── Calendar ────────────────────────────────────────────
@@ -912,9 +1277,17 @@ print("\\(x),\\(y)")`;
912
1277
  const raw = await runAppleScript(script);
913
1278
  const events = raw.split("\n").filter(Boolean).map((line) => {
914
1279
  const [cal, summary, start, end] = line.split(" | ");
915
- 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
+ };
916
1286
  });
917
- return { success: true, data: { events, count: events.length, daysAhead: days } };
1287
+ return {
1288
+ success: true,
1289
+ data: { events, count: events.length, daysAhead: days }
1290
+ };
918
1291
  }
919
1292
  case "sys_calendar_create": {
920
1293
  const summary = params.summary || params.title;
@@ -922,7 +1295,8 @@ print("\\(x),\\(y)")`;
922
1295
  const endStr = params.end;
923
1296
  const calendar = params.calendar || "";
924
1297
  const notes = params.notes || "";
925
- 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" };
926
1300
  const calTarget = calendar ? `calendar "${calendar.replace(/"/g, '\\"')}"` : "default calendar";
927
1301
  const endPart = endStr ? `set end date of newEvent to date "${endStr}"` : "";
928
1302
  const notesPart = notes ? `set description of newEvent to "${notes.replace(/"/g, '\\"')}"` : "";
@@ -934,7 +1308,10 @@ print("\\(x),\\(y)")`;
934
1308
  ${notesPart}
935
1309
  end tell
936
1310
  end tell`);
937
- 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
+ };
938
1315
  }
939
1316
  // ── Reminders ───────────────────────────────────────────
940
1317
  case "sys_reminder_list": {
@@ -958,18 +1335,33 @@ print("\\(x),\\(y)")`;
958
1335
  const raw = await runAppleScript(script);
959
1336
  const reminders = raw.split("\n").filter(Boolean).map((line) => {
960
1337
  const parts = line.split(" | ");
961
- 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() };
962
1343
  });
963
- return { success: true, data: { reminders, count: reminders.length } };
1344
+ return {
1345
+ success: true,
1346
+ data: { reminders, count: reminders.length }
1347
+ };
964
1348
  } catch {
965
- 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
+ };
966
1357
  }
967
1358
  }
968
1359
  case "sys_reminder_create": {
969
1360
  const reminderName = params.name;
970
1361
  const dueDate = params.due;
971
1362
  const listName2 = params.list || "Reminders";
972
- if (!reminderName) return { success: false, error: "Missing reminder name" };
1363
+ if (!reminderName)
1364
+ return { success: false, error: "Missing reminder name" };
973
1365
  const duePart = dueDate ? `, due date:date "${dueDate}"` : "";
974
1366
  await runAppleScript(`
975
1367
  tell application "Reminders"
@@ -977,20 +1369,34 @@ print("\\(x),\\(y)")`;
977
1369
  make new reminder with properties {name:"${reminderName.replace(/"/g, '\\"')}"${duePart}}
978
1370
  end tell
979
1371
  end tell`);
980
- 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
+ };
981
1380
  }
982
1381
  // ── iMessage ────────────────────────────────────────────
983
1382
  case "sys_imessage_send": {
984
1383
  const to2 = params.to;
985
1384
  const msg = params.message;
986
- 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
+ };
987
1390
  await runAppleScript(`
988
1391
  tell application "Messages"
989
1392
  set targetService to 1st account whose service type = iMessage
990
1393
  set targetBuddy to participant "${to2.replace(/"/g, '\\"')}" of targetService
991
1394
  send "${msg.replace(/"/g, '\\"')}" to targetBuddy
992
1395
  end tell`);
993
- 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
+ };
994
1400
  }
995
1401
  // ── System Info ─────────────────────────────────────────
996
1402
  case "sys_system_info": {
@@ -1001,7 +1407,9 @@ print("\\(x),\\(y)")`;
1001
1407
  info.battery = "N/A";
1002
1408
  }
1003
1409
  try {
1004
- 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();
1005
1413
  } catch {
1006
1414
  info.wifi = "N/A";
1007
1415
  }
@@ -1016,7 +1424,9 @@ print("\\(x),\\(y)")`;
1016
1424
  info.ip_public = "N/A";
1017
1425
  }
1018
1426
  try {
1019
- 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();
1020
1430
  } catch {
1021
1431
  info.disk = "N/A";
1022
1432
  }
@@ -1055,7 +1465,9 @@ print("\\(x),\\(y)")`;
1055
1465
  await runAppleScript(`set volume output volume ${vol}`);
1056
1466
  return { success: true, data: { volume: vol } };
1057
1467
  }
1058
- const raw2 = await runAppleScript("output volume of (get volume settings)");
1468
+ const raw2 = await runAppleScript(
1469
+ "output volume of (get volume settings)"
1470
+ );
1059
1471
  return { success: true, data: { volume: Number(raw2) || 0 } };
1060
1472
  }
1061
1473
  // ── Brightness ──────────────────────────────────────────
@@ -1063,14 +1475,27 @@ print("\\(x),\\(y)")`;
1063
1475
  const level2 = params.level;
1064
1476
  if (level2 !== void 0) {
1065
1477
  const br = Math.max(0, Math.min(1, Number(level2)));
1066
- 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
+ );
1067
1481
  return { success: true, data: { brightness: br } };
1068
1482
  }
1069
1483
  try {
1070
- const raw3 = await runShell("brightness -l 2>/dev/null | grep brightness | head -1 | awk '{print $NF}'");
1071
- 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
+ };
1072
1491
  } catch {
1073
- 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
+ };
1074
1499
  }
1075
1500
  }
1076
1501
  // ── Do Not Disturb ──────────────────────────────────────
@@ -1078,49 +1503,79 @@ print("\\(x),\\(y)")`;
1078
1503
  const enabled = params.enabled;
1079
1504
  if (enabled !== void 0) {
1080
1505
  try {
1081
- 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"'`);
1082
- 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
+ };
1083
1513
  } catch {
1084
- 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
+ };
1085
1521
  }
1086
1522
  }
1087
- 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
+ };
1088
1527
  }
1089
1528
  // ── File Management ─────────────────────────────────────
1090
1529
  case "sys_file_list": {
1091
1530
  const dirPath = params.path || "Desktop";
1092
1531
  const fullDir = safePath(dirPath);
1093
- if (!fullDir) return { success: false, error: `Access denied: ${dirPath}` };
1094
- 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}` };
1095
1536
  const entries = readdirSync(fullDir).map((name) => {
1096
1537
  try {
1097
1538
  const st = statSync(join(fullDir, name));
1098
- 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
+ };
1099
1545
  } catch {
1100
1546
  return { name, type: "unknown", size: 0, modified: "" };
1101
1547
  }
1102
1548
  });
1103
- return { success: true, data: { path: dirPath, entries, count: entries.length } };
1549
+ return {
1550
+ success: true,
1551
+ data: { path: dirPath, entries, count: entries.length }
1552
+ };
1104
1553
  }
1105
1554
  case "sys_file_move": {
1106
1555
  const src = params.from;
1107
1556
  const dst = params.to;
1108
- if (!src || !dst) return { success: false, error: "Missing from/to paths" };
1557
+ if (!src || !dst)
1558
+ return { success: false, error: "Missing from/to paths" };
1109
1559
  const fullSrc = safePath(src);
1110
1560
  const fullDst = safePath(dst);
1111
- if (!fullSrc || !fullDst) return { success: false, error: "Access denied" };
1112
- 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}` };
1113
1565
  renameSync(fullSrc, fullDst);
1114
1566
  return { success: true, data: { moved: src, to: dst } };
1115
1567
  }
1116
1568
  case "sys_file_copy": {
1117
1569
  const src2 = params.from;
1118
1570
  const dst2 = params.to;
1119
- if (!src2 || !dst2) return { success: false, error: "Missing from/to paths" };
1571
+ if (!src2 || !dst2)
1572
+ return { success: false, error: "Missing from/to paths" };
1120
1573
  const fullSrc2 = safePath(src2);
1121
1574
  const fullDst2 = safePath(dst2);
1122
- if (!fullSrc2 || !fullDst2) return { success: false, error: "Access denied" };
1123
- 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}` };
1124
1579
  copyFileSync(fullSrc2, fullDst2);
1125
1580
  return { success: true, data: { copied: src2, to: dst2 } };
1126
1581
  }
@@ -1129,16 +1584,23 @@ print("\\(x),\\(y)")`;
1129
1584
  if (!target) return { success: false, error: "Missing path" };
1130
1585
  const fullTarget = safePath(target);
1131
1586
  if (!fullTarget) return { success: false, error: "Access denied" };
1132
- if (!existsSync(fullTarget)) return { success: false, error: `Not found: ${target}` };
1133
- await runShell(`osascript -e 'tell application "Finder" to delete POSIX file "${fullTarget}"'`);
1134
- 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
+ };
1135
1596
  }
1136
1597
  case "sys_file_info": {
1137
1598
  const fPath = params.path;
1138
1599
  if (!fPath) return { success: false, error: "Missing path" };
1139
1600
  const fullF = safePath(fPath);
1140
1601
  if (!fullF) return { success: false, error: "Access denied" };
1141
- if (!existsSync(fullF)) return { success: false, error: `Not found: ${fPath}` };
1602
+ if (!existsSync(fullF))
1603
+ return { success: false, error: `Not found: ${fPath}` };
1142
1604
  const st = statSync(fullF);
1143
1605
  return {
1144
1606
  success: true,
@@ -1161,9 +1623,15 @@ print("\\(x),\\(y)")`;
1161
1623
  if (!dlUrl) return { success: false, error: "Missing URL" };
1162
1624
  const fullDl = safePath(dlDest);
1163
1625
  if (!fullDl) return { success: false, error: "Access denied" };
1164
- await runShell(`curl -sL -o "${fullDl}" "${dlUrl.replace(/"/g, '\\"')}"`, 6e4);
1626
+ await runShell(
1627
+ `curl -sL -o "${fullDl}" "${dlUrl.replace(/"/g, '\\"')}"`,
1628
+ 6e4
1629
+ );
1165
1630
  const size = existsSync(fullDl) ? statSync(fullDl).size : 0;
1166
- return { success: true, data: { downloaded: dlUrl, saved: dlDest, size } };
1631
+ return {
1632
+ success: true,
1633
+ data: { downloaded: dlUrl, saved: dlDest, size }
1634
+ };
1167
1635
  }
1168
1636
  // ── Window Management ───────────────────────────────────
1169
1637
  case "sys_window_list": {
@@ -1180,7 +1648,12 @@ print("\\(x),\\(y)")`;
1180
1648
  end tell`);
1181
1649
  const windows = raw4.split("\n").filter(Boolean).map((line) => {
1182
1650
  const [app, title, pos, sz] = line.split(" | ");
1183
- 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
+ };
1184
1657
  });
1185
1658
  return { success: true, data: { windows, count: windows.length } };
1186
1659
  }
@@ -1203,7 +1676,11 @@ print("\\(x),\\(y)")`;
1203
1676
  if (!app2) return { success: false, error: "Missing app name" };
1204
1677
  const posPart = x !== void 0 && y !== void 0 ? `set position of window 1 to {${x}, ${y}}` : "";
1205
1678
  const sizePart = w !== void 0 && h !== void 0 ? `set size of window 1 to {${w}, ${h}}` : "";
1206
- 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
+ };
1207
1684
  await runAppleScript(`
1208
1685
  tell application "System Events"
1209
1686
  tell process "${app2.replace(/"/g, '\\"')}"
@@ -1211,7 +1688,14 @@ print("\\(x),\\(y)")`;
1211
1688
  ${sizePart}
1212
1689
  end tell
1213
1690
  end tell`);
1214
- 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
+ };
1215
1699
  }
1216
1700
  // ── Apple Notes ─────────────────────────────────────────
1217
1701
  case "sys_notes_list": {
@@ -1227,7 +1711,11 @@ print("\\(x),\\(y)")`;
1227
1711
  end tell`);
1228
1712
  const notes = raw5.split("\n").filter(Boolean).map((line) => {
1229
1713
  const [id, name, date] = line.split(" | ");
1230
- 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
+ };
1231
1719
  });
1232
1720
  return { success: true, data: { notes, count: notes.length } };
1233
1721
  }
@@ -1267,9 +1755,16 @@ print("\\(x),\\(y)")`;
1267
1755
  end tell`);
1268
1756
  const contacts = raw6.split("\n").filter(Boolean).map((line) => {
1269
1757
  const [name, email, phone] = line.split(" | ");
1270
- 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
+ };
1271
1763
  });
1272
- return { success: true, data: { contacts, count: contacts.length, query: query2 } };
1764
+ return {
1765
+ success: true,
1766
+ data: { contacts, count: contacts.length, query: query2 }
1767
+ };
1273
1768
  }
1274
1769
  // ── OCR (Vision framework) ──────────────────────────────
1275
1770
  case "sys_ocr": {
@@ -1277,7 +1772,8 @@ print("\\(x),\\(y)")`;
1277
1772
  if (!imgPath) return { success: false, error: "Missing image path" };
1278
1773
  const fullImg = imgPath.startsWith("/tmp/") ? imgPath : safePath(imgPath);
1279
1774
  if (!fullImg) return { success: false, error: "Access denied" };
1280
- if (!existsSync(fullImg)) return { success: false, error: `Image not found: ${imgPath}` };
1775
+ if (!existsSync(fullImg))
1776
+ return { success: false, error: `Image not found: ${imgPath}` };
1281
1777
  const swiftOcr = `
1282
1778
  import Foundation
1283
1779
  import Vision
@@ -1299,10 +1795,311 @@ let text = results.compactMap { $0.topCandidates(1).first?.string }.joined(separ
1299
1795
  print(text)`;
1300
1796
  try {
1301
1797
  const ocrText = await runSwift(swiftOcr, 3e4);
1302
- 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
+ };
1303
1806
  } catch (err) {
1304
- 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
+ }
1305
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
+ };
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 } };
1306
2103
  }
1307
2104
  default:
1308
2105
  return { success: false, error: `Unknown command: ${command}` };
@@ -1311,6 +2108,130 @@ print(text)`;
1311
2108
  return { success: false, error: err.message };
1312
2109
  }
1313
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
+ }
1314
2235
  var ws = null;
1315
2236
  var reconnectTimer = null;
1316
2237
  var heartbeatTimer = null;
@@ -1323,12 +2244,16 @@ function connect() {
1323
2244
  ws.on("open", () => {
1324
2245
  reconnectAttempts = 0;
1325
2246
  console.log("\u2705 Connected to Pulso!");
1326
- 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
+ );
1327
2250
  console.log("");
1328
2251
  console.log(" Available capabilities:");
1329
2252
  console.log(" \u2022 Open apps & URLs");
1330
2253
  console.log(" \u2022 Control Spotify & media");
1331
- 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
+ );
1332
2257
  console.log(" \u2022 Clipboard access");
1333
2258
  console.log(" \u2022 Screenshots");
1334
2259
  console.log(" \u2022 Text-to-speech");
@@ -1337,8 +2262,12 @@ function connect() {
1337
2262
  console.log(" \u2022 Terminal commands");
1338
2263
  console.log(" \u2022 System notifications");
1339
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)");
1340
2267
  console.log("");
1341
- 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
+ );
1342
2271
  console.log(" Waiting for commands from Pulso agent...");
1343
2272
  ws.send(JSON.stringify({ type: "extension_ready" }));
1344
2273
  if (heartbeatTimer) clearInterval(heartbeatTimer);
@@ -1359,10 +2288,16 @@ function connect() {
1359
2288
  return;
1360
2289
  }
1361
2290
  if (msg.id && msg.command) {
1362
- console.log(`
1363
- \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
+ );
1364
2296
  const result = await handleCommand(msg.command, msg.params ?? {});
1365
- 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
+ );
1366
2301
  ws.send(JSON.stringify({ id: msg.id, result }));
1367
2302
  return;
1368
2303
  }
@@ -1380,9 +2315,15 @@ function connect() {
1380
2315
  heartbeatTimer = null;
1381
2316
  }
1382
2317
  if (reasonStr === "New connection from same user") {
1383
- console.log("\n\u26A0\uFE0F Another Pulso Companion instance is already connected.");
1384
- console.log(" This instance will exit. Only one companion per account is supported.");
1385
- 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
+ );
1386
2327
  process.exit(0);
1387
2328
  return;
1388
2329
  }
@@ -1395,29 +2336,209 @@ function connect() {
1395
2336
  function scheduleReconnect() {
1396
2337
  if (reconnectTimer) return;
1397
2338
  reconnectAttempts++;
1398
- const delay = Math.min(RECONNECT_DELAY * Math.pow(2, reconnectAttempts - 1), 6e4);
1399
- 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
+ );
1400
2346
  reconnectTimer = setTimeout(() => {
1401
2347
  reconnectTimer = null;
1402
2348
  connect();
1403
2349
  }, delay);
1404
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
+ }
1405
2521
  console.log("");
1406
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");
1407
- console.log(" \u2551 \u{1FAC0} Pulso Mac Companion v0.2.1 \u2551");
2523
+ console.log(" \u2551 \u{1FAC0} Pulso Mac Companion v0.2.3 \u2551");
1408
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");
1409
2525
  console.log("");
1410
2526
  setupPermissions().then(() => {
1411
2527
  connect();
2528
+ if (WAKE_WORD_ENABLED) {
2529
+ startWakeWordDetection();
2530
+ }
1412
2531
  }).catch(() => {
1413
2532
  connect();
1414
2533
  });
1415
2534
  process.on("SIGINT", () => {
1416
2535
  console.log("\n\u{1F44B} Shutting down Pulso Companion...");
2536
+ wakeWordActive = false;
1417
2537
  ws?.close(1e3, "User shutdown");
1418
2538
  process.exit(0);
1419
2539
  });
1420
2540
  process.on("SIGTERM", () => {
2541
+ wakeWordActive = false;
1421
2542
  ws?.close(1e3, "Process terminated");
1422
2543
  process.exit(0);
1423
2544
  });