@pulso/companion 0.2.2 → 0.2.4

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 +1341 -179
  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
  }
@@ -472,21 +681,48 @@ async function handleCommand(command, params) {
472
681
  const ts = Date.now();
473
682
  const pngPath = `/tmp/pulso-ss-${ts}.png`;
474
683
  const jpgPath = `/tmp/pulso-ss-${ts}.jpg`;
475
- await runShell(`screencapture -x ${pngPath}`, 15e3);
476
- if (!existsSync(pngPath)) return { success: false, error: "Screenshot failed" };
477
684
  try {
478
- await runShell(`sips --resampleWidth 1280 --setProperty format jpeg --setProperty formatOptions 60 ${pngPath} --out ${jpgPath}`, 1e4);
685
+ await runShell(`screencapture -x ${pngPath}`, 15e3);
686
+ } catch (ssErr) {
687
+ const msg = ssErr.message || "";
688
+ if (msg.includes("could not create image") || msg.includes("display")) {
689
+ return {
690
+ success: false,
691
+ error: "Screen Recording permission required. Go to System Settings \u2192 Privacy & Security \u2192 Screen Recording \u2192 enable Terminal (or your terminal app)."
692
+ };
693
+ }
694
+ return { success: false, error: `Screenshot failed: ${msg}` };
695
+ }
696
+ if (!existsSync(pngPath))
697
+ return {
698
+ success: false,
699
+ error: "Screenshot failed \u2014 Screen Recording permission may be needed. Go to System Settings \u2192 Privacy & Security \u2192 Screen Recording."
700
+ };
701
+ try {
702
+ await runShell(
703
+ `sips --resampleWidth 1280 --setProperty format jpeg --setProperty formatOptions 60 ${pngPath} --out ${jpgPath}`,
704
+ 1e4
705
+ );
479
706
  } catch {
480
707
  const buf2 = readFileSync(pngPath);
481
708
  exec(`rm -f ${pngPath}`);
482
- return { success: true, data: { image: `data:image/png;base64,${buf2.toString("base64")}`, format: "png", note: "Full screen screenshot" } };
709
+ return {
710
+ success: true,
711
+ data: {
712
+ image: `data:image/png;base64,${buf2.toString("base64")}`,
713
+ format: "png",
714
+ note: "Full screen screenshot"
715
+ }
716
+ };
483
717
  }
484
718
  const buf = readFileSync(jpgPath);
485
719
  const base64 = buf.toString("base64");
486
720
  exec(`rm -f ${pngPath} ${jpgPath}`);
487
721
  let screenSize = "unknown";
488
722
  try {
489
- screenSize = await runShell(`system_profiler SPDisplaysDataType 2>/dev/null | grep Resolution | head -1 | sed 's/.*: //'`);
723
+ screenSize = await runShell(
724
+ `system_profiler SPDisplaysDataType 2>/dev/null | grep Resolution | head -1 | sed 's/.*: //'`
725
+ );
490
726
  } catch {
491
727
  }
492
728
  return {
@@ -505,7 +741,8 @@ async function handleCommand(command, params) {
505
741
  const x = Number(params.x);
506
742
  const y = Number(params.y);
507
743
  const button = params.button || "left";
508
- if (isNaN(x) || isNaN(y)) return { success: false, error: "Missing x, y coordinates" };
744
+ if (isNaN(x) || isNaN(y))
745
+ return { success: false, error: "Missing x, y coordinates" };
509
746
  const mouseType = button === "right" ? "rightMouseDown" : "leftMouseDown";
510
747
  const mouseTypeUp = button === "right" ? "rightMouseUp" : "leftMouseUp";
511
748
  const mouseButton = button === "right" ? ".right" : ".left";
@@ -524,7 +761,8 @@ print("clicked")`;
524
761
  case "sys_mouse_double_click": {
525
762
  const x = Number(params.x);
526
763
  const y = Number(params.y);
527
- if (isNaN(x) || isNaN(y)) return { success: false, error: "Missing x, y coordinates" };
764
+ if (isNaN(x) || isNaN(y))
765
+ return { success: false, error: "Missing x, y coordinates" };
528
766
  const swift = `
529
767
  import Cocoa
530
768
  let p = CGPoint(x: ${x}, y: ${y})
@@ -552,7 +790,8 @@ print("double-clicked")`;
552
790
  const y = Number(params.y) || 0;
553
791
  const scrollY = Number(params.scrollY) || 0;
554
792
  const scrollX = Number(params.scrollX) || 0;
555
- if (!scrollY && !scrollX) return { success: false, error: "Missing scrollY or scrollX" };
793
+ if (!scrollY && !scrollX)
794
+ return { success: false, error: "Missing scrollY or scrollX" };
556
795
  const swift = `
557
796
  import Cocoa
558
797
  let p = CGPoint(x: ${x}, y: ${y})
@@ -563,12 +802,17 @@ let scroll = CGEvent(scrollWheelEvent2Source: nil, units: .pixel, wheelCount: 2,
563
802
  scroll.post(tap: .cghidEventTap)
564
803
  print("scrolled")`;
565
804
  await runSwift(swift);
566
- return { success: true, data: { scrolled: { x, y, scrollY, scrollX } } };
805
+ return {
806
+ success: true,
807
+ data: { scrolled: { x, y, scrollY, scrollX } }
808
+ };
567
809
  }
568
810
  case "sys_keyboard_type": {
569
811
  const text = params.text;
570
812
  if (!text) return { success: false, error: "Missing text" };
571
- await runAppleScript(`tell application "System Events" to keystroke "${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`);
813
+ await runAppleScript(
814
+ `tell application "System Events" to keystroke "${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
815
+ );
572
816
  return { success: true, data: { typed: text.slice(0, 100) } };
573
817
  }
574
818
  case "sys_key_press": {
@@ -619,19 +863,27 @@ print("scrolled")`;
619
863
  const keyCode = keyCodeMap[key.toLowerCase()];
620
864
  if (keyCode !== void 0) {
621
865
  const using = modStr.length > 0 ? ` using {${modStr.join(", ")}}` : "";
622
- await runAppleScript(`tell application "System Events" to key code ${keyCode}${using}`);
866
+ await runAppleScript(
867
+ `tell application "System Events" to key code ${keyCode}${using}`
868
+ );
623
869
  } else if (key.length === 1) {
624
870
  const using = modStr.length > 0 ? ` using {${modStr.join(", ")}}` : "";
625
- await runAppleScript(`tell application "System Events" to keystroke "${key}"${using}`);
871
+ await runAppleScript(
872
+ `tell application "System Events" to keystroke "${key}"${using}`
873
+ );
626
874
  } 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` };
875
+ return {
876
+ success: false,
877
+ error: `Unknown key: ${key}. Use single characters or: enter, tab, escape, delete, space, up, down, left, right, f1-f12, home, end, pageup, pagedown`
878
+ };
628
879
  }
629
880
  return { success: true, data: { pressed: key, modifiers } };
630
881
  }
631
882
  case "sys_mouse_move": {
632
883
  const x = Number(params.x);
633
884
  const y = Number(params.y);
634
- if (isNaN(x) || isNaN(y)) return { success: false, error: "Missing x, y coordinates" };
885
+ if (isNaN(x) || isNaN(y))
886
+ return { success: false, error: "Missing x, y coordinates" };
635
887
  const swift = `
636
888
  import Cocoa
637
889
  let p = CGPoint(x: ${x}, y: ${y})
@@ -646,7 +898,8 @@ print("moved")`;
646
898
  const fromY = Number(params.fromY);
647
899
  const toX = Number(params.toX);
648
900
  const toY = Number(params.toY);
649
- if ([fromX, fromY, toX, toY].some(isNaN)) return { success: false, error: "Missing fromX, fromY, toX, toY" };
901
+ if ([fromX, fromY, toX, toY].some(isNaN))
902
+ return { success: false, error: "Missing fromX, fromY, toX, toY" };
650
903
  const swift = `
651
904
  import Cocoa
652
905
  let from = CGPoint(x: ${fromX}, y: ${fromY})
@@ -666,7 +919,12 @@ let u = CGEvent(mouseEventSource: nil, mouseType: .leftMouseUp, mouseCursorPosit
666
919
  u.post(tap: .cghidEventTap)
667
920
  print("dragged")`;
668
921
  await runSwift(swift);
669
- return { success: true, data: { dragged: { from: { x: fromX, y: fromY }, to: { x: toX, y: toY } } } };
922
+ return {
923
+ success: true,
924
+ data: {
925
+ dragged: { from: { x: fromX, y: fromY }, to: { x: toX, y: toY } }
926
+ }
927
+ };
670
928
  }
671
929
  case "sys_get_cursor_position": {
672
930
  const swift = `
@@ -684,7 +942,13 @@ print("\\(x),\\(y)")`;
684
942
  }
685
943
  // ── Browser Automation ─────────────────────────────────
686
944
  case "sys_browser_list_tabs": {
687
- const browsers = ["Google Chrome", "Safari", "Arc", "Firefox", "Microsoft Edge"];
945
+ const browsers = [
946
+ "Google Chrome",
947
+ "Safari",
948
+ "Arc",
949
+ "Firefox",
950
+ "Microsoft Edge"
951
+ ];
688
952
  const allTabs = [];
689
953
  for (const browser of browsers) {
690
954
  try {
@@ -708,7 +972,12 @@ print("\\(x),\\(y)")`;
708
972
  const tabStr = rest.join("~~~");
709
973
  const pairs = tabStr.split("|||").filter(Boolean);
710
974
  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() });
975
+ allTabs.push({
976
+ browser: "Safari",
977
+ title: pairs[i],
978
+ url: pairs[i + 1],
979
+ active: pairs[i + 1] === activeURL.trim()
980
+ });
712
981
  }
713
982
  } else {
714
983
  const tabData = await runAppleScript(`
@@ -726,13 +995,21 @@ print("\\(x),\\(y)")`;
726
995
  const tabStr = rest.join("~~~");
727
996
  const pairs = tabStr.split("|||").filter(Boolean);
728
997
  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() });
998
+ allTabs.push({
999
+ browser,
1000
+ title: pairs[i],
1001
+ url: pairs[i + 1],
1002
+ active: pairs[i + 1] === activeURL.trim()
1003
+ });
730
1004
  }
731
1005
  }
732
1006
  } catch {
733
1007
  }
734
1008
  }
735
- return { success: true, data: { tabs: allTabs, count: allTabs.length } };
1009
+ return {
1010
+ success: true,
1011
+ data: { tabs: allTabs, count: allTabs.length }
1012
+ };
736
1013
  }
737
1014
  case "sys_browser_navigate": {
738
1015
  const url = params.url;
@@ -760,7 +1037,10 @@ print("\\(x),\\(y)")`;
760
1037
  }
761
1038
  return { success: true, data: { navigated: url, browser } };
762
1039
  } catch (err) {
763
- return { success: false, error: `Failed to navigate: ${err.message}` };
1040
+ return {
1041
+ success: false,
1042
+ error: `Failed to navigate: ${err.message}`
1043
+ };
764
1044
  }
765
1045
  }
766
1046
  case "sys_browser_new_tab": {
@@ -783,7 +1063,10 @@ print("\\(x),\\(y)")`;
783
1063
  }
784
1064
  return { success: true, data: { opened: url, browser } };
785
1065
  } catch (err) {
786
- return { success: false, error: `Failed to open tab: ${err.message}` };
1066
+ return {
1067
+ success: false,
1068
+ error: `Failed to open tab: ${err.message}`
1069
+ };
787
1070
  }
788
1071
  }
789
1072
  case "sys_browser_read_page": {
@@ -809,32 +1092,53 @@ print("\\(x),\\(y)")`;
809
1092
  }
810
1093
  } catch {
811
1094
  try {
812
- const savedClipboard = await runShell("pbpaste 2>/dev/null || true");
813
- await runAppleScript(`tell application "${browser.replace(/"/g, '\\"')}" to activate`);
1095
+ const savedClipboard = await runShell(
1096
+ "pbpaste 2>/dev/null || true"
1097
+ );
1098
+ await runAppleScript(
1099
+ `tell application "${browser.replace(/"/g, '\\"')}" to activate`
1100
+ );
814
1101
  await new Promise((r) => setTimeout(r, 300));
815
- await runAppleScript('tell application "System Events" to keystroke "a" using command down');
1102
+ await runAppleScript(
1103
+ 'tell application "System Events" to keystroke "a" using command down'
1104
+ );
816
1105
  await new Promise((r) => setTimeout(r, 200));
817
- await runAppleScript('tell application "System Events" to keystroke "c" using command down');
1106
+ await runAppleScript(
1107
+ 'tell application "System Events" to keystroke "c" using command down'
1108
+ );
818
1109
  await new Promise((r) => setTimeout(r, 300));
819
1110
  content = await runShell("pbpaste");
820
1111
  method = "clipboard";
821
- await runAppleScript('tell application "System Events" to key code 53');
1112
+ await runAppleScript(
1113
+ 'tell application "System Events" to key code 53'
1114
+ );
822
1115
  if (savedClipboard && savedClipboard !== content) {
823
1116
  execSync(`echo ${JSON.stringify(savedClipboard)} | pbcopy`);
824
1117
  }
825
1118
  } 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}` };
1119
+ return {
1120
+ success: false,
1121
+ error: `Could not read page. Enable 'Allow JavaScript from Apple Events' in ${browser} or grant Accessibility permission. Error: ${clipErr.message}`
1122
+ };
827
1123
  }
828
1124
  }
829
1125
  let pageUrl = "";
830
1126
  let pageTitle = "";
831
1127
  try {
832
1128
  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');
1129
+ pageUrl = await runAppleScript(
1130
+ 'tell application "Safari" to return URL of front document'
1131
+ );
1132
+ pageTitle = await runAppleScript(
1133
+ 'tell application "Safari" to return name of front document'
1134
+ );
835
1135
  } 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`);
1136
+ pageUrl = await runAppleScript(
1137
+ `tell application "${browser.replace(/"/g, '\\"')}" to return URL of active tab of front window`
1138
+ );
1139
+ pageTitle = await runAppleScript(
1140
+ `tell application "${browser.replace(/"/g, '\\"')}" to return title of active tab of front window`
1141
+ );
838
1142
  }
839
1143
  } catch {
840
1144
  }
@@ -857,13 +1161,20 @@ print("\\(x),\\(y)")`;
857
1161
  try {
858
1162
  let result;
859
1163
  if (browser === "Safari") {
860
- result = await runAppleScript(`tell application "Safari" to do JavaScript ${JSON.stringify(js)} in front document`);
1164
+ result = await runAppleScript(
1165
+ `tell application "Safari" to do JavaScript ${JSON.stringify(js)} in front document`
1166
+ );
861
1167
  } else {
862
- result = await runAppleScript(`tell application "${browser.replace(/"/g, '\\"')}" to execute javascript ${JSON.stringify(js)} in active tab of front window`);
1168
+ result = await runAppleScript(
1169
+ `tell application "${browser.replace(/"/g, '\\"')}" to execute javascript ${JSON.stringify(js)} in active tab of front window`
1170
+ );
863
1171
  }
864
1172
  return { success: true, data: { result: result.slice(0, 5e3) } };
865
1173
  } catch (err) {
866
- return { success: false, error: `JS execution failed (enable 'Allow JavaScript from Apple Events' in browser): ${err.message}` };
1174
+ return {
1175
+ success: false,
1176
+ error: `JS execution failed (enable 'Allow JavaScript from Apple Events' in browser): ${err.message}`
1177
+ };
867
1178
  }
868
1179
  }
869
1180
  // ── Email ──────────────────────────────────────────────
@@ -872,11 +1183,20 @@ print("\\(x),\\(y)")`;
872
1183
  const subject = params.subject;
873
1184
  const body = params.body;
874
1185
  const method = params.method || "mail";
875
- if (!to || !subject || !body) return { success: false, error: "Missing to, subject, or body" };
1186
+ if (!to || !subject || !body)
1187
+ return { success: false, error: "Missing to, subject, or body" };
876
1188
  if (method === "gmail") {
877
1189
  const gmailUrl = `https://mail.google.com/mail/u/0/?view=cm&fs=1&to=${encodeURIComponent(to)}&su=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
878
1190
  await runShell(`open "${gmailUrl}"`);
879
- return { success: true, data: { method: "gmail", to, subject, note: "Gmail compose opened. User needs to click Send." } };
1191
+ return {
1192
+ success: true,
1193
+ data: {
1194
+ method: "gmail",
1195
+ to,
1196
+ subject,
1197
+ note: "Gmail compose opened. User needs to click Send."
1198
+ }
1199
+ };
880
1200
  }
881
1201
  try {
882
1202
  await runAppleScript(`
@@ -887,11 +1207,22 @@ print("\\(x),\\(y)")`;
887
1207
  end tell
888
1208
  send newMessage
889
1209
  end tell`);
890
- return { success: true, data: { method: "mail", to, subject, sent: true } };
1210
+ return {
1211
+ success: true,
1212
+ data: { method: "mail", to, subject, sent: true }
1213
+ };
891
1214
  } catch (err) {
892
1215
  const gmailUrl = `https://mail.google.com/mail/u/0/?view=cm&fs=1&to=${encodeURIComponent(to)}&su=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
893
1216
  await runShell(`open "${gmailUrl}"`);
894
- return { success: true, data: { method: "gmail_fallback", to, subject, note: `Mail.app failed (${err.message}). Gmail compose opened instead.` } };
1217
+ return {
1218
+ success: true,
1219
+ data: {
1220
+ method: "gmail_fallback",
1221
+ to,
1222
+ subject,
1223
+ note: `Mail.app failed (${err.message}). Gmail compose opened instead.`
1224
+ }
1225
+ };
895
1226
  }
896
1227
  }
897
1228
  case "sys_run_shortcut": {
@@ -899,29 +1230,54 @@ print("\\(x),\\(y)")`;
899
1230
  const input = params.input;
900
1231
  if (!name) return { success: false, error: "Missing shortcut name" };
901
1232
  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" } };
1233
+ const result = await runShell(
1234
+ `shortcuts run "${name.replace(/"/g, '\\"')}" ${inputFlag}`,
1235
+ 3e4
1236
+ );
1237
+ return {
1238
+ success: true,
1239
+ data: { shortcut: name, output: result || "Shortcut executed" }
1240
+ };
904
1241
  }
905
1242
  // ── Shell Execution ─────────────────────────────────────
906
1243
  case "sys_shell": {
907
1244
  const cmd = params.command;
908
1245
  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" };
1246
+ const blocked = [
1247
+ "rm -rf /",
1248
+ "mkfs",
1249
+ "dd if=",
1250
+ "> /dev/",
1251
+ ":(){ :|:& };:"
1252
+ ];
1253
+ if (blocked.some((b) => cmd.includes(b)))
1254
+ return { success: false, error: "Command blocked for safety" };
911
1255
  const timeout = Number(params.timeout) || 15e3;
912
1256
  try {
913
1257
  const output = await runShell(cmd, timeout);
914
- return { success: true, data: { command: cmd, output: output.slice(0, 1e4), truncated: output.length > 1e4 } };
1258
+ return {
1259
+ success: true,
1260
+ data: {
1261
+ command: cmd,
1262
+ output: output.slice(0, 1e4),
1263
+ truncated: output.length > 1e4
1264
+ }
1265
+ };
915
1266
  } catch (err) {
916
- return { success: false, error: `Shell error: ${err.message.slice(0, 2e3)}` };
1267
+ return {
1268
+ success: false,
1269
+ error: `Shell error: ${err.message.slice(0, 2e3)}`
1270
+ };
917
1271
  }
918
1272
  }
919
1273
  // ── Calendar ────────────────────────────────────────────
920
1274
  case "sys_calendar_list": {
921
1275
  const days = Number(params.days) || 7;
922
- const startDate = /* @__PURE__ */ new Date();
923
- const endDate = new Date(Date.now() + days * 864e5);
924
- const fmt = (d) => `date "${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}"`;
1276
+ try {
1277
+ await runAppleScript(`tell application "Calendar" to launch`);
1278
+ await new Promise((r) => setTimeout(r, 500));
1279
+ } catch {
1280
+ }
925
1281
  const script = `
926
1282
  set output to ""
927
1283
  tell application "Calendar"
@@ -937,9 +1293,17 @@ print("\\(x),\\(y)")`;
937
1293
  const raw = await runAppleScript(script);
938
1294
  const events = raw.split("\n").filter(Boolean).map((line) => {
939
1295
  const [cal, summary, start, end] = line.split(" | ");
940
- return { calendar: cal?.trim(), summary: summary?.trim(), start: start?.trim(), end: end?.trim() };
1296
+ return {
1297
+ calendar: cal?.trim(),
1298
+ summary: summary?.trim(),
1299
+ start: start?.trim(),
1300
+ end: end?.trim()
1301
+ };
941
1302
  });
942
- return { success: true, data: { events, count: events.length, daysAhead: days } };
1303
+ return {
1304
+ success: true,
1305
+ data: { events, count: events.length, daysAhead: days }
1306
+ };
943
1307
  }
944
1308
  case "sys_calendar_create": {
945
1309
  const summary = params.summary || params.title;
@@ -947,19 +1311,50 @@ print("\\(x),\\(y)")`;
947
1311
  const endStr = params.end;
948
1312
  const calendar = params.calendar || "";
949
1313
  const notes = params.notes || "";
950
- if (!summary || !startStr) return { success: false, error: "Missing summary or start time" };
1314
+ if (!summary || !startStr)
1315
+ return { success: false, error: "Missing summary or start time" };
1316
+ const parseDate = (iso) => {
1317
+ const d = new Date(iso);
1318
+ if (isNaN(d.getTime())) return null;
1319
+ return { y: d.getFullYear(), mo: d.getMonth() + 1, d: d.getDate(), h: d.getHours(), mi: d.getMinutes() };
1320
+ };
1321
+ const buildDateScript = (varName, iso) => {
1322
+ const p = parseDate(iso);
1323
+ if (!p) return "";
1324
+ return `set ${varName} to current date
1325
+ set year of ${varName} to ${p.y}
1326
+ set month of ${varName} to ${p.mo}
1327
+ set day of ${varName} to ${p.d}
1328
+ set hours of ${varName} to ${p.h}
1329
+ set minutes of ${varName} to ${p.mi}
1330
+ set seconds of ${varName} to 0`;
1331
+ };
1332
+ const startDateScript = buildDateScript("startD", startStr);
1333
+ if (!startDateScript)
1334
+ return { success: false, error: `Invalid start date: ${startStr}` };
1335
+ const endDateScript = endStr ? buildDateScript("endD", endStr) : "";
951
1336
  const calTarget = calendar ? `calendar "${calendar.replace(/"/g, '\\"')}"` : "default calendar";
952
- const endPart = endStr ? `set end date of newEvent to date "${endStr}"` : "";
953
- const notesPart = notes ? `set description of newEvent to "${notes.replace(/"/g, '\\"')}"` : "";
1337
+ const notesPart = notes ? `
1338
+ set description of newEvent to "${notes.replace(/"/g, '\\"')}"` : "";
1339
+ const endPart = endDateScript ? `
1340
+ set end date of newEvent to endD` : "";
1341
+ try {
1342
+ await runAppleScript(`tell application "Calendar" to launch`);
1343
+ await new Promise((r) => setTimeout(r, 500));
1344
+ } catch {
1345
+ }
954
1346
  await runAppleScript(`
955
- tell application "Calendar"
956
- tell ${calTarget}
957
- set newEvent to make new event with properties {summary:"${summary.replace(/"/g, '\\"')}", start date:date "${startStr}"}
958
- ${endPart}
959
- ${notesPart}
960
- end tell
961
- end tell`);
962
- return { success: true, data: { created: summary, start: startStr, end: endStr || "auto" } };
1347
+ ${startDateScript}
1348
+ ${endDateScript}
1349
+ tell application "Calendar"
1350
+ tell ${calTarget}
1351
+ set newEvent to make new event with properties {summary:"${summary.replace(/"/g, '\\"')}", start date:startD}${endPart}${notesPart}
1352
+ end tell
1353
+ end tell`);
1354
+ return {
1355
+ success: true,
1356
+ data: { created: summary, start: startStr, end: endStr || "1 hour" }
1357
+ };
963
1358
  }
964
1359
  // ── Reminders ───────────────────────────────────────────
965
1360
  case "sys_reminder_list": {
@@ -983,39 +1378,91 @@ print("\\(x),\\(y)")`;
983
1378
  const raw = await runAppleScript(script);
984
1379
  const reminders = raw.split("\n").filter(Boolean).map((line) => {
985
1380
  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() };
1381
+ return parts.length === 3 ? {
1382
+ list: parts[0]?.trim(),
1383
+ name: parts[1]?.trim(),
1384
+ due: parts[2]?.trim()
1385
+ } : { name: parts[0]?.trim(), due: parts[1]?.trim() };
987
1386
  });
988
- return { success: true, data: { reminders, count: reminders.length } };
1387
+ return {
1388
+ success: true,
1389
+ data: { reminders, count: reminders.length }
1390
+ };
989
1391
  } catch {
990
- return { success: true, data: { reminders: [], count: 0, note: "No reminders or Reminders app not accessible" } };
1392
+ return {
1393
+ success: true,
1394
+ data: {
1395
+ reminders: [],
1396
+ count: 0,
1397
+ note: "No reminders or Reminders app not accessible"
1398
+ }
1399
+ };
991
1400
  }
992
1401
  }
993
1402
  case "sys_reminder_create": {
994
1403
  const reminderName = params.name;
995
1404
  const dueDate = params.due;
996
- const listName2 = params.list || "Reminders";
997
- if (!reminderName) return { success: false, error: "Missing reminder name" };
998
- const duePart = dueDate ? `, due date:date "${dueDate}"` : "";
999
- await runAppleScript(`
1000
- tell application "Reminders"
1001
- tell list "${listName2.replace(/"/g, '\\"')}"
1002
- make new reminder with properties {name:"${reminderName.replace(/"/g, '\\"')}"${duePart}}
1003
- end tell
1004
- end tell`);
1005
- return { success: true, data: { created: reminderName, due: dueDate || "none", list: listName2 } };
1405
+ let listName2 = params.list || "";
1406
+ if (!reminderName)
1407
+ return { success: false, error: "Missing reminder name" };
1408
+ if (!listName2) {
1409
+ try {
1410
+ listName2 = (await runAppleScript(
1411
+ `tell application "Reminders" to return name of default list`
1412
+ )).trim();
1413
+ } catch {
1414
+ listName2 = "Reminders";
1415
+ }
1416
+ }
1417
+ let duePart = "";
1418
+ if (dueDate) {
1419
+ const d = new Date(dueDate);
1420
+ if (!isNaN(d.getTime())) {
1421
+ duePart = `
1422
+ set dueD to current date
1423
+ set year of dueD to ${d.getFullYear()}
1424
+ set month of dueD to ${d.getMonth() + 1}
1425
+ set day of dueD to ${d.getDate()}
1426
+ set hours of dueD to ${d.getHours()}
1427
+ set minutes of dueD to ${d.getMinutes()}
1428
+ set seconds of dueD to 0`;
1429
+ }
1430
+ }
1431
+ const dueProperty = duePart ? `, due date:dueD` : "";
1432
+ await runAppleScript(`${duePart}
1433
+ tell application "Reminders"
1434
+ tell list "${listName2.replace(/"/g, '\\"')}"
1435
+ make new reminder with properties {name:"${reminderName.replace(/"/g, '\\"')}"${dueProperty}}
1436
+ end tell
1437
+ end tell`);
1438
+ return {
1439
+ success: true,
1440
+ data: {
1441
+ created: reminderName,
1442
+ due: dueDate || "none",
1443
+ list: listName2
1444
+ }
1445
+ };
1006
1446
  }
1007
1447
  // ── iMessage ────────────────────────────────────────────
1008
1448
  case "sys_imessage_send": {
1009
1449
  const to2 = params.to;
1010
1450
  const msg = params.message;
1011
- if (!to2 || !msg) return { success: false, error: "Missing 'to' (phone/email) or 'message'" };
1451
+ if (!to2 || !msg)
1452
+ return {
1453
+ success: false,
1454
+ error: "Missing 'to' (phone/email) or 'message'"
1455
+ };
1012
1456
  await runAppleScript(`
1013
1457
  tell application "Messages"
1014
1458
  set targetService to 1st account whose service type = iMessage
1015
1459
  set targetBuddy to participant "${to2.replace(/"/g, '\\"')}" of targetService
1016
1460
  send "${msg.replace(/"/g, '\\"')}" to targetBuddy
1017
1461
  end tell`);
1018
- return { success: true, data: { sent: true, to: to2, message: msg.slice(0, 100) } };
1462
+ return {
1463
+ success: true,
1464
+ data: { sent: true, to: to2, message: msg.slice(0, 100) }
1465
+ };
1019
1466
  }
1020
1467
  // ── System Info ─────────────────────────────────────────
1021
1468
  case "sys_system_info": {
@@ -1026,7 +1473,9 @@ print("\\(x),\\(y)")`;
1026
1473
  info.battery = "N/A";
1027
1474
  }
1028
1475
  try {
1029
- info.wifi = (await runShell("networksetup -getairportnetwork en0 2>/dev/null | cut -d: -f2")).trim();
1476
+ info.wifi = (await runShell(
1477
+ "networksetup -getairportnetwork en0 2>/dev/null | cut -d: -f2"
1478
+ )).trim();
1030
1479
  } catch {
1031
1480
  info.wifi = "N/A";
1032
1481
  }
@@ -1041,7 +1490,9 @@ print("\\(x),\\(y)")`;
1041
1490
  info.ip_public = "N/A";
1042
1491
  }
1043
1492
  try {
1044
- info.disk = (await runShell(`df -h / | tail -1 | awk '{print $3 " used / " $2 " total (" $5 " used)"}'`)).trim();
1493
+ info.disk = (await runShell(
1494
+ `df -h / | tail -1 | awk '{print $3 " used / " $2 " total (" $5 " used)"}'`
1495
+ )).trim();
1045
1496
  } catch {
1046
1497
  info.disk = "N/A";
1047
1498
  }
@@ -1080,7 +1531,9 @@ print("\\(x),\\(y)")`;
1080
1531
  await runAppleScript(`set volume output volume ${vol}`);
1081
1532
  return { success: true, data: { volume: vol } };
1082
1533
  }
1083
- const raw2 = await runAppleScript("output volume of (get volume settings)");
1534
+ const raw2 = await runAppleScript(
1535
+ "output volume of (get volume settings)"
1536
+ );
1084
1537
  return { success: true, data: { volume: Number(raw2) || 0 } };
1085
1538
  }
1086
1539
  // ── Brightness ──────────────────────────────────────────
@@ -1088,14 +1541,27 @@ print("\\(x),\\(y)")`;
1088
1541
  const level2 = params.level;
1089
1542
  if (level2 !== void 0) {
1090
1543
  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}'`);
1544
+ await runShell(
1545
+ `brightness ${br} 2>/dev/null || osascript -e 'tell application "System Events" to tell appearance preferences to set dark mode to ${br < 0.3}'`
1546
+ );
1092
1547
  return { success: true, data: { brightness: br } };
1093
1548
  }
1094
1549
  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 } };
1550
+ const raw3 = await runShell(
1551
+ "brightness -l 2>/dev/null | grep brightness | head -1 | awk '{print $NF}'"
1552
+ );
1553
+ return {
1554
+ success: true,
1555
+ data: { brightness: parseFloat(raw3) || 0.5 }
1556
+ };
1097
1557
  } catch {
1098
- return { success: true, data: { brightness: "unknown", note: "Install 'brightness' via brew for control" } };
1558
+ return {
1559
+ success: true,
1560
+ data: {
1561
+ brightness: "unknown",
1562
+ note: "Install 'brightness' via brew for control"
1563
+ }
1564
+ };
1099
1565
  }
1100
1566
  }
1101
1567
  // ── Do Not Disturb ──────────────────────────────────────
@@ -1103,49 +1569,79 @@ print("\\(x),\\(y)")`;
1103
1569
  const enabled = params.enabled;
1104
1570
  if (enabled !== void 0) {
1105
1571
  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" } };
1572
+ await runShell(
1573
+ `shortcuts run "Toggle Do Not Disturb" 2>/dev/null || osascript -e 'do shell script "defaults write com.apple.ncprefs dnd_prefs -data 0"'`
1574
+ );
1575
+ return {
1576
+ success: true,
1577
+ data: { dnd: enabled, note: "DND toggled" }
1578
+ };
1108
1579
  } catch {
1109
- return { success: true, data: { dnd: enabled, note: "Set DND manually in Control Center" } };
1580
+ return {
1581
+ success: true,
1582
+ data: {
1583
+ dnd: enabled,
1584
+ note: "Set DND manually in Control Center"
1585
+ }
1586
+ };
1110
1587
  }
1111
1588
  }
1112
- return { success: true, data: { note: "Pass enabled: true/false to toggle DND" } };
1589
+ return {
1590
+ success: true,
1591
+ data: { note: "Pass enabled: true/false to toggle DND" }
1592
+ };
1113
1593
  }
1114
1594
  // ── File Management ─────────────────────────────────────
1115
1595
  case "sys_file_list": {
1116
1596
  const dirPath = params.path || "Desktop";
1117
1597
  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}` };
1598
+ if (!fullDir)
1599
+ return { success: false, error: `Access denied: ${dirPath}` };
1600
+ if (!existsSync(fullDir))
1601
+ return { success: false, error: `Directory not found: ${dirPath}` };
1120
1602
  const entries = readdirSync(fullDir).map((name) => {
1121
1603
  try {
1122
1604
  const st = statSync(join(fullDir, name));
1123
- return { name, type: st.isDirectory() ? "dir" : "file", size: st.size, modified: st.mtime.toISOString() };
1605
+ return {
1606
+ name,
1607
+ type: st.isDirectory() ? "dir" : "file",
1608
+ size: st.size,
1609
+ modified: st.mtime.toISOString()
1610
+ };
1124
1611
  } catch {
1125
1612
  return { name, type: "unknown", size: 0, modified: "" };
1126
1613
  }
1127
1614
  });
1128
- return { success: true, data: { path: dirPath, entries, count: entries.length } };
1615
+ return {
1616
+ success: true,
1617
+ data: { path: dirPath, entries, count: entries.length }
1618
+ };
1129
1619
  }
1130
1620
  case "sys_file_move": {
1131
1621
  const src = params.from;
1132
1622
  const dst = params.to;
1133
- if (!src || !dst) return { success: false, error: "Missing from/to paths" };
1623
+ if (!src || !dst)
1624
+ return { success: false, error: "Missing from/to paths" };
1134
1625
  const fullSrc = safePath(src);
1135
1626
  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}` };
1627
+ if (!fullSrc || !fullDst)
1628
+ return { success: false, error: "Access denied" };
1629
+ if (!existsSync(fullSrc))
1630
+ return { success: false, error: `Source not found: ${src}` };
1138
1631
  renameSync(fullSrc, fullDst);
1139
1632
  return { success: true, data: { moved: src, to: dst } };
1140
1633
  }
1141
1634
  case "sys_file_copy": {
1142
1635
  const src2 = params.from;
1143
1636
  const dst2 = params.to;
1144
- if (!src2 || !dst2) return { success: false, error: "Missing from/to paths" };
1637
+ if (!src2 || !dst2)
1638
+ return { success: false, error: "Missing from/to paths" };
1145
1639
  const fullSrc2 = safePath(src2);
1146
1640
  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}` };
1641
+ if (!fullSrc2 || !fullDst2)
1642
+ return { success: false, error: "Access denied" };
1643
+ if (!existsSync(fullSrc2))
1644
+ return { success: false, error: `Source not found: ${src2}` };
1149
1645
  copyFileSync(fullSrc2, fullDst2);
1150
1646
  return { success: true, data: { copied: src2, to: dst2 } };
1151
1647
  }
@@ -1154,16 +1650,23 @@ print("\\(x),\\(y)")`;
1154
1650
  if (!target) return { success: false, error: "Missing path" };
1155
1651
  const fullTarget = safePath(target);
1156
1652
  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" } };
1653
+ if (!existsSync(fullTarget))
1654
+ return { success: false, error: `Not found: ${target}` };
1655
+ await runShell(
1656
+ `osascript -e 'tell application "Finder" to delete POSIX file "${fullTarget}"'`
1657
+ );
1658
+ return {
1659
+ success: true,
1660
+ data: { deleted: target, method: "moved_to_trash" }
1661
+ };
1160
1662
  }
1161
1663
  case "sys_file_info": {
1162
1664
  const fPath = params.path;
1163
1665
  if (!fPath) return { success: false, error: "Missing path" };
1164
1666
  const fullF = safePath(fPath);
1165
1667
  if (!fullF) return { success: false, error: "Access denied" };
1166
- if (!existsSync(fullF)) return { success: false, error: `Not found: ${fPath}` };
1668
+ if (!existsSync(fullF))
1669
+ return { success: false, error: `Not found: ${fPath}` };
1167
1670
  const st = statSync(fullF);
1168
1671
  return {
1169
1672
  success: true,
@@ -1186,9 +1689,15 @@ print("\\(x),\\(y)")`;
1186
1689
  if (!dlUrl) return { success: false, error: "Missing URL" };
1187
1690
  const fullDl = safePath(dlDest);
1188
1691
  if (!fullDl) return { success: false, error: "Access denied" };
1189
- await runShell(`curl -sL -o "${fullDl}" "${dlUrl.replace(/"/g, '\\"')}"`, 6e4);
1692
+ await runShell(
1693
+ `curl -sL -o "${fullDl}" "${dlUrl.replace(/"/g, '\\"')}"`,
1694
+ 6e4
1695
+ );
1190
1696
  const size = existsSync(fullDl) ? statSync(fullDl).size : 0;
1191
- return { success: true, data: { downloaded: dlUrl, saved: dlDest, size } };
1697
+ return {
1698
+ success: true,
1699
+ data: { downloaded: dlUrl, saved: dlDest, size }
1700
+ };
1192
1701
  }
1193
1702
  // ── Window Management ───────────────────────────────────
1194
1703
  case "sys_window_list": {
@@ -1205,7 +1714,12 @@ print("\\(x),\\(y)")`;
1205
1714
  end tell`);
1206
1715
  const windows = raw4.split("\n").filter(Boolean).map((line) => {
1207
1716
  const [app, title, pos, sz] = line.split(" | ");
1208
- return { app: app?.trim(), title: title?.trim(), position: pos?.trim(), size: sz?.trim() };
1717
+ return {
1718
+ app: app?.trim(),
1719
+ title: title?.trim(),
1720
+ position: pos?.trim(),
1721
+ size: sz?.trim()
1722
+ };
1209
1723
  });
1210
1724
  return { success: true, data: { windows, count: windows.length } };
1211
1725
  }
@@ -1228,7 +1742,11 @@ print("\\(x),\\(y)")`;
1228
1742
  if (!app2) return { success: false, error: "Missing app name" };
1229
1743
  const posPart = x !== void 0 && y !== void 0 ? `set position of window 1 to {${x}, ${y}}` : "";
1230
1744
  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" };
1745
+ if (!posPart && !sizePart)
1746
+ return {
1747
+ success: false,
1748
+ error: "Provide x,y for position and/or width,height for size"
1749
+ };
1232
1750
  await runAppleScript(`
1233
1751
  tell application "System Events"
1234
1752
  tell process "${app2.replace(/"/g, '\\"')}"
@@ -1236,7 +1754,14 @@ print("\\(x),\\(y)")`;
1236
1754
  ${sizePart}
1237
1755
  end tell
1238
1756
  end tell`);
1239
- return { success: true, data: { app: app2, position: posPart ? { x, y } : "unchanged", size: sizePart ? { width: w, height: h } : "unchanged" } };
1757
+ return {
1758
+ success: true,
1759
+ data: {
1760
+ app: app2,
1761
+ position: posPart ? { x, y } : "unchanged",
1762
+ size: sizePart ? { width: w, height: h } : "unchanged"
1763
+ }
1764
+ };
1240
1765
  }
1241
1766
  // ── Apple Notes ─────────────────────────────────────────
1242
1767
  case "sys_notes_list": {
@@ -1252,7 +1777,11 @@ print("\\(x),\\(y)")`;
1252
1777
  end tell`);
1253
1778
  const notes = raw5.split("\n").filter(Boolean).map((line) => {
1254
1779
  const [id, name, date] = line.split(" | ");
1255
- return { id: id?.trim(), name: name?.trim(), modified: date?.trim() };
1780
+ return {
1781
+ id: id?.trim(),
1782
+ name: name?.trim(),
1783
+ modified: date?.trim()
1784
+ };
1256
1785
  });
1257
1786
  return { success: true, data: { notes, count: notes.length } };
1258
1787
  }
@@ -1292,9 +1821,16 @@ print("\\(x),\\(y)")`;
1292
1821
  end tell`);
1293
1822
  const contacts = raw6.split("\n").filter(Boolean).map((line) => {
1294
1823
  const [name, email, phone] = line.split(" | ");
1295
- return { name: name?.trim(), email: email?.trim(), phone: phone?.trim() };
1824
+ return {
1825
+ name: name?.trim(),
1826
+ email: email?.trim(),
1827
+ phone: phone?.trim()
1828
+ };
1296
1829
  });
1297
- return { success: true, data: { contacts, count: contacts.length, query: query2 } };
1830
+ return {
1831
+ success: true,
1832
+ data: { contacts, count: contacts.length, query: query2 }
1833
+ };
1298
1834
  }
1299
1835
  // ── OCR (Vision framework) ──────────────────────────────
1300
1836
  case "sys_ocr": {
@@ -1302,7 +1838,8 @@ print("\\(x),\\(y)")`;
1302
1838
  if (!imgPath) return { success: false, error: "Missing image path" };
1303
1839
  const fullImg = imgPath.startsWith("/tmp/") ? imgPath : safePath(imgPath);
1304
1840
  if (!fullImg) return { success: false, error: "Access denied" };
1305
- if (!existsSync(fullImg)) return { success: false, error: `Image not found: ${imgPath}` };
1841
+ if (!existsSync(fullImg))
1842
+ return { success: false, error: `Image not found: ${imgPath}` };
1306
1843
  const swiftOcr = `
1307
1844
  import Foundation
1308
1845
  import Vision
@@ -1324,11 +1861,312 @@ let text = results.compactMap { $0.topCandidates(1).first?.string }.joined(separ
1324
1861
  print(text)`;
1325
1862
  try {
1326
1863
  const ocrText = await runSwift(swiftOcr, 3e4);
1327
- return { success: true, data: { text: ocrText.slice(0, 1e4), length: ocrText.length, path: imgPath } };
1864
+ return {
1865
+ success: true,
1866
+ data: {
1867
+ text: ocrText.slice(0, 1e4),
1868
+ length: ocrText.length,
1869
+ path: imgPath
1870
+ }
1871
+ };
1328
1872
  } catch (err) {
1329
- return { success: false, error: `OCR failed: ${err.message}` };
1873
+ return {
1874
+ success: false,
1875
+ error: `OCR failed: ${err.message}`
1876
+ };
1330
1877
  }
1331
1878
  }
1879
+ // ── Philips Hue (Local Bridge API) ─────────────────────
1880
+ // Commands are prefixed with sys_hue_ (mapped from hue_ in chat.ts → system_hue_ → sys_hue_)
1881
+ case "sys_hue_lights_on": {
1882
+ const light = params.light;
1883
+ if (!light)
1884
+ return { success: false, error: "Missing light ID or name" };
1885
+ const hueConfig = await getHueConfig();
1886
+ if (!hueConfig)
1887
+ return {
1888
+ success: false,
1889
+ error: "Philips Hue not configured. Set HUE_BRIDGE_IP and HUE_USERNAME environment variables, or configure in Pulso Settings."
1890
+ };
1891
+ const lightId = await resolveHueLight(hueConfig, light);
1892
+ if (!lightId)
1893
+ return {
1894
+ success: false,
1895
+ error: `Light '${light}' not found on Hue bridge`
1896
+ };
1897
+ const state = { on: true };
1898
+ if (params.brightness !== void 0)
1899
+ state.bri = Math.max(1, Math.min(254, Number(params.brightness)));
1900
+ if (params.color) {
1901
+ const rgb = parseColorCompanion(params.color);
1902
+ if (rgb) {
1903
+ const [x, y] = rgbToXyCompanion(...rgb);
1904
+ state.xy = [x, y];
1905
+ }
1906
+ }
1907
+ const hueRes = await hueRequest(
1908
+ hueConfig,
1909
+ `lights/${lightId}/state`,
1910
+ "PUT",
1911
+ state
1912
+ );
1913
+ return {
1914
+ success: true,
1915
+ data: { light: lightId, action: "on", ...state, response: hueRes }
1916
+ };
1917
+ }
1918
+ case "sys_hue_lights_off": {
1919
+ const light = params.light;
1920
+ if (!light)
1921
+ return { success: false, error: "Missing light ID or name" };
1922
+ const hueConfig = await getHueConfig();
1923
+ if (!hueConfig)
1924
+ return { success: false, error: "Philips Hue not configured." };
1925
+ const lightId = await resolveHueLight(hueConfig, light);
1926
+ if (!lightId)
1927
+ return { success: false, error: `Light '${light}' not found` };
1928
+ const hueRes = await hueRequest(
1929
+ hueConfig,
1930
+ `lights/${lightId}/state`,
1931
+ "PUT",
1932
+ { on: false }
1933
+ );
1934
+ return {
1935
+ success: true,
1936
+ data: { light: lightId, action: "off", response: hueRes }
1937
+ };
1938
+ }
1939
+ case "sys_hue_lights_color": {
1940
+ const light = params.light;
1941
+ const color = params.color;
1942
+ if (!light || !color)
1943
+ return { success: false, error: "Missing light or color" };
1944
+ const hueConfig = await getHueConfig();
1945
+ if (!hueConfig)
1946
+ return { success: false, error: "Philips Hue not configured." };
1947
+ const lightId = await resolveHueLight(hueConfig, light);
1948
+ if (!lightId)
1949
+ return { success: false, error: `Light '${light}' not found` };
1950
+ const rgb = parseColorCompanion(color);
1951
+ if (!rgb)
1952
+ return { success: false, error: `Unrecognized color: ${color}` };
1953
+ const [x, y] = rgbToXyCompanion(...rgb);
1954
+ const hueRes = await hueRequest(
1955
+ hueConfig,
1956
+ `lights/${lightId}/state`,
1957
+ "PUT",
1958
+ { on: true, xy: [x, y] }
1959
+ );
1960
+ return {
1961
+ success: true,
1962
+ data: { light: lightId, color, xy: [x, y], response: hueRes }
1963
+ };
1964
+ }
1965
+ case "sys_hue_lights_brightness": {
1966
+ const light = params.light;
1967
+ const brightness = Number(params.brightness);
1968
+ if (!light || isNaN(brightness))
1969
+ return { success: false, error: "Missing light or brightness" };
1970
+ const hueConfig = await getHueConfig();
1971
+ if (!hueConfig)
1972
+ return { success: false, error: "Philips Hue not configured." };
1973
+ const lightId = await resolveHueLight(hueConfig, light);
1974
+ if (!lightId)
1975
+ return { success: false, error: `Light '${light}' not found` };
1976
+ const bri = Math.max(1, Math.min(254, brightness));
1977
+ const hueRes = await hueRequest(
1978
+ hueConfig,
1979
+ `lights/${lightId}/state`,
1980
+ "PUT",
1981
+ { on: true, bri }
1982
+ );
1983
+ return {
1984
+ success: true,
1985
+ data: { light: lightId, brightness: bri, response: hueRes }
1986
+ };
1987
+ }
1988
+ case "sys_hue_lights_scene": {
1989
+ const scene = params.scene;
1990
+ const group = params.group || "0";
1991
+ if (!scene) return { success: false, error: "Missing scene name" };
1992
+ const hueConfig = await getHueConfig();
1993
+ if (!hueConfig)
1994
+ return { success: false, error: "Philips Hue not configured." };
1995
+ const scenes = await hueRequest(hueConfig, "scenes", "GET");
1996
+ let sceneId = null;
1997
+ for (const [id, s] of Object.entries(scenes)) {
1998
+ if (s.name?.toLowerCase() === scene.toLowerCase()) {
1999
+ sceneId = id;
2000
+ break;
2001
+ }
2002
+ }
2003
+ if (!sceneId)
2004
+ return {
2005
+ success: false,
2006
+ error: `Scene '${scene}' not found. Available: ${Object.values(
2007
+ scenes
2008
+ ).map((s) => s.name).join(", ")}`
2009
+ };
2010
+ const hueRes = await hueRequest(
2011
+ hueConfig,
2012
+ `groups/${group}/action`,
2013
+ "PUT",
2014
+ { scene: sceneId }
2015
+ );
2016
+ return {
2017
+ success: true,
2018
+ data: { scene, sceneId, group, response: hueRes }
2019
+ };
2020
+ }
2021
+ case "sys_hue_lights_list": {
2022
+ const hueConfig = await getHueConfig();
2023
+ if (!hueConfig)
2024
+ return { success: false, error: "Philips Hue not configured." };
2025
+ const [lights, groups, scenes] = await Promise.all([
2026
+ hueRequest(hueConfig, "lights", "GET"),
2027
+ hueRequest(hueConfig, "groups", "GET"),
2028
+ hueRequest(hueConfig, "scenes", "GET")
2029
+ ]);
2030
+ return {
2031
+ success: true,
2032
+ data: {
2033
+ lights: Object.entries(lights).map(([id, l]) => ({
2034
+ id,
2035
+ name: l.name,
2036
+ on: l.state?.on,
2037
+ brightness: l.state?.bri,
2038
+ type: l.type
2039
+ })),
2040
+ groups: Object.entries(groups).map(([id, g]) => ({
2041
+ id,
2042
+ name: g.name,
2043
+ type: g.type,
2044
+ lightCount: g.lights?.length
2045
+ })),
2046
+ scenes: Object.entries(scenes).map(([id, s]) => ({
2047
+ id,
2048
+ name: s.name,
2049
+ group: s.group
2050
+ }))
2051
+ }
2052
+ };
2053
+ }
2054
+ // ── Sonos (Local HTTP API) ──────────────────────────────
2055
+ case "sys_sonos_play": {
2056
+ const room = params.room;
2057
+ if (!room) return { success: false, error: "Missing room name" };
2058
+ const sonosUrl = getSonosApiUrl();
2059
+ if (!sonosUrl)
2060
+ return {
2061
+ success: false,
2062
+ error: "Sonos not configured. Set SONOS_API_URL environment variable (e.g., http://localhost:5005)."
2063
+ };
2064
+ const res = await sonosRequest(
2065
+ sonosUrl,
2066
+ `${encodeURIComponent(room)}/play`
2067
+ );
2068
+ return { success: true, data: { room, action: "play", response: res } };
2069
+ }
2070
+ case "sys_sonos_pause": {
2071
+ const room = params.room;
2072
+ if (!room) return { success: false, error: "Missing room name" };
2073
+ const sonosUrl = getSonosApiUrl();
2074
+ if (!sonosUrl)
2075
+ return { success: false, error: "Sonos not configured." };
2076
+ const res = await sonosRequest(
2077
+ sonosUrl,
2078
+ `${encodeURIComponent(room)}/pause`
2079
+ );
2080
+ return {
2081
+ success: true,
2082
+ data: { room, action: "pause", response: res }
2083
+ };
2084
+ }
2085
+ case "sys_sonos_volume": {
2086
+ const room = params.room;
2087
+ const level = Number(params.level);
2088
+ if (!room || isNaN(level))
2089
+ return { success: false, error: "Missing room or level" };
2090
+ const sonosUrl = getSonosApiUrl();
2091
+ if (!sonosUrl)
2092
+ return { success: false, error: "Sonos not configured." };
2093
+ const vol = Math.max(0, Math.min(100, level));
2094
+ const res = await sonosRequest(
2095
+ sonosUrl,
2096
+ `${encodeURIComponent(room)}/volume/${vol}`
2097
+ );
2098
+ return { success: true, data: { room, volume: vol, response: res } };
2099
+ }
2100
+ case "sys_sonos_play_uri": {
2101
+ const room = params.room;
2102
+ const uri = params.uri;
2103
+ if (!room || !uri)
2104
+ return { success: false, error: "Missing room or URI" };
2105
+ const sonosUrl = getSonosApiUrl();
2106
+ if (!sonosUrl)
2107
+ return { success: false, error: "Sonos not configured." };
2108
+ if (uri.startsWith("spotify:")) {
2109
+ const res2 = await sonosRequest(
2110
+ sonosUrl,
2111
+ `${encodeURIComponent(room)}/spotify/now/${encodeURIComponent(uri)}`
2112
+ );
2113
+ return {
2114
+ success: true,
2115
+ data: { room, uri, type: "spotify", response: res2 }
2116
+ };
2117
+ }
2118
+ const res = await sonosRequest(
2119
+ sonosUrl,
2120
+ `${encodeURIComponent(room)}/setavtransporturi/${encodeURIComponent(uri)}`
2121
+ );
2122
+ return { success: true, data: { room, uri, response: res } };
2123
+ }
2124
+ case "sys_sonos_rooms": {
2125
+ const sonosUrl = getSonosApiUrl();
2126
+ if (!sonosUrl)
2127
+ return { success: false, error: "Sonos not configured." };
2128
+ const res = await sonosRequest(sonosUrl, "zones");
2129
+ return { success: true, data: res };
2130
+ }
2131
+ case "sys_sonos_next": {
2132
+ const room = params.room;
2133
+ if (!room) return { success: false, error: "Missing room name" };
2134
+ const sonosUrl = getSonosApiUrl();
2135
+ if (!sonosUrl)
2136
+ return { success: false, error: "Sonos not configured." };
2137
+ const res = await sonosRequest(
2138
+ sonosUrl,
2139
+ `${encodeURIComponent(room)}/next`
2140
+ );
2141
+ return { success: true, data: { room, action: "next", response: res } };
2142
+ }
2143
+ case "sys_sonos_previous": {
2144
+ const room = params.room;
2145
+ if (!room) return { success: false, error: "Missing room name" };
2146
+ const sonosUrl = getSonosApiUrl();
2147
+ if (!sonosUrl)
2148
+ return { success: false, error: "Sonos not configured." };
2149
+ const res = await sonosRequest(
2150
+ sonosUrl,
2151
+ `${encodeURIComponent(room)}/previous`
2152
+ );
2153
+ return {
2154
+ success: true,
2155
+ data: { room, action: "previous", response: res }
2156
+ };
2157
+ }
2158
+ case "sys_sonos_now_playing": {
2159
+ const room = params.room;
2160
+ if (!room) return { success: false, error: "Missing room name" };
2161
+ const sonosUrl = getSonosApiUrl();
2162
+ if (!sonosUrl)
2163
+ return { success: false, error: "Sonos not configured." };
2164
+ const res = await sonosRequest(
2165
+ sonosUrl,
2166
+ `${encodeURIComponent(room)}/state`
2167
+ );
2168
+ return { success: true, data: { room, ...res } };
2169
+ }
1332
2170
  default:
1333
2171
  return { success: false, error: `Unknown command: ${command}` };
1334
2172
  }
@@ -1336,6 +2174,130 @@ print(text)`;
1336
2174
  return { success: false, error: err.message };
1337
2175
  }
1338
2176
  }
2177
+ var HUE_CONFIG_FILE = join(HOME, ".pulso-hue-config.json");
2178
+ async function getHueConfig() {
2179
+ const ip = process.env.HUE_BRIDGE_IP;
2180
+ const user = process.env.HUE_USERNAME;
2181
+ if (ip && user) return { bridgeIp: ip, username: user };
2182
+ try {
2183
+ if (existsSync(HUE_CONFIG_FILE)) {
2184
+ const data = JSON.parse(readFileSync(HUE_CONFIG_FILE, "utf-8"));
2185
+ if (data.bridgeIp && data.username) return data;
2186
+ }
2187
+ } catch {
2188
+ }
2189
+ return null;
2190
+ }
2191
+ async function hueRequest(config, path, method, body) {
2192
+ const url = `http://${config.bridgeIp}/api/${config.username}/${path}`;
2193
+ const controller = new AbortController();
2194
+ const timeout = setTimeout(() => controller.abort(), 1e4);
2195
+ try {
2196
+ const opts = {
2197
+ method,
2198
+ signal: controller.signal,
2199
+ headers: { "Content-Type": "application/json" }
2200
+ };
2201
+ if (body && method !== "GET") opts.body = JSON.stringify(body);
2202
+ const res = await fetch(url, opts);
2203
+ clearTimeout(timeout);
2204
+ return await res.json();
2205
+ } catch (err) {
2206
+ clearTimeout(timeout);
2207
+ throw new Error(
2208
+ `Hue bridge unreachable at ${config.bridgeIp}: ${err.message}`
2209
+ );
2210
+ }
2211
+ }
2212
+ var hueLightCache = null;
2213
+ var hueLightCacheTs = 0;
2214
+ async function resolveHueLight(config, lightRef) {
2215
+ if (/^\d+$/.test(lightRef)) return lightRef;
2216
+ if (!hueLightCache || Date.now() - hueLightCacheTs > 3e5) {
2217
+ try {
2218
+ const lights = await hueRequest(config, "lights", "GET");
2219
+ hueLightCache = /* @__PURE__ */ new Map();
2220
+ for (const [id, light] of Object.entries(lights)) {
2221
+ hueLightCache.set(light.name.toLowerCase(), id);
2222
+ }
2223
+ hueLightCacheTs = Date.now();
2224
+ } catch {
2225
+ return null;
2226
+ }
2227
+ }
2228
+ return hueLightCache.get(lightRef.toLowerCase()) ?? null;
2229
+ }
2230
+ var CSS_COLORS_COMPANION = {
2231
+ red: [255, 0, 0],
2232
+ green: [0, 128, 0],
2233
+ blue: [0, 0, 255],
2234
+ yellow: [255, 255, 0],
2235
+ orange: [255, 165, 0],
2236
+ purple: [128, 0, 128],
2237
+ pink: [255, 192, 203],
2238
+ white: [255, 255, 255],
2239
+ cyan: [0, 255, 255],
2240
+ magenta: [255, 0, 255],
2241
+ lime: [0, 255, 0],
2242
+ teal: [0, 128, 128],
2243
+ coral: [255, 127, 80],
2244
+ salmon: [250, 128, 114],
2245
+ gold: [255, 215, 0],
2246
+ lavender: [230, 230, 250],
2247
+ turquoise: [64, 224, 208],
2248
+ warmwhite: [255, 200, 150],
2249
+ coolwhite: [200, 220, 255]
2250
+ };
2251
+ function parseColorCompanion(color) {
2252
+ if (color.startsWith("#")) {
2253
+ const hex = color.slice(1);
2254
+ if (hex.length === 6) {
2255
+ return [
2256
+ parseInt(hex.slice(0, 2), 16),
2257
+ parseInt(hex.slice(2, 4), 16),
2258
+ parseInt(hex.slice(4, 6), 16)
2259
+ ];
2260
+ }
2261
+ }
2262
+ return CSS_COLORS_COMPANION[color.toLowerCase()] ?? null;
2263
+ }
2264
+ function rgbToXyCompanion(r, g, b) {
2265
+ let rr = r / 255;
2266
+ let gg = g / 255;
2267
+ let bb = b / 255;
2268
+ rr = rr > 0.04045 ? Math.pow((rr + 0.055) / 1.055, 2.4) : rr / 12.92;
2269
+ gg = gg > 0.04045 ? Math.pow((gg + 0.055) / 1.055, 2.4) : gg / 12.92;
2270
+ bb = bb > 0.04045 ? Math.pow((bb + 0.055) / 1.055, 2.4) : bb / 12.92;
2271
+ const X = rr * 0.664511 + gg * 0.154324 + bb * 0.162028;
2272
+ const Y = rr * 0.283881 + gg * 0.668433 + bb * 0.047685;
2273
+ const Z = rr * 88e-6 + gg * 0.07231 + bb * 0.986039;
2274
+ const sum = X + Y + Z;
2275
+ if (sum === 0) return [0.3127, 0.329];
2276
+ return [X / sum, Y / sum];
2277
+ }
2278
+ function getSonosApiUrl() {
2279
+ return process.env.SONOS_API_URL || null;
2280
+ }
2281
+ async function sonosRequest(baseUrl, path) {
2282
+ const url = `${baseUrl.replace(/\/$/, "")}/${path}`;
2283
+ const controller = new AbortController();
2284
+ const timeout = setTimeout(() => controller.abort(), 1e4);
2285
+ try {
2286
+ const res = await fetch(url, { signal: controller.signal });
2287
+ clearTimeout(timeout);
2288
+ const text = await res.text();
2289
+ try {
2290
+ return JSON.parse(text);
2291
+ } catch {
2292
+ return { raw: text };
2293
+ }
2294
+ } catch (err) {
2295
+ clearTimeout(timeout);
2296
+ throw new Error(
2297
+ `Sonos API unreachable at ${baseUrl}: ${err.message}`
2298
+ );
2299
+ }
2300
+ }
1339
2301
  var ws = null;
1340
2302
  var reconnectTimer = null;
1341
2303
  var heartbeatTimer = null;
@@ -1348,12 +2310,16 @@ function connect() {
1348
2310
  ws.on("open", () => {
1349
2311
  reconnectAttempts = 0;
1350
2312
  console.log("\u2705 Connected to Pulso!");
1351
- console.log(`\u{1F5A5}\uFE0F Companion is active \u2014 ${ACCESS_LEVEL === "full" ? "full device access" : "sandboxed mode"}`);
2313
+ console.log(
2314
+ `\u{1F5A5}\uFE0F Companion is active \u2014 ${ACCESS_LEVEL === "full" ? "full device access" : "sandboxed mode"}`
2315
+ );
1352
2316
  console.log("");
1353
2317
  console.log(" Available capabilities:");
1354
2318
  console.log(" \u2022 Open apps & URLs");
1355
2319
  console.log(" \u2022 Control Spotify & media");
1356
- console.log(` \u2022 Read/write files ${ACCESS_LEVEL === "full" ? "(full device)" : "(Documents, Desktop, Downloads)"}`);
2320
+ console.log(
2321
+ ` \u2022 Read/write files ${ACCESS_LEVEL === "full" ? "(full device)" : "(Documents, Desktop, Downloads)"}`
2322
+ );
1357
2323
  console.log(" \u2022 Clipboard access");
1358
2324
  console.log(" \u2022 Screenshots");
1359
2325
  console.log(" \u2022 Text-to-speech");
@@ -1362,8 +2328,12 @@ function connect() {
1362
2328
  console.log(" \u2022 Terminal commands");
1363
2329
  console.log(" \u2022 System notifications");
1364
2330
  console.log(" \u2022 macOS Shortcuts");
2331
+ console.log(" \u2022 Smart Home: Philips Hue (if configured)");
2332
+ console.log(" \u2022 Smart Home: Sonos (if configured)");
1365
2333
  console.log("");
1366
- console.log(` Access: ${ACCESS_LEVEL === "full" ? "\u{1F513} Full (unrestricted)" : "\u{1F512} Sandboxed (safe dirs only)"}`);
2334
+ console.log(
2335
+ ` Access: ${ACCESS_LEVEL === "full" ? "\u{1F513} Full (unrestricted)" : "\u{1F512} Sandboxed (safe dirs only)"}`
2336
+ );
1367
2337
  console.log(" Waiting for commands from Pulso agent...");
1368
2338
  ws.send(JSON.stringify({ type: "extension_ready" }));
1369
2339
  if (heartbeatTimer) clearInterval(heartbeatTimer);
@@ -1384,10 +2354,16 @@ function connect() {
1384
2354
  return;
1385
2355
  }
1386
2356
  if (msg.id && msg.command) {
1387
- console.log(`
1388
- \u26A1 Command: ${msg.command}`, msg.params ? JSON.stringify(msg.params).slice(0, 200) : "");
2357
+ console.log(
2358
+ `
2359
+ \u26A1 Command: ${msg.command}`,
2360
+ msg.params ? JSON.stringify(msg.params).slice(0, 200) : ""
2361
+ );
1389
2362
  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);
2363
+ console.log(
2364
+ ` \u2192 ${result.success ? "\u2705" : "\u274C"}`,
2365
+ result.success ? JSON.stringify(result.data).slice(0, 200) : result.error
2366
+ );
1391
2367
  ws.send(JSON.stringify({ id: msg.id, result }));
1392
2368
  return;
1393
2369
  }
@@ -1405,9 +2381,15 @@ function connect() {
1405
2381
  heartbeatTimer = null;
1406
2382
  }
1407
2383
  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");
2384
+ console.log(
2385
+ "\n\u26A0\uFE0F Another Pulso Companion instance is already connected."
2386
+ );
2387
+ console.log(
2388
+ " This instance will exit. Only one companion per account is supported."
2389
+ );
2390
+ console.log(
2391
+ " If this is unexpected, close other terminals running Pulso Companion.\n"
2392
+ );
1411
2393
  process.exit(0);
1412
2394
  return;
1413
2395
  }
@@ -1420,29 +2402,209 @@ function connect() {
1420
2402
  function scheduleReconnect() {
1421
2403
  if (reconnectTimer) return;
1422
2404
  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})`);
2405
+ const delay = Math.min(
2406
+ RECONNECT_DELAY * Math.pow(2, reconnectAttempts - 1),
2407
+ 6e4
2408
+ );
2409
+ console.log(
2410
+ ` Reconnecting in ${(delay / 1e3).toFixed(0)}s... (attempt ${reconnectAttempts})`
2411
+ );
1425
2412
  reconnectTimer = setTimeout(() => {
1426
2413
  reconnectTimer = null;
1427
2414
  connect();
1428
2415
  }, delay);
1429
2416
  }
2417
+ var wakeWordActive = false;
2418
+ async function startWakeWordDetection() {
2419
+ if (!WAKE_WORD_ENABLED) return;
2420
+ if (!PICOVOICE_ACCESS_KEY) {
2421
+ console.log(" \u26A0\uFE0F Wake word enabled but no Picovoice key provided.");
2422
+ console.log(" Get a free key at https://console.picovoice.ai/");
2423
+ console.log(
2424
+ " Use: --picovoice-key <key> or PICOVOICE_ACCESS_KEY env var\n"
2425
+ );
2426
+ return;
2427
+ }
2428
+ try {
2429
+ const { Porcupine, BuiltinKeyword } = await import("@picovoice/porcupine-node");
2430
+ const { PvRecorder } = await import("@picovoice/pvrecorder-node");
2431
+ const customKeywordPath = join(HOME, ".pulso-wake-word.ppn");
2432
+ const useCustom = existsSync(customKeywordPath);
2433
+ let porcupine;
2434
+ if (useCustom) {
2435
+ porcupine = new Porcupine(
2436
+ PICOVOICE_ACCESS_KEY,
2437
+ [customKeywordPath],
2438
+ [0.5]
2439
+ // sensitivity
2440
+ );
2441
+ console.log(
2442
+ " \u{1F3A4} Wake word: custom model loaded from ~/.pulso-wake-word.ppn"
2443
+ );
2444
+ } else {
2445
+ porcupine = new Porcupine(
2446
+ PICOVOICE_ACCESS_KEY,
2447
+ [BuiltinKeyword.HEY_GOOGLE],
2448
+ // Placeholder — replace with custom "Hey Pulso"
2449
+ [0.5]
2450
+ );
2451
+ console.log(
2452
+ ' \u{1F3A4} Wake word: using "Hey Google" as placeholder (create custom "Hey Pulso" at console.picovoice.ai)'
2453
+ );
2454
+ }
2455
+ const frameLength = porcupine.frameLength;
2456
+ const sampleRate = porcupine.sampleRate;
2457
+ const devices = PvRecorder.getAvailableDevices();
2458
+ console.log(` \u{1F3A4} Audio devices: ${devices.length} found (using default)`);
2459
+ const recorder = new PvRecorder(frameLength, 0);
2460
+ recorder.start();
2461
+ wakeWordActive = true;
2462
+ console.log(" \u{1F3A4} Wake word detection ACTIVE \u2014 listening...\n");
2463
+ const listen = async () => {
2464
+ while (wakeWordActive) {
2465
+ try {
2466
+ const pcm = await recorder.read();
2467
+ const keywordIndex = porcupine.process(pcm);
2468
+ if (keywordIndex >= 0) {
2469
+ console.log("\n \u{1F5E3}\uFE0F Wake word detected!");
2470
+ exec("afplay /System/Library/Sounds/Tink.aiff");
2471
+ const audioChunks = [];
2472
+ let silenceFrames = 0;
2473
+ const MAX_SILENCE_FRAMES = Math.ceil(
2474
+ 1.5 * sampleRate / frameLength
2475
+ );
2476
+ const MAX_RECORD_FRAMES = Math.ceil(
2477
+ 10 * sampleRate / frameLength
2478
+ );
2479
+ let totalFrames = 0;
2480
+ console.log(" \u{1F3A4} Listening for command...");
2481
+ while (totalFrames < MAX_RECORD_FRAMES) {
2482
+ const frame = await recorder.read();
2483
+ audioChunks.push(new Int16Array(frame));
2484
+ totalFrames++;
2485
+ let sum = 0;
2486
+ for (let i = 0; i < frame.length; i++) {
2487
+ sum += frame[i] * frame[i];
2488
+ }
2489
+ const rms = Math.sqrt(sum / frame.length);
2490
+ if (rms < 200) {
2491
+ silenceFrames++;
2492
+ if (silenceFrames >= MAX_SILENCE_FRAMES && totalFrames > 5) {
2493
+ break;
2494
+ }
2495
+ } else {
2496
+ silenceFrames = 0;
2497
+ }
2498
+ }
2499
+ console.log(
2500
+ ` \u{1F3A4} Captured ${(totalFrames * frameLength / sampleRate).toFixed(1)}s of audio`
2501
+ );
2502
+ const totalSamples = audioChunks.reduce((s, c) => s + c.length, 0);
2503
+ const wavBuffer = createWavBuffer(
2504
+ audioChunks,
2505
+ sampleRate,
2506
+ totalSamples
2507
+ );
2508
+ if (ws && ws.readyState === WebSocket.OPEN) {
2509
+ const base64Audio = Buffer.from(wavBuffer).toString("base64");
2510
+ ws.send(
2511
+ JSON.stringify({
2512
+ type: "wake_word_audio",
2513
+ audio: base64Audio,
2514
+ format: "wav",
2515
+ sampleRate,
2516
+ durationMs: Math.round(
2517
+ totalFrames * frameLength / sampleRate * 1e3
2518
+ )
2519
+ })
2520
+ );
2521
+ console.log(" \u{1F4E4} Audio sent to server for processing");
2522
+ exec("afplay /System/Library/Sounds/Pop.aiff");
2523
+ } else {
2524
+ console.log(" \u26A0\uFE0F Not connected to server \u2014 audio discarded");
2525
+ }
2526
+ }
2527
+ } catch (err) {
2528
+ if (wakeWordActive) {
2529
+ console.error(" \u274C Wake word error:", err.message);
2530
+ await new Promise((r) => setTimeout(r, 1e3));
2531
+ }
2532
+ }
2533
+ }
2534
+ recorder.stop();
2535
+ recorder.release();
2536
+ porcupine.release();
2537
+ };
2538
+ listen().catch((err) => {
2539
+ console.error(" \u274C Wake word listener crashed:", err.message);
2540
+ wakeWordActive = false;
2541
+ });
2542
+ } catch (err) {
2543
+ const msg = err.message;
2544
+ if (msg.includes("Cannot find module") || msg.includes("MODULE_NOT_FOUND")) {
2545
+ console.log(" \u26A0\uFE0F Wake word packages not installed. Run:");
2546
+ console.log(
2547
+ " npm install @picovoice/porcupine-node @picovoice/pvrecorder-node\n"
2548
+ );
2549
+ } else {
2550
+ console.error(" \u274C Wake word init failed:", msg);
2551
+ }
2552
+ }
2553
+ }
2554
+ function createWavBuffer(chunks, sampleRate, totalSamples) {
2555
+ const bytesPerSample = 2;
2556
+ const numChannels = 1;
2557
+ const dataSize = totalSamples * bytesPerSample;
2558
+ const buffer = new ArrayBuffer(44 + dataSize);
2559
+ const view = new DataView(buffer);
2560
+ writeString(view, 0, "RIFF");
2561
+ view.setUint32(4, 36 + dataSize, true);
2562
+ writeString(view, 8, "WAVE");
2563
+ writeString(view, 12, "fmt ");
2564
+ view.setUint32(16, 16, true);
2565
+ view.setUint16(20, 1, true);
2566
+ view.setUint16(22, numChannels, true);
2567
+ view.setUint32(24, sampleRate, true);
2568
+ view.setUint32(28, sampleRate * numChannels * bytesPerSample, true);
2569
+ view.setUint16(32, numChannels * bytesPerSample, true);
2570
+ view.setUint16(34, bytesPerSample * 8, true);
2571
+ writeString(view, 36, "data");
2572
+ view.setUint32(40, dataSize, true);
2573
+ let offset = 44;
2574
+ for (const chunk of chunks) {
2575
+ for (let i = 0; i < chunk.length; i++) {
2576
+ view.setInt16(offset, chunk[i], true);
2577
+ offset += 2;
2578
+ }
2579
+ }
2580
+ return buffer;
2581
+ }
2582
+ function writeString(view, offset, str) {
2583
+ for (let i = 0; i < str.length; i++) {
2584
+ view.setUint8(offset + i, str.charCodeAt(i));
2585
+ }
2586
+ }
1430
2587
  console.log("");
1431
2588
  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");
2589
+ console.log(" \u2551 \u{1FAC0} Pulso Mac Companion v0.2.3 \u2551");
1433
2590
  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
2591
  console.log("");
1435
2592
  setupPermissions().then(() => {
1436
2593
  connect();
2594
+ if (WAKE_WORD_ENABLED) {
2595
+ startWakeWordDetection();
2596
+ }
1437
2597
  }).catch(() => {
1438
2598
  connect();
1439
2599
  });
1440
2600
  process.on("SIGINT", () => {
1441
2601
  console.log("\n\u{1F44B} Shutting down Pulso Companion...");
2602
+ wakeWordActive = false;
1442
2603
  ws?.close(1e3, "User shutdown");
1443
2604
  process.exit(0);
1444
2605
  });
1445
2606
  process.on("SIGTERM", () => {
2607
+ wakeWordActive = false;
1446
2608
  ws?.close(1e3, "Process terminated");
1447
2609
  process.exit(0);
1448
2610
  });