@settinghead/voxlert 0.3.5

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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +353 -0
  3. package/assets/cortana.png +0 -0
  4. package/assets/deckard-cain.png +0 -0
  5. package/assets/demo-thumbnail.png +0 -0
  6. package/assets/glados.png +0 -0
  7. package/assets/hl-hev-suit.png +0 -0
  8. package/assets/logo.png +0 -0
  9. package/assets/red-alert-eva.png +0 -0
  10. package/assets/sc1-adjutant.gif +0 -0
  11. package/assets/sc1-kerrigan.gif +0 -0
  12. package/assets/sc1-protoss-advisor.jpg +0 -0
  13. package/assets/sc2-adjutant.jpg +0 -0
  14. package/assets/sc2-kerrigan.jpg +0 -0
  15. package/assets/ss1-shodan.png +0 -0
  16. package/config.default.json +35 -0
  17. package/openclaw-plugin/index.ts +100 -0
  18. package/openclaw-plugin/openclaw.plugin.json +21 -0
  19. package/package.json +51 -0
  20. package/packs/hl-hev-suit/pack.json +72 -0
  21. package/packs/hl-hev-suit/voice.wav +0 -0
  22. package/packs/red-alert-eva/pack.json +73 -0
  23. package/packs/red-alert-eva/voice.wav +0 -0
  24. package/packs/sc1-adjutant/pack.json +31 -0
  25. package/packs/sc1-adjutant/voice.wav +0 -0
  26. package/packs/sc1-kerrigan/pack.json +69 -0
  27. package/packs/sc1-kerrigan/voice.wav +0 -0
  28. package/packs/sc1-protoss-advisor/pack.json +70 -0
  29. package/packs/sc1-protoss-advisor/voice.wav +0 -0
  30. package/packs/sc2-adjutant/pack.json +14 -0
  31. package/packs/sc2-adjutant/voice.wav +0 -0
  32. package/packs/sc2-kerrigan/pack.json +69 -0
  33. package/packs/sc2-kerrigan/voice.wav +0 -0
  34. package/packs/sc2-protoss-advisor/pack.json +70 -0
  35. package/packs/sc2-protoss-advisor/voice.wav +0 -0
  36. package/packs/ss1-shodan/pack.json +69 -0
  37. package/packs/ss1-shodan/voice.wav +0 -0
  38. package/skills/voxlert-config/SKILL.md +44 -0
  39. package/src/activity-log.js +58 -0
  40. package/src/audio.js +381 -0
  41. package/src/cli.js +86 -0
  42. package/src/codex-config.js +149 -0
  43. package/src/commands/codex-notify.js +70 -0
  44. package/src/commands/config.js +141 -0
  45. package/src/commands/cost.js +20 -0
  46. package/src/commands/cursor-hook.js +52 -0
  47. package/src/commands/help.js +25 -0
  48. package/src/commands/hook-utils.js +73 -0
  49. package/src/commands/hook.js +27 -0
  50. package/src/commands/index.js +45 -0
  51. package/src/commands/log.js +92 -0
  52. package/src/commands/notification.js +50 -0
  53. package/src/commands/pack-helpers.js +157 -0
  54. package/src/commands/pack.js +25 -0
  55. package/src/commands/setup.js +13 -0
  56. package/src/commands/test.js +14 -0
  57. package/src/commands/uninstall.js +60 -0
  58. package/src/commands/version.js +12 -0
  59. package/src/commands/voice.js +14 -0
  60. package/src/commands/volume.js +38 -0
  61. package/src/config.js +230 -0
  62. package/src/cost.js +124 -0
  63. package/src/cursor-hooks.js +93 -0
  64. package/src/formats.js +55 -0
  65. package/src/hooks.js +129 -0
  66. package/src/llm.js +237 -0
  67. package/src/overlay.js +212 -0
  68. package/src/overlay.jxa +186 -0
  69. package/src/pack-registry.js +28 -0
  70. package/src/packs.js +182 -0
  71. package/src/paths.js +39 -0
  72. package/src/postinstall.js +13 -0
  73. package/src/providers.js +129 -0
  74. package/src/setup-ui.js +177 -0
  75. package/src/setup.js +504 -0
  76. package/src/tts-test.js +243 -0
  77. package/src/upgrade-check.js +137 -0
  78. package/src/voxlert.js +200 -0
  79. package/voxlert.sh +4 -0
package/src/hooks.js ADDED
@@ -0,0 +1,129 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "fs";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+
5
+ const SETTINGS_FILE = join(homedir(), ".claude", "settings.json");
6
+ const SKILL_SRC = join(import.meta.dirname, "..", "skills", "voxlert-config", "SKILL.md");
7
+ const SKILL_DEST_DIR = join(homedir(), ".claude", "skills", "voxlert-config");
8
+
9
+ const HOOK_EVENTS = {
10
+ Stop: { matcher: "", timeout: 10, async: true },
11
+ Notification: { matcher: "", timeout: 10, async: true },
12
+ SessionEnd: { matcher: "", timeout: 10, async: true },
13
+ UserPromptSubmit: { matcher: "", timeout: 10, async: true },
14
+ PermissionRequest: { matcher: "", timeout: 10, async: true },
15
+ PreCompact: { matcher: "", timeout: 10, async: true },
16
+ };
17
+
18
+ function loadSettings() {
19
+ try {
20
+ return JSON.parse(readFileSync(SETTINGS_FILE, "utf-8"));
21
+ } catch {
22
+ return {};
23
+ }
24
+ }
25
+
26
+ function saveSettings(settings) {
27
+ mkdirSync(join(homedir(), ".claude"), { recursive: true });
28
+ writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2) + "\n");
29
+ }
30
+
31
+ export function hasVoxlertHooks() {
32
+ const settings = loadSettings();
33
+ if (!settings.hooks || typeof settings.hooks !== "object") return false;
34
+ return Object.values(settings.hooks).some(
35
+ (blocks) =>
36
+ Array.isArray(blocks) &&
37
+ blocks.some(
38
+ (block) =>
39
+ block.hooks &&
40
+ block.hooks.some((hook) => (hook.command || "").includes("voxlert")),
41
+ ),
42
+ );
43
+ }
44
+
45
+ /**
46
+ * Register Voxlert hooks in ~/.claude/settings.json.
47
+ * Idempotent — removes any existing voxlert hooks first, then adds fresh ones.
48
+ * @param {string} command — the hook command to execute (e.g. path to voxlert.sh or "voxlert hook")
49
+ */
50
+ export function registerHooks(command) {
51
+ const settings = loadSettings();
52
+ if (!settings.hooks) settings.hooks = {};
53
+
54
+ for (const [event, cfg] of Object.entries(HOOK_EVENTS)) {
55
+ const hookEntry = {
56
+ type: "command",
57
+ command,
58
+ timeout: cfg.timeout,
59
+ };
60
+ if (cfg.async) hookEntry.async = true;
61
+
62
+ const matcherBlock = {
63
+ matcher: cfg.matcher,
64
+ hooks: [hookEntry],
65
+ };
66
+
67
+ if (!settings.hooks[event]) settings.hooks[event] = [];
68
+
69
+ // Remove any existing voxlert hooks for this event
70
+ settings.hooks[event] = settings.hooks[event].filter(
71
+ (block) =>
72
+ !block.hooks ||
73
+ !block.hooks.some((h) => (h.command || "").includes("voxlert")),
74
+ );
75
+
76
+ settings.hooks[event].push(matcherBlock);
77
+ }
78
+
79
+ saveSettings(settings);
80
+ return Object.keys(HOOK_EVENTS).length;
81
+ }
82
+
83
+ /**
84
+ * Remove all Voxlert hooks from ~/.claude/settings.json.
85
+ */
86
+ export function unregisterHooks() {
87
+ const settings = loadSettings();
88
+ if (!settings.hooks) return 0;
89
+
90
+ let removed = 0;
91
+ for (const event of Object.keys(settings.hooks)) {
92
+ const before = settings.hooks[event].length;
93
+ settings.hooks[event] = settings.hooks[event].filter(
94
+ (block) =>
95
+ !block.hooks ||
96
+ !block.hooks.some((h) => (h.command || "").includes("voxlert")),
97
+ );
98
+ removed += before - settings.hooks[event].length;
99
+ if (settings.hooks[event].length === 0) delete settings.hooks[event];
100
+ }
101
+
102
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
103
+ saveSettings(settings);
104
+ return removed;
105
+ }
106
+
107
+ /**
108
+ * Install the voxlert-config skill to ~/.claude/skills/.
109
+ */
110
+ export function installSkill() {
111
+ if (!existsSync(SKILL_SRC)) return false;
112
+ mkdirSync(SKILL_DEST_DIR, { recursive: true });
113
+ const content = readFileSync(SKILL_SRC, "utf-8");
114
+ writeFileSync(join(SKILL_DEST_DIR, "SKILL.md"), content);
115
+ return true;
116
+ }
117
+
118
+ export function hasInstalledSkill() {
119
+ return existsSync(join(SKILL_DEST_DIR, "SKILL.md"));
120
+ }
121
+
122
+ /**
123
+ * Remove the voxlert-config skill from ~/.claude/skills/.
124
+ */
125
+ export function removeSkill() {
126
+ if (!existsSync(SKILL_DEST_DIR)) return false;
127
+ rmSync(SKILL_DEST_DIR, { recursive: true });
128
+ return true;
129
+ }
package/src/llm.js ADDED
@@ -0,0 +1,237 @@
1
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync } from "fs";
2
+ import { join, dirname } from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { request as httpsRequest } from "https";
5
+ import { request as httpRequest } from "http";
6
+ import { COLLECT_DIR, STATE_DIR, USAGE_FILE } from "./paths.js";
7
+ import { buildSystemPrompt } from "./formats.js";
8
+ import { getProvider, getApiKey, getModel, formatRequestBody, parseResponse, getEndpointUrl } from "./providers.js";
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
12
+
13
+ function saveLlmPair(messages, responseText, model, config) {
14
+ if (!config.collect_llm_data) return;
15
+ try {
16
+ mkdirSync(COLLECT_DIR, { recursive: true });
17
+ const record = {
18
+ timestamp: Date.now() / 1000,
19
+ version: pkg.version,
20
+ model,
21
+ messages,
22
+ response: responseText,
23
+ };
24
+ const filename = `${Date.now()}.json`;
25
+ writeFileSync(
26
+ join(COLLECT_DIR, filename),
27
+ JSON.stringify(record, null, 2),
28
+ );
29
+ } catch {
30
+ // ignore
31
+ }
32
+ }
33
+
34
+ function logUsage(model, usage) {
35
+ if (!usage) return;
36
+ try {
37
+ mkdirSync(STATE_DIR, { recursive: true });
38
+ const record = JSON.stringify({
39
+ timestamp: new Date().toISOString(),
40
+ model,
41
+ prompt_tokens: usage.prompt_tokens || 0,
42
+ completion_tokens: usage.completion_tokens || 0,
43
+ total_tokens: usage.total_tokens || 0,
44
+ cost: usage.cost != null ? usage.cost : undefined,
45
+ });
46
+ appendFileSync(USAGE_FILE, record + "\n");
47
+ } catch {
48
+ // best-effort
49
+ }
50
+ }
51
+
52
+ export function extractContext(eventData) {
53
+ const event = eventData.hook_event_name || "";
54
+
55
+ if (event === "Stop") {
56
+ const msg = eventData.last_assistant_message || "";
57
+ if (msg) {
58
+ return `${msg.slice(0, 300)}`;
59
+ }
60
+ const inputMessages = Array.isArray(eventData.input_messages) ? eventData.input_messages : [];
61
+ if (inputMessages.length > 0) {
62
+ return inputMessages.join(" ").slice(0, 300);
63
+ }
64
+ return null;
65
+ }
66
+
67
+ if (event === "PostToolUseFailure") {
68
+ const err = eventData.error_message || "";
69
+ if (err) {
70
+ return `Tool failed: ${err.slice(0, 280)}`;
71
+ }
72
+ return null;
73
+ }
74
+
75
+ return null;
76
+ }
77
+
78
+ function cleanPhrase(raw) {
79
+ let phrase = raw.trim().replace(/^["'.,!;:]+|["'.,!;:]+$/g, "").trim();
80
+ const words = phrase.split(/\s+/).slice(0, 8);
81
+ return words.length ? words.join(" ") : null;
82
+ }
83
+
84
+ /**
85
+ * Generic cloud LLM request — works with any provider in providers.js.
86
+ */
87
+ function generatePhraseCloud(context, config, style, llmTemperature, examples) {
88
+ return new Promise((resolve) => {
89
+ const backendId = config.llm_backend || "openrouter";
90
+ const provider = getProvider(backendId);
91
+ if (!provider) return resolve({ phrase: null, fallbackReason: "unknown_provider", detail: backendId });
92
+
93
+ const apiKey = getApiKey(config);
94
+ if (!apiKey) return resolve({ phrase: null, fallbackReason: "no_api_key" });
95
+
96
+ const model = getModel(config);
97
+ const messages = [
98
+ { role: "system", content: buildSystemPrompt(style, "status-report", examples) },
99
+ { role: "user", content: context },
100
+ ];
101
+ const temperature = llmTemperature != null ? llmTemperature : 0.9;
102
+ const payload = formatRequestBody(provider, model, messages, 30, temperature);
103
+
104
+ let url;
105
+ try {
106
+ url = new URL(getEndpointUrl(provider));
107
+ } catch {
108
+ return resolve({ phrase: null, fallbackReason: "invalid_base_url", detail: provider.baseUrl });
109
+ }
110
+
111
+ const authHeaders = provider.authHeader(apiKey);
112
+ const headers = {
113
+ ...authHeaders,
114
+ "Content-Type": "application/json",
115
+ "Content-Length": Buffer.byteLength(payload),
116
+ };
117
+
118
+ const isHttps = url.protocol === "https:";
119
+ const reqFn = isHttps ? httpsRequest : httpRequest;
120
+
121
+ const req = reqFn(
122
+ url,
123
+ { method: "POST", headers, timeout: 5000 },
124
+ (res) => {
125
+ let data = "";
126
+ res.on("data", (chunk) => (data += chunk));
127
+ res.on("end", () => {
128
+ try {
129
+ const result = JSON.parse(data);
130
+ const parsed = parseResponse(provider, result);
131
+ logUsage(model, parsed.usage);
132
+ const phrase = cleanPhrase(parsed.text);
133
+ saveLlmPair(messages, parsed.text, model, config);
134
+ if (phrase) {
135
+ resolve({ phrase, fallbackReason: null, usage: parsed.usage });
136
+ } else {
137
+ resolve({ phrase: null, fallbackReason: "empty_response", detail: result, usage: parsed.usage });
138
+ }
139
+ } catch (err) {
140
+ resolve({ phrase: null, fallbackReason: "parse_error", detail: `${err.message}; body=${data.slice(0, 200)}` });
141
+ }
142
+ });
143
+ },
144
+ );
145
+
146
+ req.on("error", (err) => resolve({ phrase: null, fallbackReason: "request_error", detail: err.message }));
147
+ req.on("timeout", () => {
148
+ req.destroy();
149
+ resolve({ phrase: null, fallbackReason: "timeout" });
150
+ });
151
+ req.write(payload);
152
+ req.end();
153
+ });
154
+ }
155
+
156
+ function generatePhraseLocal(context, config, style, llmTemperature, examples) {
157
+ return new Promise((resolve) => {
158
+ const local = config.local_api || {};
159
+ const baseUrl = local.base_url || "http://localhost:8000";
160
+ const model = local.model || "default";
161
+ const maxTokens = local.max_tokens || 50;
162
+ const timeout = local.timeout || 15000;
163
+
164
+ const messages = [
165
+ { role: "system", content: buildSystemPrompt(style, "status-report", examples) },
166
+ { role: "user", content: context },
167
+ ];
168
+
169
+ const payload = JSON.stringify({
170
+ model,
171
+ messages,
172
+ max_tokens: maxTokens,
173
+ temperature: llmTemperature != null ? llmTemperature : 0.9,
174
+ });
175
+
176
+ let url;
177
+ try {
178
+ url = new URL("/v1/chat/completions", baseUrl);
179
+ } catch {
180
+ return resolve({ phrase: null, fallbackReason: "invalid_base_url", detail: baseUrl });
181
+ }
182
+
183
+ const isHttps = url.protocol === "https:";
184
+ const reqFn = isHttps ? httpsRequest : httpRequest;
185
+
186
+ const req = reqFn(
187
+ url,
188
+ {
189
+ method: "POST",
190
+ headers: {
191
+ "Content-Type": "application/json",
192
+ "Content-Length": Buffer.byteLength(payload),
193
+ },
194
+ timeout,
195
+ },
196
+ (res) => {
197
+ let data = "";
198
+ res.on("data", (chunk) => (data += chunk));
199
+ res.on("end", () => {
200
+ try {
201
+ const result = JSON.parse(data);
202
+ const usage = result.usage || null;
203
+ logUsage(model, usage);
204
+ const phrase = cleanPhrase(result.choices[0].message.content);
205
+ saveLlmPair(messages, result.choices[0].message.content, model, config);
206
+ if (phrase) {
207
+ resolve({ phrase, fallbackReason: null, usage });
208
+ } else {
209
+ resolve({ phrase: null, fallbackReason: "empty_response", detail: result, usage });
210
+ }
211
+ } catch (err) {
212
+ resolve({ phrase: null, fallbackReason: "parse_error", detail: `${err.message}; body=${data.slice(0, 200)}` });
213
+ }
214
+ });
215
+ },
216
+ );
217
+
218
+ req.on("error", (err) => resolve({ phrase: null, fallbackReason: "request_error", detail: err.message }));
219
+ req.on("timeout", () => {
220
+ req.destroy();
221
+ resolve({ phrase: null, fallbackReason: "timeout" });
222
+ });
223
+ req.write(payload);
224
+ req.end();
225
+ });
226
+ }
227
+
228
+ // Backward-compatible export
229
+ export { generatePhraseCloud as generatePhraseLlm };
230
+
231
+ export function generatePhrase(context, config, style, llmTemperature, examples) {
232
+ const backend = config.llm_backend || "openrouter";
233
+ if (backend === "local") {
234
+ return generatePhraseLocal(context, config, style, llmTemperature, examples);
235
+ }
236
+ return generatePhraseCloud(context, config, style, llmTemperature, examples);
237
+ }
package/src/overlay.js ADDED
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Voxlert — On-screen notification wrapper.
3
+ *
4
+ * - macOS: Custom Cocoa overlay via JXA (overlay.jxa) — gradient, icon, stacking.
5
+ * - Windows/Linux: System notifications via node-notifier (native toasts / notify-send).
6
+ * No-op when overlay is disabled in config.
7
+ */
8
+
9
+ import { join, dirname } from "path";
10
+ import { existsSync, mkdirSync, rmdirSync, readdirSync, statSync } from "fs";
11
+ import { spawn } from "child_process";
12
+ import { fileURLToPath } from "url";
13
+ import { createRequire } from "module";
14
+
15
+ const require = createRequire(import.meta.url);
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const SCRIPT_DIR = dirname(__dirname);
18
+ const JXA_SCRIPT = join(__dirname, "overlay.jxa");
19
+ const SLOT_DIR = "/tmp/voxlert-popups";
20
+ const MAX_SLOTS = 5;
21
+ const STALE_MS = 60_000;
22
+
23
+ const OVERLAY_DEBUG = process.env.VOXLERT_OVERLAY_DEBUG === "1" || process.env.VOXLERT_OVERLAY_DEBUG === "true";
24
+ function overlayDebug(msg, ...args) {
25
+ if (OVERLAY_DEBUG) console.error("[voxlert overlay]", msg, ...args);
26
+ }
27
+
28
+ // Default gradient (dark charcoal)
29
+ const DEFAULT_COLORS = [[0.15, 0.15, 0.2], [0.1, 0.1, 0.15]];
30
+
31
+ /**
32
+ * Acquire a slot for vertical stacking. Uses mkdir for race-safe locking.
33
+ * Returns slot index (0-based) or -1 if all slots taken.
34
+ */
35
+ function acquireSlot() {
36
+ mkdirSync(SLOT_DIR, { recursive: true });
37
+
38
+ // Clean stale slots first
39
+ try {
40
+ const entries = readdirSync(SLOT_DIR);
41
+ const now = Date.now();
42
+ for (const entry of entries) {
43
+ if (!entry.startsWith("slot-")) continue;
44
+ try {
45
+ const st = statSync(join(SLOT_DIR, entry));
46
+ if (now - st.mtimeMs > STALE_MS) {
47
+ rmdirSync(join(SLOT_DIR, entry));
48
+ }
49
+ } catch {
50
+ // already removed
51
+ }
52
+ }
53
+ } catch {
54
+ // best-effort cleanup
55
+ }
56
+
57
+ // Try to acquire a slot
58
+ for (let i = 0; i < MAX_SLOTS; i++) {
59
+ const slotPath = join(SLOT_DIR, `slot-${i}`);
60
+ try {
61
+ mkdirSync(slotPath);
62
+ return i;
63
+ } catch {
64
+ // slot taken, try next
65
+ }
66
+ }
67
+ return -1;
68
+ }
69
+
70
+ /**
71
+ * Release a slot after dismiss + buffer time.
72
+ */
73
+ function releaseSlotAfter(slot, delaySecs) {
74
+ const slotPath = join(SLOT_DIR, `slot-${slot}`);
75
+ setTimeout(() => {
76
+ try {
77
+ rmdirSync(slotPath);
78
+ } catch {
79
+ // already removed
80
+ }
81
+ }, delaySecs * 1000);
82
+ }
83
+
84
+ /**
85
+ * Resolve icon path for a pack. Looks for assets/{packId}.{png,jpg,gif}.
86
+ */
87
+ function resolveIcon(packId) {
88
+ if (!packId) return "";
89
+ const exts = ["png", "jpg", "gif"];
90
+ for (const ext of exts) {
91
+ const p = join(SCRIPT_DIR, "assets", `${packId}.${ext}`);
92
+ if (existsSync(p)) return p;
93
+ }
94
+ return "";
95
+ }
96
+
97
+ /**
98
+ * Show an overlay notification.
99
+ * Never throws — any failure is caught and logged; the rest of the pipeline (e.g. audio) continues.
100
+ * Fire-and-forget: spawns osascript (macOS) or uses node-notifier and returns immediately.
101
+ *
102
+ * @param {string} phrase - The phrase to display
103
+ * @param {object} opts
104
+ * @param {string} opts.category - Event category
105
+ * @param {string} opts.packName - Display name of the voice pack
106
+ * @param {string} opts.packId - Pack identifier (for icon lookup)
107
+ * @param {string} opts.prefix - Resolved prefix string
108
+ * @param {object} opts.config - Loaded config object
109
+ * @param {Array} [opts.overlayColors] - Gradient colors from pack
110
+ */
111
+ export function showOverlay(phrase, { category, packName, packId, prefix, config, overlayColors } = {}) {
112
+ overlayDebug("showOverlay called", { phrase: phrase?.slice(0, 40), platform: process.platform, configOverlay: config?.overlay });
113
+
114
+ if (config && config.overlay === false) {
115
+ overlayDebug("skip: overlay disabled in config");
116
+ return;
117
+ }
118
+
119
+ try {
120
+ runOverlay(phrase, { category, packName, packId, prefix, config, overlayColors });
121
+ } catch (err) {
122
+ overlayDebug("notification failed (non-fatal)", err?.message || err);
123
+ }
124
+ }
125
+
126
+ function runOverlay(phrase, { packName, packId, prefix, config, overlayColors } = {}) {
127
+ const platform = process.platform;
128
+ const style = config?.overlay_style || "custom";
129
+
130
+ // Use system notification (node-notifier) when style is "system" or on non-darwin (no custom option there)
131
+ const useSystem = style === "system" || platform === "win32" || platform === "linux";
132
+
133
+ // Build subtitle and display phrase (shared)
134
+ let subtitle = "";
135
+ const parts = [];
136
+ if (prefix) parts.push(prefix);
137
+ if (packName) parts.push(String(packName).toUpperCase());
138
+ subtitle = parts.join(" · ");
139
+ let displayPhrase = phrase;
140
+ if (prefix && phrase.startsWith(prefix + "; ")) {
141
+ displayPhrase = phrase.slice(prefix.length + 2);
142
+ }
143
+ const iconPath = resolveIcon(packId);
144
+
145
+ // --- System notification (node-notifier): Windows, Linux, or macOS when overlay_style === "system" ---
146
+ if (useSystem) {
147
+ try {
148
+ const notifier = require("node-notifier");
149
+ notifier.notify({
150
+ title: subtitle || "Voxlert",
151
+ message: displayPhrase || phrase,
152
+ icon: iconPath || undefined,
153
+ sound: false,
154
+ });
155
+ overlayDebug("node-notifier sent");
156
+ } catch (err) {
157
+ overlayDebug("node-notifier failed", err?.message || err);
158
+ }
159
+ return;
160
+ }
161
+
162
+ // --- macOS only: custom JXA overlay (when overlay_style === "custom") ---
163
+ if (platform !== "darwin") {
164
+ overlayDebug("skip: unsupported platform");
165
+ return;
166
+ }
167
+
168
+ if (!existsSync(JXA_SCRIPT)) {
169
+ overlayDebug("skip: JXA script not found", JXA_SCRIPT);
170
+ return;
171
+ }
172
+ overlayDebug("JXA script exists", JXA_SCRIPT);
173
+
174
+ const dismissSecs = (config && config.overlay_dismiss) || 4;
175
+ const colors = overlayColors || DEFAULT_COLORS;
176
+
177
+ // Acquire stacking slot
178
+ const slot = acquireSlot();
179
+ if (slot < 0) {
180
+ overlayDebug("skip: no free slot (all slots taken)");
181
+ return;
182
+ }
183
+ overlayDebug("acquired slot", slot);
184
+
185
+ // Spawn osascript detached
186
+ const args = [
187
+ "-l", "JavaScript",
188
+ JXA_SCRIPT,
189
+ displayPhrase,
190
+ JSON.stringify(colors),
191
+ iconPath,
192
+ String(slot),
193
+ String(dismissSecs),
194
+ subtitle,
195
+ ];
196
+ overlayDebug("spawning osascript", args.slice(0, 4), "...");
197
+
198
+ try {
199
+ const child = spawn("osascript", args, {
200
+ stdio: "ignore",
201
+ detached: true,
202
+ });
203
+ child.unref();
204
+ overlayDebug("osascript spawned pid", child.pid);
205
+ } catch (err) {
206
+ overlayDebug("osascript spawn failed", err?.message || err);
207
+ // best-effort — don't break audio pipeline
208
+ }
209
+
210
+ // Schedule slot release after dismiss + 2s buffer
211
+ releaseSlotAfter(slot, dismissSecs + 2);
212
+ }