@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
@@ -0,0 +1,141 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { loadConfig, saveConfig } from "../config.js";
4
+ import { CONFIG_PATH } from "../paths.js";
5
+
6
+ function maskKey(key) {
7
+ if (!key || typeof key !== "string") return "(not set)";
8
+ if (key.length <= 8) return "****";
9
+ return key.slice(0, 4) + "…" + key.slice(-4);
10
+ }
11
+
12
+ function coerceValue(value) {
13
+ if (value === "null") return null;
14
+ if (value === "true") return true;
15
+ if (value === "false") return false;
16
+ if (value !== "" && !isNaN(Number(value))) return Number(value);
17
+ return value;
18
+ }
19
+
20
+ function setValue(target, key, value) {
21
+ const dotIdx = key.indexOf(".");
22
+ if (dotIdx !== -1) {
23
+ const parent = key.slice(0, dotIdx);
24
+ const child = key.slice(dotIdx + 1);
25
+ if (!target[parent] || typeof target[parent] !== "object") {
26
+ target[parent] = {};
27
+ }
28
+ target[parent][child] = value;
29
+ return;
30
+ }
31
+ target[key] = value;
32
+ }
33
+
34
+ function showConfig() {
35
+ const config = loadConfig(process.cwd());
36
+ const display = { ...config };
37
+ if (display.llm_api_key) display.llm_api_key = maskKey(display.llm_api_key);
38
+ if (display.openrouter_api_key) display.openrouter_api_key = maskKey(display.openrouter_api_key);
39
+ console.log(JSON.stringify(display, null, 2));
40
+ }
41
+
42
+ function configSet(key, value) {
43
+ if (!key) {
44
+ console.error("Usage: voxlert config set <key> <value>");
45
+ process.exit(1);
46
+ }
47
+ const config = loadConfig(process.cwd());
48
+ setValue(config, key, coerceValue(value));
49
+ saveConfig(config);
50
+ console.log(`Set ${key} = ${JSON.stringify(coerceValue(value))}`);
51
+ }
52
+
53
+ function configSetLocal(key, value) {
54
+ if (!key) {
55
+ console.error("Usage: voxlert config local set <key> <value>");
56
+ process.exit(1);
57
+ }
58
+ const filePath = join(process.cwd(), ".voxlert.json");
59
+ let local = {};
60
+ try {
61
+ if (existsSync(filePath)) local = JSON.parse(readFileSync(filePath, "utf-8"));
62
+ } catch {
63
+ // malformed — overwrite
64
+ }
65
+ setValue(local, key, coerceValue(value));
66
+ writeFileSync(filePath, JSON.stringify(local, null, 2) + "\n");
67
+ console.log(`Set ${key} = ${JSON.stringify(coerceValue(value))} in ${filePath}`);
68
+ }
69
+
70
+ function configSetSource(name, key, value) {
71
+ if (!name || !key) {
72
+ console.error("Usage: voxlert config source <name> set <key> <value>");
73
+ process.exit(1);
74
+ }
75
+ const config = loadConfig(process.cwd());
76
+ if (!config.sources) config.sources = {};
77
+ if (!config.sources[name] || typeof config.sources[name] !== "object") config.sources[name] = {};
78
+ setValue(config.sources[name], key, coerceValue(value));
79
+ saveConfig(config);
80
+ console.log(`Set sources.${name}.${key} = ${JSON.stringify(coerceValue(value))}`);
81
+ }
82
+
83
+ export const configCommand = {
84
+ name: "config",
85
+ aliases: [],
86
+ help: [
87
+ " voxlert config Show current configuration",
88
+ " voxlert config show Show current configuration",
89
+ " voxlert config set <k> <v> Set a global config value (supports categories.X dot notation)",
90
+ " voxlert config path Print global config file path",
91
+ " voxlert config local Show local (project) config for current directory",
92
+ " voxlert config local set <k> <v> Set a value in .voxlert.json in the current directory",
93
+ " voxlert config local path Print path to local config file",
94
+ " voxlert config source <name> Show overrides for a source (cursor, claude, codex)",
95
+ " voxlert config source <name> set <k> <v> Set a per-source override (supports categories.X dot notation)",
96
+ ],
97
+ skipSetupWizard: true,
98
+ skipUpgradeCheck: false,
99
+ async run(context) {
100
+ const [, sub, ...rest] = context.args;
101
+ if (sub === "set") {
102
+ configSet(rest[0], rest.slice(1).join(" "));
103
+ return;
104
+ }
105
+ if (sub === "local") {
106
+ if (rest[0] === "set") {
107
+ configSetLocal(rest[1], rest.slice(2).join(" "));
108
+ } else if (rest[0] === "path") {
109
+ console.log(join(process.cwd(), ".voxlert.json"));
110
+ } else {
111
+ const localPath = join(process.cwd(), ".voxlert.json");
112
+ if (existsSync(localPath)) {
113
+ console.log(readFileSync(localPath, "utf-8"));
114
+ } else {
115
+ console.log("No local config found in", process.cwd());
116
+ }
117
+ }
118
+ return;
119
+ }
120
+ if (sub === "source") {
121
+ const srcName = rest[0];
122
+ if (rest[1] === "set") {
123
+ configSetSource(srcName, rest[2], rest.slice(3).join(" "));
124
+ } else {
125
+ const cfg = loadConfig(process.cwd());
126
+ const srcCfg = (cfg.sources || {})[srcName];
127
+ if (srcCfg) {
128
+ console.log(JSON.stringify(srcCfg, null, 2));
129
+ } else {
130
+ console.log(`No source overrides configured for "${srcName}"`);
131
+ }
132
+ }
133
+ return;
134
+ }
135
+ if (sub === "path") {
136
+ console.log(CONFIG_PATH);
137
+ return;
138
+ }
139
+ showConfig();
140
+ },
141
+ };
@@ -0,0 +1,20 @@
1
+ import { formatCost, resetUsage } from "../cost.js";
2
+
3
+ export const costCommand = {
4
+ name: "cost",
5
+ aliases: [],
6
+ help: [
7
+ " voxlert cost Show accumulated token usage and estimated cost",
8
+ " voxlert cost reset Clear the usage log",
9
+ ],
10
+ skipSetupWizard: false,
11
+ skipUpgradeCheck: false,
12
+ async run(context) {
13
+ if (context.args[1] === "reset") {
14
+ resetUsage();
15
+ console.log("Usage log cleared.");
16
+ return;
17
+ }
18
+ console.log(await formatCost());
19
+ },
20
+ };
@@ -0,0 +1,52 @@
1
+ import { processHookEvent } from "../voxlert.js";
2
+ import { CURSOR_TO_VOXLERT_EVENT, getLastAssistantFromTranscript } from "./hook-utils.js";
3
+
4
+ export const cursorHookCommand = {
5
+ name: "cursor-hook",
6
+ aliases: [],
7
+ help: [
8
+ " voxlert cursor-hook Process a hook event from stdin (used by Cursor hooks)",
9
+ ],
10
+ skipSetupWizard: true,
11
+ skipUpgradeCheck: true,
12
+ async run() {
13
+ let input = "";
14
+ for await (const chunk of process.stdin) {
15
+ input += chunk;
16
+ }
17
+ let payload;
18
+ try {
19
+ payload = JSON.parse(input);
20
+ } catch {
21
+ process.stdout.write("{}\n");
22
+ return;
23
+ }
24
+ const cursorEvent = payload.hook_event_name || "";
25
+ const ourEvent = CURSOR_TO_VOXLERT_EVENT[cursorEvent];
26
+ if (!ourEvent) {
27
+ process.stdout.write("{}\n");
28
+ return;
29
+ }
30
+ const workspaceRoots = payload.workspace_roots;
31
+ const cwd = Array.isArray(workspaceRoots) && workspaceRoots[0] ? workspaceRoots[0] : "";
32
+ const translated = {
33
+ ...payload,
34
+ hook_event_name: ourEvent,
35
+ cwd,
36
+ source: "cursor",
37
+ };
38
+ if (ourEvent === "Stop" && payload.transcript_path) {
39
+ const last = getLastAssistantFromTranscript(payload.transcript_path);
40
+ if (last) translated.last_assistant_message = last;
41
+ }
42
+ if (ourEvent === "PostToolUseFailure" && payload.error_message) {
43
+ translated.error_message = payload.error_message;
44
+ }
45
+ try {
46
+ await processHookEvent(translated);
47
+ } catch {
48
+ // best-effort: still return {} so Cursor doesn't error
49
+ }
50
+ process.stdout.write("{}\n");
51
+ },
52
+ };
@@ -0,0 +1,25 @@
1
+ export function formatHelp(commands, pkg) {
2
+ const sections = commands
3
+ .filter((command) => Array.isArray(command.help) && command.help.length > 0)
4
+ .map((command) => command.help.join("\n"));
5
+
6
+ return [
7
+ `voxlert v${pkg.version} — Game character voice notifications for Claude Code, Cursor, Codex, and OpenClaw`,
8
+ "",
9
+ "Usage:",
10
+ ...sections,
11
+ ].join("\n");
12
+ }
13
+
14
+ export const helpCommand = {
15
+ name: "help",
16
+ aliases: ["--help", "-h"],
17
+ help: [
18
+ " voxlert help Show this help message",
19
+ ],
20
+ skipSetupWizard: true,
21
+ skipUpgradeCheck: false,
22
+ async run(context) {
23
+ console.log(context.formatHelp());
24
+ },
25
+ };
@@ -0,0 +1,73 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "fs";
2
+ import { CONFIG_PATH, HOOK_DEBUG_LOG, IS_NPM_GLOBAL, SCRIPT_DIR, STATE_DIR } from "../paths.js";
3
+ import { getCodexConfigPath } from "../codex-config.js";
4
+
5
+ export const CURSOR_TO_VOXLERT_EVENT = {
6
+ sessionStart: "SessionStart",
7
+ sessionEnd: "SessionEnd",
8
+ stop: "Stop",
9
+ postToolUseFailure: "PostToolUseFailure",
10
+ preCompact: "PreCompact",
11
+ };
12
+
13
+ export const CODEX_NOTIFY_TYPE_TO_EVENT = {
14
+ "agent-turn-complete": "Stop",
15
+ };
16
+
17
+ export function appendHookDebugLine(message) {
18
+ try {
19
+ mkdirSync(STATE_DIR, { recursive: true });
20
+ appendFileSync(HOOK_DEBUG_LOG, `[${new Date().toISOString()}] ${message}\n`);
21
+ } catch {
22
+ // best-effort
23
+ }
24
+ }
25
+
26
+ export function stringifyForLog(value, limit = 1000) {
27
+ try {
28
+ const text = typeof value === "string" ? value : JSON.stringify(value);
29
+ if (typeof text !== "string") return String(value);
30
+ return text.length > limit ? `${text.slice(0, limit)}…` : text;
31
+ } catch {
32
+ return String(value);
33
+ }
34
+ }
35
+
36
+ export function listEnvKeys(prefixes) {
37
+ return Object.keys(process.env)
38
+ .filter((key) => prefixes.some((prefix) => key.startsWith(prefix)))
39
+ .sort();
40
+ }
41
+
42
+ export function normalizeCodexInputMessages(payload) {
43
+ const value = payload["input-messages"] || payload.input_messages || payload.inputMessages;
44
+ if (!Array.isArray(value)) return [];
45
+ return value.filter((item) => typeof item === "string" && item.trim());
46
+ }
47
+
48
+ export function getLastAssistantFromTranscript(transcriptPath) {
49
+ try {
50
+ if (!transcriptPath || !existsSync(transcriptPath)) return null;
51
+ const raw = readFileSync(transcriptPath, "utf-8");
52
+ if (!raw || !raw.trim()) return null;
53
+ const slice = raw.length > 500 ? raw.slice(-500) : raw;
54
+ return slice.trim();
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ export function getHookRuntimeInfo() {
61
+ return {
62
+ pid: process.pid,
63
+ ppid: process.ppid,
64
+ execPath: process.execPath,
65
+ node: process.version,
66
+ cwd: process.cwd(),
67
+ scriptDir: SCRIPT_DIR,
68
+ isNpmGlobal: IS_NPM_GLOBAL,
69
+ configPath: CONFIG_PATH,
70
+ codexConfigPath: getCodexConfigPath(),
71
+ envKeys: listEnvKeys(["CODEX", "VOXLERT", "OPENAI"]),
72
+ };
73
+ }
@@ -0,0 +1,27 @@
1
+ import { processHookEvent } from "../voxlert.js";
2
+ import { appendHookDebugLine, stringifyForLog } from "./hook-utils.js";
3
+
4
+ export const hookCommand = {
5
+ name: "hook",
6
+ aliases: [],
7
+ help: [
8
+ " voxlert hook Process a hook event from stdin (used by Claude Code hooks)",
9
+ ],
10
+ skipSetupWizard: true,
11
+ skipUpgradeCheck: true,
12
+ async run() {
13
+ let input = "";
14
+ for await (const chunk of process.stdin) {
15
+ input += chunk;
16
+ }
17
+ try {
18
+ appendHookDebugLine(`voxlert hook stdin received length=${input.length} raw=${stringifyForLog(input, 200)}`);
19
+ const eventData = JSON.parse(input);
20
+ if (!eventData.source) eventData.source = "claude";
21
+ appendHookDebugLine(`voxlert hook parsed eventData ${stringifyForLog(eventData)}`);
22
+ await processHookEvent(eventData);
23
+ } catch (err) {
24
+ appendHookDebugLine(`voxlert hook parse/process error ${err && err.message}`);
25
+ }
26
+ },
27
+ };
@@ -0,0 +1,45 @@
1
+ import { codexNotifyCommand } from "./codex-notify.js";
2
+ import { configCommand } from "./config.js";
3
+ import { costCommand } from "./cost.js";
4
+ import { cursorHookCommand } from "./cursor-hook.js";
5
+ import { helpCommand } from "./help.js";
6
+ import { hookCommand } from "./hook.js";
7
+ import { logCommand } from "./log.js";
8
+ import { notificationCommand } from "./notification.js";
9
+ import { packCommand } from "./pack.js";
10
+ import { setupCommand } from "./setup.js";
11
+ import { testCommand } from "./test.js";
12
+ import { uninstallCommand } from "./uninstall.js";
13
+ import { versionCommand } from "./version.js";
14
+ import { voiceCommand } from "./voice.js";
15
+ import { volumeCommand } from "./volume.js";
16
+
17
+ export const COMMANDS = [
18
+ setupCommand,
19
+ hookCommand,
20
+ cursorHookCommand,
21
+ codexNotifyCommand,
22
+ configCommand,
23
+ logCommand,
24
+ voiceCommand,
25
+ packCommand,
26
+ volumeCommand,
27
+ notificationCommand,
28
+ testCommand,
29
+ costCommand,
30
+ uninstallCommand,
31
+ helpCommand,
32
+ versionCommand,
33
+ ];
34
+
35
+ const COMMAND_LOOKUP = new Map();
36
+ for (const command of COMMANDS) {
37
+ COMMAND_LOOKUP.set(command.name, command);
38
+ for (const alias of command.aliases || []) {
39
+ COMMAND_LOOKUP.set(alias, command);
40
+ }
41
+ }
42
+
43
+ export function resolveCommand(name) {
44
+ return COMMAND_LOOKUP.get(name) || null;
45
+ }
@@ -0,0 +1,92 @@
1
+ import { existsSync, readFileSync, watchFile } from "fs";
2
+ import { loadConfig, saveConfig } from "../config.js";
3
+ import { LOG_FILE, MAIN_LOG_FILE } from "../paths.js";
4
+
5
+ const TAIL_LINES = 100;
6
+
7
+ function tailLog() {
8
+ if (!existsSync(MAIN_LOG_FILE)) {
9
+ console.log("(No activity log yet. Logging is on by default; events will appear here.)");
10
+ console.log("Path: " + MAIN_LOG_FILE);
11
+ }
12
+ let lastSize = 0;
13
+ function readNew() {
14
+ try {
15
+ const content = readFileSync(MAIN_LOG_FILE, "utf-8");
16
+ if (content.length < lastSize) lastSize = 0;
17
+ if (content.length > lastSize) {
18
+ const newPart = content.slice(lastSize);
19
+ process.stdout.write(newPart);
20
+ lastSize = content.length;
21
+ }
22
+ } catch {
23
+ // file may have been removed
24
+ }
25
+ }
26
+ function init() {
27
+ try {
28
+ const content = readFileSync(MAIN_LOG_FILE, "utf-8");
29
+ const lines = content.split("\n").filter((line) => line.length > 0);
30
+ const toShow = lines.slice(-TAIL_LINES);
31
+ toShow.forEach((line) => console.log(line));
32
+ lastSize = content.length;
33
+ } catch {
34
+ lastSize = 0;
35
+ }
36
+ }
37
+ init();
38
+ watchFile(MAIN_LOG_FILE, { interval: 500 }, () => {
39
+ readNew();
40
+ });
41
+ process.stdin.resume();
42
+ }
43
+
44
+ function setLoggingOnOff(value) {
45
+ const config = loadConfig(process.cwd());
46
+ config.logging = value === "on" || value === true;
47
+ saveConfig(config);
48
+ console.log("Activity logging: " + (config.logging ? "on" : "off"));
49
+ }
50
+
51
+ function setErrorLogOnOff(value) {
52
+ const config = loadConfig(process.cwd());
53
+ config.error_log = value === "on" || value === true;
54
+ saveConfig(config);
55
+ console.log("Error (fallback) logging: " + (config.error_log ? "on" : "off"));
56
+ }
57
+
58
+ export const logCommand = {
59
+ name: "log",
60
+ aliases: [],
61
+ help: [
62
+ " voxlert log Stream activity log (tail -f style)",
63
+ " voxlert log path Print activity log file path",
64
+ " voxlert log error-path Print error/fallback log file path",
65
+ " voxlert log on | off Enable or disable activity logging",
66
+ " voxlert log error on | off Enable or disable error (fallback) logging",
67
+ ],
68
+ skipSetupWizard: true,
69
+ skipUpgradeCheck: false,
70
+ async run(context) {
71
+ const [, sub, arg] = context.args;
72
+ if (sub === "path") {
73
+ console.log(MAIN_LOG_FILE);
74
+ } else if (sub === "error-path") {
75
+ console.log(LOG_FILE);
76
+ } else if (sub === "on" || sub === "off") {
77
+ setLoggingOnOff(sub);
78
+ } else if (sub === "error" && (arg === "on" || arg === "off")) {
79
+ setErrorLogOnOff(arg);
80
+ } else if (!sub || sub === "tail") {
81
+ tailLog();
82
+ } else {
83
+ console.log("Activity log: " + MAIN_LOG_FILE);
84
+ console.log("Error log: " + LOG_FILE);
85
+ console.log("Use: voxlert log (stream activity log)");
86
+ console.log(" voxlert log path (activity log path)");
87
+ console.log(" voxlert log error-path");
88
+ console.log(" voxlert log on | off");
89
+ console.log(" voxlert log error on | off");
90
+ }
91
+ },
92
+ };
@@ -0,0 +1,50 @@
1
+ import select from "@inquirer/select";
2
+ import { loadConfig, saveConfig } from "../config.js";
3
+
4
+ async function notificationPick() {
5
+ const config = loadConfig(process.cwd());
6
+ const platform = process.platform;
7
+ const currentOverlay = config.overlay !== false;
8
+ const currentStyle = config.overlay_style || "custom";
9
+
10
+ const choices =
11
+ platform === "darwin"
12
+ ? [
13
+ { value: "custom", name: "Custom overlay (popup)", description: "In-app style popup with gradient and icon" },
14
+ { value: "system", name: "System notification", description: "macOS Notification Center" },
15
+ { value: "off", name: "Off", description: "No popup, voice only" },
16
+ ]
17
+ : [
18
+ { value: "system", name: "System notification", description: platform === "win32" ? "Windows toast" : "notify-send / system tray" },
19
+ { value: "off", name: "Off", description: "No popup, voice only" },
20
+ ];
21
+
22
+ const currentValue =
23
+ !currentOverlay ? "off" : platform === "darwin" ? currentStyle : currentOverlay ? "system" : "off";
24
+
25
+ const chosen = await select({
26
+ message: "Notification style",
27
+ choices,
28
+ default: currentValue,
29
+ });
30
+
31
+ config.overlay = chosen !== "off";
32
+ if (chosen !== "off") config.overlay_style = chosen;
33
+ saveConfig(config);
34
+
35
+ const labels = { custom: "Custom overlay", system: "System notification", off: "Off" };
36
+ console.log(`Notifications: ${labels[chosen]}`);
37
+ }
38
+
39
+ export const notificationCommand = {
40
+ name: "notification",
41
+ aliases: ["notify"],
42
+ help: [
43
+ " voxlert notification Choose notification style (popup / system / off)",
44
+ ],
45
+ skipSetupWizard: true,
46
+ skipUpgradeCheck: false,
47
+ async run() {
48
+ await notificationPick();
49
+ },
50
+ };
@@ -0,0 +1,157 @@
1
+ import { createInterface } from "readline";
2
+ import select from "@inquirer/select";
3
+ import { loadConfig, saveConfig } from "../config.js";
4
+ import { showOverlay } from "../overlay.js";
5
+ import { listPacks, loadPack } from "../packs.js";
6
+ import { generatePhrase } from "../llm.js";
7
+ import { speakPhrase } from "../audio.js";
8
+
9
+ export async function testPipeline(text, pack) {
10
+ if (!text) {
11
+ console.error("Usage: voxlert test \"<text>\"");
12
+ process.exit(1);
13
+ }
14
+
15
+ const config = loadConfig(process.cwd());
16
+ const activePack = pack || loadPack(config);
17
+
18
+ console.log(`Input: ${text}`);
19
+ console.log(`Pack: ${activePack.name} (${activePack.id}), echo: ${activePack.echo !== false}`);
20
+ console.log("Generating phrase via LLM...");
21
+
22
+ const result = await generatePhrase(text, config, activePack.style, activePack.llm_temperature, activePack.examples);
23
+
24
+ let phrase;
25
+ if (result.phrase) {
26
+ phrase = result.phrase;
27
+ console.log(`LLM phrase: ${phrase}`);
28
+ if (result.usage) {
29
+ console.log(`Tokens: ${result.usage.total_tokens || 0} (${result.usage.prompt_tokens || 0} prompt + ${result.usage.completion_tokens || 0} completion)`);
30
+ }
31
+ } else {
32
+ console.log(`LLM failed (${result.fallbackReason}), using raw text as phrase.`);
33
+ phrase = text;
34
+ }
35
+
36
+ console.log("Sending to TTS...");
37
+ showOverlay(phrase, {
38
+ category: "notification",
39
+ packName: activePack.name,
40
+ packId: activePack.id || (config.active_pack || "sc2-adjutant"),
41
+ prefix: "Test",
42
+ config,
43
+ overlayColors: activePack.overlay_colors,
44
+ });
45
+ await speakPhrase(phrase, config, activePack);
46
+ console.log("Done.");
47
+ }
48
+
49
+ export function packList() {
50
+ const packs = listPacks();
51
+ const config = loadConfig(process.cwd());
52
+ const active = config.active_pack || "";
53
+ if (packs.length === 0) {
54
+ console.log("No voice packs found.");
55
+ return;
56
+ }
57
+ const randomMarker = active === "random" ? " (active)" : "";
58
+ console.log(` random — Random (picks a different voice each time)${randomMarker}`);
59
+ for (const pack of packs) {
60
+ const marker = pack.id === active ? " (active)" : "";
61
+ console.log(` ${pack.id} — ${pack.name}${marker}`);
62
+ }
63
+ }
64
+
65
+ export function packShow() {
66
+ const config = loadConfig(process.cwd());
67
+ console.log(JSON.stringify(loadPack(config), null, 2));
68
+ }
69
+
70
+ export async function greetWithVoice() {
71
+ const config = loadConfig(process.cwd());
72
+ const pack = loadPack(config);
73
+ await testPipeline(`You have chosen '${pack.name}' as the new voice. It is now activated.`, pack);
74
+ }
75
+
76
+ export async function packUse(packId) {
77
+ if (!packId) {
78
+ console.error("Usage: voxlert pack use <pack-id>");
79
+ process.exit(1);
80
+ }
81
+ if (packId === "random") {
82
+ const config = loadConfig(process.cwd());
83
+ config.active_pack = "random";
84
+ saveConfig(config);
85
+ console.log("Switched to pack: Random (picks a different voice each time)");
86
+ return;
87
+ }
88
+ const packs = listPacks();
89
+ const match = packs.find((pack) => pack.id === packId);
90
+ if (!match) {
91
+ console.error(`Pack "${packId}" not found. Available packs:`);
92
+ console.error(" random — Random (picks a different voice each time)");
93
+ for (const pack of packs) console.error(` ${pack.id} — ${pack.name}`);
94
+ process.exit(1);
95
+ }
96
+ const config = loadConfig(process.cwd());
97
+ config.active_pack = packId;
98
+ saveConfig(config);
99
+ console.log(`Switched to pack: ${match.name} (${packId})`);
100
+ await greetWithVoice();
101
+ }
102
+
103
+ export async function voicePick() {
104
+ const packs = listPacks();
105
+ if (packs.length === 0) {
106
+ console.log("No voice packs found.");
107
+ return;
108
+ }
109
+
110
+ const config = loadConfig(process.cwd());
111
+ const active = config.active_pack || "";
112
+ const choices = [
113
+ {
114
+ name: active === "random" ? "Random (active)" : "Random",
115
+ value: "random",
116
+ description: "Picks a different voice each time",
117
+ },
118
+ ...packs.map((pack) => ({
119
+ name: pack.id === active ? `${pack.name} (active)` : pack.name,
120
+ value: pack.id,
121
+ description: pack.id,
122
+ })),
123
+ ];
124
+
125
+ const chosen = await select({
126
+ message: "Select a voice pack",
127
+ choices,
128
+ default: active || undefined,
129
+ });
130
+
131
+ if (chosen === active) {
132
+ const selectedPack = packs.find((pack) => pack.id === chosen);
133
+ const label = chosen === "random" ? "Random" : selectedPack.name;
134
+ console.log(`Already using: ${label}`);
135
+ return;
136
+ }
137
+
138
+ config.active_pack = chosen;
139
+ saveConfig(config);
140
+ if (chosen === "random") {
141
+ console.log("Switched to: Random");
142
+ } else {
143
+ const match = packs.find((pack) => pack.id === chosen);
144
+ console.log(`Switched to: ${match.name} (${chosen})`);
145
+ }
146
+ await greetWithVoice();
147
+ }
148
+
149
+ export function askLine(prompt) {
150
+ return new Promise((resolve) => {
151
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
152
+ rl.question(prompt, (answer) => {
153
+ rl.close();
154
+ resolve(answer.trim());
155
+ });
156
+ });
157
+ }