@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.
- package/LICENSE +21 -0
- package/README.md +353 -0
- package/assets/cortana.png +0 -0
- package/assets/deckard-cain.png +0 -0
- package/assets/demo-thumbnail.png +0 -0
- package/assets/glados.png +0 -0
- package/assets/hl-hev-suit.png +0 -0
- package/assets/logo.png +0 -0
- package/assets/red-alert-eva.png +0 -0
- package/assets/sc1-adjutant.gif +0 -0
- package/assets/sc1-kerrigan.gif +0 -0
- package/assets/sc1-protoss-advisor.jpg +0 -0
- package/assets/sc2-adjutant.jpg +0 -0
- package/assets/sc2-kerrigan.jpg +0 -0
- package/assets/ss1-shodan.png +0 -0
- package/config.default.json +35 -0
- package/openclaw-plugin/index.ts +100 -0
- package/openclaw-plugin/openclaw.plugin.json +21 -0
- package/package.json +51 -0
- package/packs/hl-hev-suit/pack.json +72 -0
- package/packs/hl-hev-suit/voice.wav +0 -0
- package/packs/red-alert-eva/pack.json +73 -0
- package/packs/red-alert-eva/voice.wav +0 -0
- package/packs/sc1-adjutant/pack.json +31 -0
- package/packs/sc1-adjutant/voice.wav +0 -0
- package/packs/sc1-kerrigan/pack.json +69 -0
- package/packs/sc1-kerrigan/voice.wav +0 -0
- package/packs/sc1-protoss-advisor/pack.json +70 -0
- package/packs/sc1-protoss-advisor/voice.wav +0 -0
- package/packs/sc2-adjutant/pack.json +14 -0
- package/packs/sc2-adjutant/voice.wav +0 -0
- package/packs/sc2-kerrigan/pack.json +69 -0
- package/packs/sc2-kerrigan/voice.wav +0 -0
- package/packs/sc2-protoss-advisor/pack.json +70 -0
- package/packs/sc2-protoss-advisor/voice.wav +0 -0
- package/packs/ss1-shodan/pack.json +69 -0
- package/packs/ss1-shodan/voice.wav +0 -0
- package/skills/voxlert-config/SKILL.md +44 -0
- package/src/activity-log.js +58 -0
- package/src/audio.js +381 -0
- package/src/cli.js +86 -0
- package/src/codex-config.js +149 -0
- package/src/commands/codex-notify.js +70 -0
- package/src/commands/config.js +141 -0
- package/src/commands/cost.js +20 -0
- package/src/commands/cursor-hook.js +52 -0
- package/src/commands/help.js +25 -0
- package/src/commands/hook-utils.js +73 -0
- package/src/commands/hook.js +27 -0
- package/src/commands/index.js +45 -0
- package/src/commands/log.js +92 -0
- package/src/commands/notification.js +50 -0
- package/src/commands/pack-helpers.js +157 -0
- package/src/commands/pack.js +25 -0
- package/src/commands/setup.js +13 -0
- package/src/commands/test.js +14 -0
- package/src/commands/uninstall.js +60 -0
- package/src/commands/version.js +12 -0
- package/src/commands/voice.js +14 -0
- package/src/commands/volume.js +38 -0
- package/src/config.js +230 -0
- package/src/cost.js +124 -0
- package/src/cursor-hooks.js +93 -0
- package/src/formats.js +55 -0
- package/src/hooks.js +129 -0
- package/src/llm.js +237 -0
- package/src/overlay.js +212 -0
- package/src/overlay.jxa +186 -0
- package/src/pack-registry.js +28 -0
- package/src/packs.js +182 -0
- package/src/paths.js +39 -0
- package/src/postinstall.js +13 -0
- package/src/providers.js +129 -0
- package/src/setup-ui.js +177 -0
- package/src/setup.js +504 -0
- package/src/tts-test.js +243 -0
- package/src/upgrade-check.js +137 -0
- package/src/voxlert.js +200 -0
- package/voxlert.sh +4 -0
package/src/tts-test.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { writeFileSync, unlinkSync, existsSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { request as httpsRequest } from "https";
|
|
4
|
+
import { request as httpRequest } from "http";
|
|
5
|
+
import confirm from "@inquirer/confirm";
|
|
6
|
+
import select from "@inquirer/select";
|
|
7
|
+
import { playFile } from "./audio.js";
|
|
8
|
+
import { loadPack } from "./packs.js";
|
|
9
|
+
import { CACHE_DIR } from "./paths.js";
|
|
10
|
+
import { printStatus, printSuccess, printWarning } from "./setup-ui.js";
|
|
11
|
+
|
|
12
|
+
const TTS_TEST_PHRASE = "Voxlert TTS check. If you hear this voice, your setup is working.";
|
|
13
|
+
|
|
14
|
+
export const QWEN_DOCS_URL = "https://github.com/settinghead/voxlert/blob/main/qwen3-tts-server/README.md";
|
|
15
|
+
export const CHATTERBOX_DOCS_URL = "https://github.com/settinghead/voxlert/blob/main/docs/chatterbox-tts.md";
|
|
16
|
+
|
|
17
|
+
function getRequestFunction(url) {
|
|
18
|
+
const urlObj = new URL(url);
|
|
19
|
+
return urlObj.protocol === "https:" ? httpsRequest : httpRequest;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getTtsEndpoint(config, backend) {
|
|
23
|
+
const base = backend === "qwen"
|
|
24
|
+
? (config.qwen_tts_url || "http://localhost:8100")
|
|
25
|
+
: (config.chatterbox_url || "http://localhost:8004");
|
|
26
|
+
return `${base}/tts`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getTtsDocsUrl(backend) {
|
|
30
|
+
return backend === "qwen" ? QWEN_DOCS_URL : CHATTERBOX_DOCS_URL;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getTtsLabel(backend) {
|
|
34
|
+
return backend === "qwen" ? "Qwen TTS" : "Chatterbox";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getTtsChoices(currentBackend) {
|
|
38
|
+
return [
|
|
39
|
+
{
|
|
40
|
+
name: currentBackend === "qwen"
|
|
41
|
+
? "Qwen TTS (recommended, current, more natural voice, port 8100)"
|
|
42
|
+
: "Qwen TTS (recommended, more natural voice, port 8100)",
|
|
43
|
+
value: "qwen",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: currentBackend === "chatterbox"
|
|
47
|
+
? "Chatterbox (current, port 8004)"
|
|
48
|
+
: "Chatterbox (port 8004)",
|
|
49
|
+
value: "chatterbox",
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function probeTtsBackend(config, backend, timeoutMs = 2000) {
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
const healthUrl = backend === "qwen"
|
|
57
|
+
? `${config.qwen_tts_url || "http://localhost:8100"}/health`
|
|
58
|
+
: `${config.chatterbox_url || "http://localhost:8004"}/health`;
|
|
59
|
+
|
|
60
|
+
let urlObj;
|
|
61
|
+
try {
|
|
62
|
+
urlObj = new URL(healthUrl);
|
|
63
|
+
} catch {
|
|
64
|
+
return resolve(false);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const reqFn = urlObj.protocol === "https:" ? httpsRequest : httpRequest;
|
|
68
|
+
const req = reqFn(urlObj, { method: "GET", timeout: timeoutMs }, (res) => {
|
|
69
|
+
res.resume();
|
|
70
|
+
resolve(true);
|
|
71
|
+
});
|
|
72
|
+
req.on("error", () => resolve(false));
|
|
73
|
+
req.on("timeout", () => {
|
|
74
|
+
req.destroy();
|
|
75
|
+
resolve(false);
|
|
76
|
+
});
|
|
77
|
+
req.end();
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function requestTtsAudio(config, backend, pack) {
|
|
82
|
+
return new Promise((resolve) => {
|
|
83
|
+
const endpoint = getTtsEndpoint(config, backend);
|
|
84
|
+
const body = backend === "qwen"
|
|
85
|
+
? { text: TTS_TEST_PHRASE, pack_id: pack.id || "_default" }
|
|
86
|
+
: {
|
|
87
|
+
text: TTS_TEST_PHRASE,
|
|
88
|
+
voice_mode: "predefined",
|
|
89
|
+
predefined_voice_id: pack.voicePath || config.voice || "default.wav",
|
|
90
|
+
output_format: "wav",
|
|
91
|
+
...(pack.tts_params || {}),
|
|
92
|
+
};
|
|
93
|
+
const payload = JSON.stringify(body);
|
|
94
|
+
|
|
95
|
+
let reqFn;
|
|
96
|
+
try {
|
|
97
|
+
reqFn = getRequestFunction(endpoint);
|
|
98
|
+
} catch {
|
|
99
|
+
return resolve(null);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const req = reqFn(
|
|
103
|
+
endpoint,
|
|
104
|
+
{
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: {
|
|
107
|
+
"Content-Type": "application/json",
|
|
108
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
109
|
+
},
|
|
110
|
+
timeout: backend === "qwen" ? 30000 : 8000,
|
|
111
|
+
},
|
|
112
|
+
(res) => {
|
|
113
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
114
|
+
res.resume();
|
|
115
|
+
return resolve(null);
|
|
116
|
+
}
|
|
117
|
+
const chunks = [];
|
|
118
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
119
|
+
res.on("end", () => resolve(Buffer.concat(chunks)));
|
|
120
|
+
res.on("error", () => resolve(null));
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
req.on("error", () => resolve(null));
|
|
125
|
+
req.on("timeout", () => {
|
|
126
|
+
req.destroy();
|
|
127
|
+
resolve(null);
|
|
128
|
+
});
|
|
129
|
+
req.write(payload);
|
|
130
|
+
req.end();
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function runTtsSample(config, backend) {
|
|
135
|
+
const pack = loadPack(config);
|
|
136
|
+
const outPath = join(CACHE_DIR, `setup-tts-test-${Date.now()}.wav`);
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const audio = await requestTtsAudio(config, backend, pack);
|
|
140
|
+
if (!audio || audio.length === 0) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
writeFileSync(outPath, audio);
|
|
144
|
+
await playFile(outPath, config.volume ?? 0.5);
|
|
145
|
+
return true;
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
} finally {
|
|
149
|
+
if (existsSync(outPath)) {
|
|
150
|
+
try {
|
|
151
|
+
unlinkSync(outPath);
|
|
152
|
+
} catch {
|
|
153
|
+
// ignore cleanup failures
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function chooseTtsBackend(config, { qwenUp, chatterboxUp }) {
|
|
160
|
+
if (qwenUp && chatterboxUp) {
|
|
161
|
+
return select({
|
|
162
|
+
message: "Both TTS servers detected. Which one to use? Qwen TTS is recommended for a more natural voice.",
|
|
163
|
+
choices: getTtsChoices(config.tts_backend),
|
|
164
|
+
default: config.tts_backend || "qwen",
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (qwenUp) {
|
|
169
|
+
printSuccess("Using Qwen TTS.");
|
|
170
|
+
return "qwen";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (chatterboxUp) {
|
|
174
|
+
printSuccess("Using Chatterbox.");
|
|
175
|
+
return "chatterbox";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return select({
|
|
179
|
+
message: "Choose the TTS backend you are setting up. Qwen TTS is recommended for a more natural voice.",
|
|
180
|
+
choices: getTtsChoices(config.tts_backend),
|
|
181
|
+
default: config.tts_backend || "qwen",
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function verifyTtsSetup(config, backend) {
|
|
186
|
+
const label = getTtsLabel(backend);
|
|
187
|
+
const docsUrl = getTtsDocsUrl(backend);
|
|
188
|
+
|
|
189
|
+
while (true) {
|
|
190
|
+
console.log("");
|
|
191
|
+
process.stdout.write(` Checking ${label}... `);
|
|
192
|
+
const backendUp = await probeTtsBackend(config, backend);
|
|
193
|
+
console.log(backendUp ? "detected!" : "not running");
|
|
194
|
+
|
|
195
|
+
if (!backendUp) {
|
|
196
|
+
printWarning(`${label} is not running yet. Finish that setup and come back here to try again.`);
|
|
197
|
+
printStatus(`${label} docs`, docsUrl);
|
|
198
|
+
console.log("");
|
|
199
|
+
await confirm({
|
|
200
|
+
message: `Press enter after ${label} is running to test again.`,
|
|
201
|
+
default: true,
|
|
202
|
+
});
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
process.stdout.write(` Testing ${label} audio... `);
|
|
207
|
+
const ok = await runTtsSample(config, backend);
|
|
208
|
+
console.log(ok ? "played." : "failed.");
|
|
209
|
+
|
|
210
|
+
if (!ok) {
|
|
211
|
+
printWarning(`The ${label} test failed. Keep the docs open, fix the server, and try again.`);
|
|
212
|
+
printStatus(`${label} docs`, docsUrl);
|
|
213
|
+
console.log("");
|
|
214
|
+
await confirm({
|
|
215
|
+
message: `Press enter to retry the ${label} test.`,
|
|
216
|
+
default: true,
|
|
217
|
+
});
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const heardVoice = await select({
|
|
222
|
+
message: `Did you hear the ${label} voice test?`,
|
|
223
|
+
choices: [
|
|
224
|
+
{ name: "Yes", value: true },
|
|
225
|
+
{ name: "No", value: false },
|
|
226
|
+
],
|
|
227
|
+
default: true,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
if (heardVoice) {
|
|
231
|
+
printSuccess(`${label} verified.`);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
printWarning("No workaround here. Keep troubleshooting and retry until you hear the voice.");
|
|
236
|
+
printStatus(`${label} docs`, docsUrl);
|
|
237
|
+
console.log("");
|
|
238
|
+
await confirm({
|
|
239
|
+
message: "Press enter to play the test again.",
|
|
240
|
+
default: true,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upgrade check: fetch latest version from npm registry and show a terminal notification
|
|
3
|
+
* when a newer version is available. Uses a cache file to avoid hitting the registry every run.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
import { STATE_DIR } from "./paths.js";
|
|
9
|
+
|
|
10
|
+
const NPM_REGISTRY = "https://registry.npmjs.org";
|
|
11
|
+
const CACHE_FILE = join(STATE_DIR, "upgrade-check.json");
|
|
12
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
13
|
+
|
|
14
|
+
/** Parse "x.y.z" into [x, y, z]; non-numeric parts become 0. */
|
|
15
|
+
function parseVersion(v) {
|
|
16
|
+
const parts = String(v).split(".").map((n) => parseInt(n, 10) || 0);
|
|
17
|
+
return [parts[0] || 0, parts[1] || 0, parts[2] || 0];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** True if latest > current (semver-ish). */
|
|
21
|
+
function isNewer(latest, current) {
|
|
22
|
+
const [lMajor, lMinor, lPatch] = parseVersion(latest);
|
|
23
|
+
const [cMajor, cMinor, cPatch] = parseVersion(current);
|
|
24
|
+
if (lMajor !== cMajor) return lMajor > cMajor;
|
|
25
|
+
if (lMinor !== cMinor) return lMinor > cMinor;
|
|
26
|
+
return lPatch > cPatch;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Fetch latest version from npm registry.
|
|
31
|
+
* @param {string} packageName - e.g. "@settinghead/voxlert"
|
|
32
|
+
* @returns {{ latest: string } | null}
|
|
33
|
+
*/
|
|
34
|
+
async function fetchLatestVersion(packageName) {
|
|
35
|
+
try {
|
|
36
|
+
const url = `${NPM_REGISTRY}/${encodeURIComponent(packageName)}`;
|
|
37
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
|
38
|
+
if (!res.ok) return null;
|
|
39
|
+
const data = await res.json();
|
|
40
|
+
const latest = data["dist-tags"]?.latest;
|
|
41
|
+
return typeof latest === "string" ? { latest } : null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get upgrade info: use cache if fresh, else fetch from registry.
|
|
49
|
+
* @param {string} currentVersion - e.g. "0.3.2"
|
|
50
|
+
* @param {string} packageName - e.g. "@settinghead/voxlert"
|
|
51
|
+
* @returns {Promise<{ current: string, latest: string } | null>} null if no upgrade or error
|
|
52
|
+
*/
|
|
53
|
+
export async function getUpgradeInfo(currentVersion, packageName) {
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
let latest = null;
|
|
56
|
+
|
|
57
|
+
if (existsSync(CACHE_FILE)) {
|
|
58
|
+
try {
|
|
59
|
+
const cache = JSON.parse(readFileSync(CACHE_FILE, "utf-8"));
|
|
60
|
+
if (now - (cache.checkedAt || 0) < CACHE_TTL_MS && cache.latest)
|
|
61
|
+
latest = cache.latest;
|
|
62
|
+
} catch {}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (latest === null) {
|
|
66
|
+
const result = await fetchLatestVersion(packageName);
|
|
67
|
+
if (!result) return null;
|
|
68
|
+
latest = result.latest;
|
|
69
|
+
try {
|
|
70
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
71
|
+
writeFileSync(
|
|
72
|
+
CACHE_FILE,
|
|
73
|
+
JSON.stringify({ latest, checkedAt: now }),
|
|
74
|
+
"utf-8"
|
|
75
|
+
);
|
|
76
|
+
} catch {}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!isNewer(latest, currentVersion)) return null;
|
|
80
|
+
return { current: currentVersion, latest };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** ANSI codes for terminal styling (no-op when not TTY). */
|
|
84
|
+
const ansi = {
|
|
85
|
+
reset: "\x1b[0m",
|
|
86
|
+
bold: "\x1b[1m",
|
|
87
|
+
dim: "\x1b[2m",
|
|
88
|
+
cyan: "\x1b[36m",
|
|
89
|
+
yellow: "\x1b[33m",
|
|
90
|
+
white: "\x1b[37m",
|
|
91
|
+
grey: "\x1b[90m",
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
function stripAnsi(str) {
|
|
95
|
+
return str.replace(/\x1b\[[0-9;]*m/g, "");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Print upgrade notification to stdout, styled like a terminal box:
|
|
100
|
+
* dark background hint, cyan "Update available!", version range, command, release notes link.
|
|
101
|
+
* Only uses colors when stdout is TTY.
|
|
102
|
+
*/
|
|
103
|
+
export function printUpgradeNotification(info, options = {}) {
|
|
104
|
+
const { packageName = "@settinghead/voxlert", releaseNotesUrl } = options;
|
|
105
|
+
const useColor = process.stdout.isTTY;
|
|
106
|
+
const c = useColor ? ansi : { reset: "", bold: "", dim: "", cyan: "", yellow: "", white: "", grey: "" };
|
|
107
|
+
|
|
108
|
+
const installCmd = `npm install -g ${packageName}`;
|
|
109
|
+
const url =
|
|
110
|
+
releaseNotesUrl ||
|
|
111
|
+
`https://github.com/settinghead/voxlert/releases/latest`;
|
|
112
|
+
|
|
113
|
+
const line1 = `${c.yellow}✨${c.reset} ${c.cyan}Update available!${c.reset} ${info.current} -> ${info.latest}`;
|
|
114
|
+
const line2 = `Run ${installCmd} to update.`;
|
|
115
|
+
const line3 = "See full release notes:";
|
|
116
|
+
const line4 = `${c.cyan}${url}${c.reset}`;
|
|
117
|
+
|
|
118
|
+
const padding = 2;
|
|
119
|
+
const maxLen = Math.max(
|
|
120
|
+
stripAnsi(line1).length,
|
|
121
|
+
line2.length,
|
|
122
|
+
line3.length,
|
|
123
|
+
url.length
|
|
124
|
+
);
|
|
125
|
+
const width = maxLen + padding * 2;
|
|
126
|
+
const border = "─".repeat(width);
|
|
127
|
+
const pad = (s) => " ".repeat(Math.max(0, width - 2 - stripAnsi(s).length));
|
|
128
|
+
|
|
129
|
+
console.log("");
|
|
130
|
+
console.log(`${c.grey}┌${border}┐${c.reset}`);
|
|
131
|
+
console.log(`${c.grey}│${c.reset}${" ".repeat(padding)}${line1}${pad(line1)}${c.grey}│${c.reset}`);
|
|
132
|
+
console.log(`${c.grey}│${c.reset}${" ".repeat(padding)}${line2}${pad(line2)}${c.grey}│${c.reset}`);
|
|
133
|
+
console.log(`${c.grey}│${c.reset}${" ".repeat(padding)}${line3}${pad(line3)}${c.grey}│${c.reset}`);
|
|
134
|
+
console.log(`${c.grey}│${c.reset}${" ".repeat(padding)}${line4}${pad(line4)}${c.grey}│${c.reset}`);
|
|
135
|
+
console.log(`${c.grey}└${border}┘${c.reset}`);
|
|
136
|
+
console.log("");
|
|
137
|
+
}
|
package/src/voxlert.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Voxlert - Game character voice notifications for Claude Code.
|
|
4
|
+
*
|
|
5
|
+
* Generates contextual 2-8 word phrases via OpenRouter LLM,
|
|
6
|
+
* speaks them through a local Chatterbox TTS server.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { basename } from "path";
|
|
10
|
+
import { appendFileSync, mkdirSync } from "fs";
|
|
11
|
+
import { loadConfig, EVENT_MAP, CONTEXTUAL_EVENTS, FALLBACK_PHRASES } from "./config.js";
|
|
12
|
+
import { extractContext, generatePhrase } from "./llm.js";
|
|
13
|
+
import { speakPhrase } from "./audio.js";
|
|
14
|
+
import { showOverlay } from "./overlay.js";
|
|
15
|
+
import { loadPack } from "./packs.js";
|
|
16
|
+
import { STATE_DIR, LOG_FILE, HOOK_DEBUG_LOG } from "./paths.js";
|
|
17
|
+
import { appendLog } from "./activity-log.js";
|
|
18
|
+
|
|
19
|
+
function debugLog(msg, data) {
|
|
20
|
+
try {
|
|
21
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
22
|
+
const line = data !== undefined
|
|
23
|
+
? `[${new Date().toISOString()}] ${msg} ${JSON.stringify(data)}\n`
|
|
24
|
+
: `[${new Date().toISOString()}] ${msg}\n`;
|
|
25
|
+
appendFileSync(HOOK_DEBUG_LOG, line);
|
|
26
|
+
} catch {
|
|
27
|
+
// best-effort
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function logFallback(eventName, reason, detail) {
|
|
32
|
+
try {
|
|
33
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
34
|
+
const ts = new Date().toISOString();
|
|
35
|
+
const line = detail
|
|
36
|
+
? `[${ts}] event=${eventName} reason=${reason} detail=${typeof detail === "string" ? detail : JSON.stringify(detail)}\n`
|
|
37
|
+
: `[${ts}] event=${eventName} reason=${reason}\n`;
|
|
38
|
+
appendFileSync(LOG_FILE, line);
|
|
39
|
+
} catch {
|
|
40
|
+
// best-effort logging
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Process a hook event from parsed JSON input.
|
|
46
|
+
* Exported so it can be called from the CLI `voxlert hook` command.
|
|
47
|
+
*/
|
|
48
|
+
export async function processHookEvent(eventData) {
|
|
49
|
+
const source = eventData.source || "claude";
|
|
50
|
+
debugLog("processHookEvent entered", { source, hook_event_name: eventData.hook_event_name, cwd: eventData.cwd });
|
|
51
|
+
const cwd = eventData.cwd || "";
|
|
52
|
+
const config = loadConfig(cwd || undefined);
|
|
53
|
+
debugLog("processHookEvent config loaded", {
|
|
54
|
+
source,
|
|
55
|
+
cwd,
|
|
56
|
+
enabled: config.enabled !== false,
|
|
57
|
+
active_pack: config.active_pack || "",
|
|
58
|
+
llm_backend: config.llm_backend || "",
|
|
59
|
+
tts_backend: config.tts_backend || "",
|
|
60
|
+
overlay: config.overlay === true,
|
|
61
|
+
prefix: config.prefix !== undefined ? config.prefix : "${dirname}",
|
|
62
|
+
task_complete_enabled: config.categories?.["task.complete"] !== false,
|
|
63
|
+
task_error_enabled: config.categories?.["task.error"] !== false,
|
|
64
|
+
});
|
|
65
|
+
if (config.enabled === false) {
|
|
66
|
+
debugLog("processHookEvent skip: config.enabled === false", { source });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const eventName = eventData.hook_event_name || "";
|
|
71
|
+
const category = EVENT_MAP[eventName];
|
|
72
|
+
if (!category) {
|
|
73
|
+
debugLog("processHookEvent skip: no category for event", { source, eventName });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Check source-specific overrides (config.sources.<source>.enabled / .categories)
|
|
78
|
+
const sourceOverride = (config.sources || {})[source] || {};
|
|
79
|
+
if (sourceOverride.enabled === false) {
|
|
80
|
+
debugLog("processHookEvent skip: source disabled", { source });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Check if category is enabled (source-specific categories take precedence)
|
|
85
|
+
const categories = { ...(config.categories || {}), ...(sourceOverride.categories || {}) };
|
|
86
|
+
if (categories[category] === false) {
|
|
87
|
+
debugLog("processHookEvent skip: category disabled", { source, category });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
debugLog("processHookEvent processing", { source, eventName, category });
|
|
92
|
+
// Load active voice pack
|
|
93
|
+
const pack = loadPack(config);
|
|
94
|
+
const projectName = cwd ? basename(cwd) : "";
|
|
95
|
+
|
|
96
|
+
// For contextual events, try LLM phrase generation
|
|
97
|
+
let phrase = null;
|
|
98
|
+
let fallbackReason = null;
|
|
99
|
+
let fallbackDetail = null;
|
|
100
|
+
if (CONTEXTUAL_EVENTS.has(eventName)) {
|
|
101
|
+
const context = extractContext(eventData);
|
|
102
|
+
debugLog("processHookEvent context extracted", {
|
|
103
|
+
source,
|
|
104
|
+
eventName,
|
|
105
|
+
hasContext: Boolean(context),
|
|
106
|
+
contextPreview: context ? context.replace(/\s+/g, " ").slice(0, 160) : "",
|
|
107
|
+
});
|
|
108
|
+
if (context) {
|
|
109
|
+
const result = await generatePhrase(context, config, pack.style, pack.llm_temperature, pack.examples);
|
|
110
|
+
phrase = result.phrase;
|
|
111
|
+
fallbackReason = result.fallbackReason;
|
|
112
|
+
fallbackDetail = result.detail || null;
|
|
113
|
+
if (phrase) {
|
|
114
|
+
debugLog("processHookEvent generated phrase", {
|
|
115
|
+
source,
|
|
116
|
+
eventName,
|
|
117
|
+
phrase: phrase.replace(/\s+/g, " ").slice(0, 160),
|
|
118
|
+
});
|
|
119
|
+
} else {
|
|
120
|
+
debugLog("processHookEvent generation fell back", {
|
|
121
|
+
source,
|
|
122
|
+
eventName,
|
|
123
|
+
fallbackReason,
|
|
124
|
+
fallbackDetail,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
} else {
|
|
128
|
+
fallbackReason = "no_context";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Fall back to predefined phrases (pack overrides defaults)
|
|
133
|
+
if (!phrase) {
|
|
134
|
+
debugLog("processHookEvent using fallback phrase", {
|
|
135
|
+
source,
|
|
136
|
+
eventName,
|
|
137
|
+
fallbackReason,
|
|
138
|
+
fallbackDetail,
|
|
139
|
+
});
|
|
140
|
+
if (fallbackReason && config.error_log === true) {
|
|
141
|
+
logFallback(eventName, fallbackReason, fallbackDetail);
|
|
142
|
+
}
|
|
143
|
+
const fallbackSource = pack.fallback_phrases || FALLBACK_PHRASES;
|
|
144
|
+
const phrases = fallbackSource[category] || ["Standing by"];
|
|
145
|
+
phrase = phrases[Math.floor(Math.random() * phrases.length)];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Prepend prefix (supports ${dirname} template variable)
|
|
149
|
+
const prefixTemplate = config.prefix !== undefined ? config.prefix : "${dirname}";
|
|
150
|
+
let resolvedPrefix = "";
|
|
151
|
+
if (prefixTemplate !== "") {
|
|
152
|
+
resolvedPrefix = prefixTemplate.replace(/\$\{dirname\}/g, projectName);
|
|
153
|
+
if (resolvedPrefix) {
|
|
154
|
+
phrase = `${resolvedPrefix}; ${phrase}`;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const packId = config.active_pack || "sc2-adjutant";
|
|
159
|
+
const phraseOneLine = phrase.replace(/\s+/g, " ").slice(0, 120);
|
|
160
|
+
debugLog("processHookEvent speaking", { source, phrase: phraseOneLine });
|
|
161
|
+
appendLog(
|
|
162
|
+
`[${new Date().toISOString()}] source=${source} event=${eventName} category=${category} phrase=${phraseOneLine}${phrase.length > 120 ? "…" : ""}`,
|
|
163
|
+
config,
|
|
164
|
+
);
|
|
165
|
+
showOverlay(phrase, {
|
|
166
|
+
category,
|
|
167
|
+
packName: pack.name,
|
|
168
|
+
packId: pack.id || packId,
|
|
169
|
+
prefix: resolvedPrefix,
|
|
170
|
+
config,
|
|
171
|
+
overlayColors: pack.overlay_colors,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await speakPhrase(phrase, config, pack);
|
|
175
|
+
debugLog("processHookEvent done (speakPhrase returned)", { source });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function main() {
|
|
179
|
+
// Read event data from stdin
|
|
180
|
+
let input = "";
|
|
181
|
+
for await (const chunk of process.stdin) {
|
|
182
|
+
input += chunk;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let eventData;
|
|
186
|
+
try {
|
|
187
|
+
eventData = JSON.parse(input);
|
|
188
|
+
} catch {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
await processHookEvent(eventData);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Only run main() when this file is the entry point (not when imported by cli.js)
|
|
196
|
+
const entryUrl = new URL(process.argv[1], "file://").href;
|
|
197
|
+
const thisUrl = new URL(import.meta.url).href;
|
|
198
|
+
if (entryUrl === thisUrl) {
|
|
199
|
+
main();
|
|
200
|
+
}
|
package/voxlert.sh
ADDED