@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/audio.js ADDED
@@ -0,0 +1,381 @@
1
+ import {
2
+ readFileSync,
3
+ writeFileSync,
4
+ readdirSync,
5
+ unlinkSync,
6
+ renameSync,
7
+ mkdirSync,
8
+ existsSync,
9
+ statSync,
10
+ utimesSync,
11
+ } from "fs";
12
+ import { join } from "path";
13
+ import { createHash } from "crypto";
14
+ import { spawn, execSync } from "child_process";
15
+ import { request as httpsRequest } from "https";
16
+ import { request as httpRequest } from "http";
17
+ import { CACHE_DIR, QUEUE_DIR, LOCK_FILE } from "./paths.js";
18
+
19
+ const DEFAULT_MAX_CACHE = 150;
20
+
21
+ function evictCache(cacheDir, maxEntries) {
22
+ let files;
23
+ try {
24
+ files = readdirSync(cacheDir)
25
+ .filter((f) => f.endsWith(".wav"))
26
+ .map((f) => {
27
+ const p = join(cacheDir, f);
28
+ return { path: p, atime: statSync(p).atimeMs };
29
+ });
30
+ } catch {
31
+ return;
32
+ }
33
+ if (files.length <= maxEntries) return;
34
+ // Sort oldest-accessed first, remove excess
35
+ files.sort((a, b) => a.atime - b.atime);
36
+ const toRemove = files.length - maxEntries;
37
+ for (let i = 0; i < toRemove; i++) {
38
+ try {
39
+ unlinkSync(files[i].path);
40
+ } catch {
41
+ // ignore
42
+ }
43
+ }
44
+ }
45
+
46
+ function touchFile(filePath) {
47
+ const now = new Date();
48
+ try {
49
+ utimesSync(filePath, now, statSync(filePath).mtime);
50
+ } catch {
51
+ // ignore
52
+ }
53
+ }
54
+
55
+ function audioFilter() {
56
+ // Short multi-tap echo: two taps at 40ms and 75ms with moderate decay
57
+ return "aecho=0.8:0.88:40|75:0.4|0.25";
58
+ }
59
+
60
+ // --- File-based playback queue ---
61
+
62
+ function acquireLock() {
63
+ mkdirSync(QUEUE_DIR, { recursive: true });
64
+ try {
65
+ writeFileSync(LOCK_FILE, String(process.pid), { flag: "wx" });
66
+ return true;
67
+ } catch {
68
+ // Lock exists — check if holder is still alive
69
+ try {
70
+ const pid = parseInt(readFileSync(LOCK_FILE, "utf-8").trim(), 10);
71
+ process.kill(pid, 0); // throws if dead
72
+ return false; // holder alive, let it drain the queue
73
+ } catch {
74
+ // Stale lock — reclaim
75
+ try {
76
+ unlinkSync(LOCK_FILE);
77
+ writeFileSync(LOCK_FILE, String(process.pid), { flag: "wx" });
78
+ return true;
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
83
+ }
84
+ }
85
+
86
+ function releaseLock() {
87
+ try {
88
+ unlinkSync(LOCK_FILE);
89
+ } catch {
90
+ // ignore
91
+ }
92
+ }
93
+
94
+ function enqueue(cachePath, volume) {
95
+ mkdirSync(QUEUE_DIR, { recursive: true });
96
+ const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`;
97
+ writeFileSync(join(QUEUE_DIR, filename), JSON.stringify({ cachePath, volume }));
98
+ }
99
+
100
+ function getNextEntry() {
101
+ try {
102
+ const files = readdirSync(QUEUE_DIR)
103
+ .filter((f) => f.endsWith(".json"))
104
+ .sort();
105
+ return files.length > 0 ? files[0] : null;
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ // --- Playback ---
112
+
113
+ function applyEcho(cachePath, customAudioFilter) {
114
+ if (!existsSync(cachePath)) return;
115
+ const tmpOut = cachePath + ".fx.wav";
116
+ const filter = customAudioFilter || audioFilter();
117
+ try {
118
+ execSync(
119
+ `ffmpeg -y -i "${cachePath}" -af "${filter}" "${tmpOut}"`,
120
+ { timeout: 10000, stdio: "ignore" },
121
+ );
122
+ if (existsSync(tmpOut)) {
123
+ unlinkSync(cachePath);
124
+ renameSync(tmpOut, cachePath);
125
+ }
126
+ } catch {
127
+ try { unlinkSync(tmpOut); } catch { /* ignore */ }
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Return { cmd, args } to play a WAV file, or null if no player for this platform.
133
+ * - darwin: afplay (macOS)
134
+ * - win32: ffplay (FFmpeg) — install FFmpeg and add to PATH
135
+ * - linux: ffplay, else paplay (PulseAudio) or pw-play (PipeWire)
136
+ */
137
+ function getPlaybackCommand(platform, volume, cachePath) {
138
+ const vol = Math.max(0, Math.min(1, volume));
139
+ if (platform === "darwin") {
140
+ return { cmd: "afplay", args: ["-v", String(vol), cachePath] };
141
+ }
142
+ if (platform === "win32") {
143
+ return { cmd: "ffplay", args: ["-nodisp", "-autoexit", "-loglevel", "quiet", cachePath] };
144
+ }
145
+ if (platform === "linux") {
146
+ return { cmd: "ffplay", args: ["-nodisp", "-autoexit", "-loglevel", "quiet", cachePath] };
147
+ }
148
+ return null;
149
+ }
150
+
151
+ export function playFile(cachePath, volume) {
152
+ return new Promise((resolve) => {
153
+ if (!existsSync(cachePath)) return resolve();
154
+ const platform = process.platform;
155
+ const spec = getPlaybackCommand(platform, volume, cachePath);
156
+ if (!spec) return resolve();
157
+ const proc = spawn(spec.cmd, spec.args, {
158
+ stdio: ["ignore", "ignore", "ignore"],
159
+ });
160
+ proc.on("error", () => resolve());
161
+ proc.on("close", () => resolve());
162
+ });
163
+ }
164
+
165
+ async function processQueue() {
166
+ if (!acquireLock()) return;
167
+ try {
168
+ let entry;
169
+ while ((entry = getNextEntry())) {
170
+ const entryPath = join(QUEUE_DIR, entry);
171
+ try {
172
+ const entry_data = JSON.parse(
173
+ readFileSync(entryPath, "utf-8"),
174
+ );
175
+ unlinkSync(entryPath);
176
+ await playFile(entry_data.cachePath, entry_data.volume);
177
+ } catch {
178
+ try {
179
+ unlinkSync(entryPath);
180
+ } catch {
181
+ // ignore
182
+ }
183
+ }
184
+ }
185
+ } finally {
186
+ releaseLock();
187
+ }
188
+ }
189
+
190
+ // --- TTS download ---
191
+
192
+ function downloadChatterbox(phrase, cachePath, config, voicePath, ttsParams) {
193
+ return new Promise((resolve) => {
194
+ const chatterboxUrl = config.chatterbox_url || "http://localhost:8004";
195
+ const endpoint = `${chatterboxUrl}/tts`;
196
+
197
+ const body = {
198
+ text: phrase,
199
+ voice_mode: "predefined",
200
+ predefined_voice_id: voicePath || config.voice || "default.wav",
201
+ output_format: "wav",
202
+ };
203
+ // Apply per-pack TTS parameters (exaggeration, cfg_weight, temperature)
204
+ if (ttsParams) {
205
+ if (ttsParams.exaggeration != null) body.exaggeration = ttsParams.exaggeration;
206
+ if (ttsParams.cfg_weight != null) body.cfg_weight = ttsParams.cfg_weight;
207
+ if (ttsParams.temperature != null) body.temperature = ttsParams.temperature;
208
+ }
209
+ const payload = JSON.stringify(body);
210
+
211
+ const url = new URL(endpoint);
212
+ const requestFn = url.protocol === "https:" ? httpsRequest : httpRequest;
213
+
214
+ const req = requestFn(
215
+ endpoint,
216
+ {
217
+ method: "POST",
218
+ headers: {
219
+ "Content-Type": "application/json",
220
+ "Content-Length": Buffer.byteLength(payload),
221
+ },
222
+ timeout: 8000,
223
+ },
224
+ (res) => {
225
+ if (res.statusCode < 200 || res.statusCode >= 300) {
226
+ res.resume();
227
+ return resolve();
228
+ }
229
+ const chunks = [];
230
+ res.on("data", (chunk) => chunks.push(chunk));
231
+ res.on("end", () => {
232
+ try {
233
+ writeFileSync(cachePath, Buffer.concat(chunks));
234
+ } catch {
235
+ // ignore
236
+ }
237
+ resolve();
238
+ });
239
+ res.on("error", () => resolve());
240
+ },
241
+ );
242
+
243
+ req.on("error", () => resolve());
244
+ req.on("timeout", () => {
245
+ req.destroy();
246
+ resolve();
247
+ });
248
+ req.write(payload);
249
+ req.end();
250
+ });
251
+ }
252
+
253
+ function downloadQwen(phrase, cachePath, config, packId) {
254
+ return new Promise((resolve) => {
255
+ const qwenUrl = config.qwen_tts_url || "http://localhost:8100";
256
+ const endpoint = `${qwenUrl}/tts`;
257
+
258
+ const payload = JSON.stringify({ text: phrase, pack_id: packId });
259
+
260
+ const url = new URL(endpoint);
261
+ const requestFn = url.protocol === "https:" ? httpsRequest : httpRequest;
262
+
263
+ const req = requestFn(
264
+ endpoint,
265
+ {
266
+ method: "POST",
267
+ headers: {
268
+ "Content-Type": "application/json",
269
+ "Content-Length": Buffer.byteLength(payload),
270
+ },
271
+ timeout: 30000,
272
+ },
273
+ (res) => {
274
+ if (res.statusCode < 200 || res.statusCode >= 300) {
275
+ res.resume();
276
+ return resolve();
277
+ }
278
+ const chunks = [];
279
+ res.on("data", (chunk) => chunks.push(chunk));
280
+ res.on("end", () => {
281
+ try {
282
+ writeFileSync(cachePath, Buffer.concat(chunks));
283
+ } catch {
284
+ // ignore
285
+ }
286
+ resolve();
287
+ });
288
+ res.on("error", () => resolve());
289
+ },
290
+ );
291
+
292
+ req.on("error", () => resolve());
293
+ req.on("timeout", () => {
294
+ req.destroy();
295
+ resolve();
296
+ });
297
+ req.write(payload);
298
+ req.end();
299
+ });
300
+ }
301
+
302
+ function downloadToCache(phrase, cachePath, config, voicePath, ttsParams, packId) {
303
+ if (config.tts_backend === "qwen") {
304
+ return downloadQwen(phrase, cachePath, config, packId);
305
+ }
306
+ return downloadChatterbox(phrase, cachePath, config, voicePath, ttsParams);
307
+ }
308
+
309
+ // --- Post-processing ---
310
+
311
+ function normalizeVolume(cachePath) {
312
+ if (!existsSync(cachePath)) return;
313
+ const tmpOut = cachePath + ".norm.wav";
314
+ try {
315
+ // Simple peak normalization to -3dB headroom — no look-ahead artifacts
316
+ execSync(
317
+ `sox "${cachePath}" "${tmpOut}" norm -3`,
318
+ { timeout: 10000, stdio: "ignore" },
319
+ );
320
+ if (existsSync(tmpOut)) {
321
+ unlinkSync(cachePath);
322
+ renameSync(tmpOut, cachePath);
323
+ }
324
+ } catch {
325
+ try { unlinkSync(tmpOut); } catch { /* ignore */ }
326
+ }
327
+ }
328
+
329
+ function postProcess(cachePath, command) {
330
+ if (!command || !existsSync(cachePath)) return;
331
+ const tmpOut = cachePath + ".tmp.wav";
332
+ // Replace $INPUT and $OUTPUT placeholders in command
333
+ const cmd = command.replace(/\$INPUT/g, cachePath).replace(/\$OUTPUT/g, tmpOut);
334
+ try {
335
+ execSync(cmd, { timeout: 15000, stdio: "ignore" });
336
+ if (existsSync(tmpOut)) {
337
+ unlinkSync(cachePath);
338
+ renameSync(tmpOut, cachePath);
339
+ }
340
+ } catch {
341
+ // Post-processing failed — use raw TTS output
342
+ try { unlinkSync(tmpOut); } catch { /* ignore */ }
343
+ }
344
+ }
345
+
346
+ // --- Public API ---
347
+
348
+ export async function speakPhrase(phrase, config, pack) {
349
+ const packId = (pack && pack.id) || "_default";
350
+ const packCacheDir = join(CACHE_DIR, packId);
351
+ mkdirSync(packCacheDir, { recursive: true });
352
+
353
+ const ttsParams = pack ? pack.tts_params : null;
354
+ const backend = config.tts_backend || "chatterbox";
355
+ const cacheKey = createHash("md5")
356
+ .update(backend + ":" + phrase.toLowerCase() + (ttsParams ? JSON.stringify(ttsParams) : ""))
357
+ .digest("hex");
358
+ const cachePath = join(packCacheDir, `${cacheKey}.wav`);
359
+ const volume = config.volume ?? 0.5;
360
+ const maxCache = config.max_cache_entries ?? DEFAULT_MAX_CACHE;
361
+ const echo = pack ? pack.echo !== false : true;
362
+ const voicePath = (pack && pack.voicePath) || config.voice || "default.wav";
363
+ const customAudioFilter = (pack && pack.audio_filter) || null;
364
+ const postProcessCmd = (pack && pack.post_process) || null;
365
+
366
+ // Ensure audio is in cache (all effects baked in at cache time)
367
+ if (existsSync(cachePath)) {
368
+ touchFile(cachePath);
369
+ } else {
370
+ await downloadToCache(phrase, cachePath, config, voicePath, ttsParams, packId);
371
+ if (!existsSync(cachePath)) return; // download failed
372
+ if (postProcessCmd) postProcess(cachePath, postProcessCmd);
373
+ if (customAudioFilter || echo) applyEcho(cachePath, customAudioFilter);
374
+ normalizeVolume(cachePath);
375
+ evictCache(packCacheDir, maxCache);
376
+ }
377
+
378
+ // Enqueue and try to become the player
379
+ enqueue(cachePath, volume);
380
+ await processQueue();
381
+ }
package/src/cli.js ADDED
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync, existsSync } from "fs";
4
+ import { join, dirname } from "path";
5
+ import { fileURLToPath } from "url";
6
+ import { STATE_DIR } from "./paths.js";
7
+ import { getUpgradeInfo, printUpgradeNotification } from "./upgrade-check.js";
8
+ import { COMMANDS, resolveCommand } from "./commands/index.js";
9
+ import { formatHelp } from "./commands/help.js";
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
13
+
14
+ function isPromptAbort(err) {
15
+ const name = err && typeof err.name === "string" ? err.name : "";
16
+ const message = err && typeof err.message === "string" ? err.message : "";
17
+ return name === "ExitPromptError" || message.includes("User force closed the prompt");
18
+ }
19
+
20
+ function createHelpText() {
21
+ return formatHelp(COMMANDS, pkg);
22
+ }
23
+
24
+ async function maybeRunSetup(command) {
25
+ if (command.skipSetupWizard || existsSync(STATE_DIR)) return false;
26
+ console.log("Welcome to Voxlert! Let's get you set up.\n");
27
+ const { runSetup } = await import("./setup.js");
28
+ await runSetup();
29
+ return true;
30
+ }
31
+
32
+ (async () => {
33
+ const args = process.argv.slice(2);
34
+ const requested = args[0] || "help";
35
+ const command = resolveCommand(requested);
36
+
37
+ const interactiveUpgrade =
38
+ process.stdout.isTTY &&
39
+ (!command || !command.skipUpgradeCheck);
40
+ const upgradePromise = interactiveUpgrade
41
+ ? getUpgradeInfo(pkg.version, pkg.name)
42
+ : null;
43
+
44
+ if (!command) {
45
+ console.error(`Unknown command: ${requested}\n`);
46
+ console.log(createHelpText());
47
+ process.exit(1);
48
+ }
49
+
50
+ if (await maybeRunSetup(command)) {
51
+ return;
52
+ }
53
+
54
+ await command.run({
55
+ args,
56
+ command,
57
+ formatHelp: createHelpText,
58
+ pkg,
59
+ });
60
+
61
+ if (upgradePromise) {
62
+ try {
63
+ const info = await upgradePromise;
64
+ if (info) {
65
+ const releaseNotesUrl =
66
+ pkg.repository?.url &&
67
+ String(pkg.repository.url).replace(/\.git$/i, "").replace(/^git\+https:/, "https:");
68
+ printUpgradeNotification(info, {
69
+ packageName: pkg.name,
70
+ releaseNotesUrl: releaseNotesUrl
71
+ ? `${releaseNotesUrl}/releases/latest`
72
+ : undefined,
73
+ });
74
+ }
75
+ } catch {
76
+ // non-fatal: ignore
77
+ }
78
+ }
79
+ })().catch((err) => {
80
+ if (isPromptAbort(err)) {
81
+ process.stdout.write("\n");
82
+ process.exit(130);
83
+ }
84
+ console.error(err && err.stack ? err.stack : String(err));
85
+ process.exit(1);
86
+ });
@@ -0,0 +1,149 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+
5
+ const CODEX_DIR = join(homedir(), ".codex");
6
+ const CODEX_CONFIG_FILE = join(CODEX_DIR, "config.toml");
7
+
8
+ function splitTopLevelPrefix(content) {
9
+ const lines = content.split("\n");
10
+ const firstSectionIndex = lines.findIndex((line) => /^\s*\[/.test(line));
11
+ if (firstSectionIndex === -1) {
12
+ return { prefixLines: lines, suffixLines: [] };
13
+ }
14
+ return {
15
+ prefixLines: lines.slice(0, firstSectionIndex),
16
+ suffixLines: lines.slice(firstSectionIndex),
17
+ };
18
+ }
19
+
20
+ function countBrackets(text) {
21
+ let depth = 0;
22
+ for (const ch of text) {
23
+ if (ch === "[") depth++;
24
+ if (ch === "]") depth--;
25
+ }
26
+ return depth;
27
+ }
28
+
29
+ function replaceTopLevelArrayAssignment(content, key, renderedValue) {
30
+ const { prefixLines, suffixLines } = splitTopLevelPrefix(content);
31
+ const output = [];
32
+ let i = 0;
33
+ let replaced = false;
34
+
35
+ while (i < prefixLines.length) {
36
+ const line = prefixLines[i];
37
+ if (/^\s*$/.test(line) || /^\s*#/.test(line)) {
38
+ output.push(line);
39
+ i++;
40
+ continue;
41
+ }
42
+
43
+ const match = line.match(new RegExp(`^(\\s*)${key}\\s*=`));
44
+ if (!match) {
45
+ output.push(line);
46
+ i++;
47
+ continue;
48
+ }
49
+
50
+ replaced = true;
51
+ output.push(`${match[1]}${key} = ${renderedValue}`);
52
+
53
+ let depth = countBrackets(line.slice(line.indexOf("=") + 1));
54
+ i++;
55
+ while (depth > 0 && i < prefixLines.length) {
56
+ depth += countBrackets(prefixLines[i]);
57
+ i++;
58
+ }
59
+ }
60
+
61
+ if (!replaced) {
62
+ while (output.length > 0 && output[output.length - 1] === "") {
63
+ output.pop();
64
+ }
65
+ if (output.length > 0) output.push("");
66
+ output.push(`${key} = ${renderedValue}`);
67
+ if (suffixLines.length > 0) output.push("");
68
+ }
69
+
70
+ return [...output, ...suffixLines].join("\n");
71
+ }
72
+
73
+ function removeTopLevelAssignment(content, key) {
74
+ const { prefixLines, suffixLines } = splitTopLevelPrefix(content);
75
+ const output = [];
76
+ let i = 0;
77
+ let removed = false;
78
+
79
+ while (i < prefixLines.length) {
80
+ const line = prefixLines[i];
81
+ const match = line.match(new RegExp(`^(\\s*)${key}\\s*=`));
82
+ if (!match) {
83
+ output.push(line);
84
+ i++;
85
+ continue;
86
+ }
87
+
88
+ removed = true;
89
+ let depth = countBrackets(line.slice(line.indexOf("=") + 1));
90
+ i++;
91
+ while (depth > 0 && i < prefixLines.length) {
92
+ depth += countBrackets(prefixLines[i]);
93
+ i++;
94
+ }
95
+ }
96
+
97
+ while (output.length > 0 && output[output.length - 1] === "" && suffixLines.length > 0 && /^\s*\[/.test(suffixLines[0])) {
98
+ output.pop();
99
+ }
100
+
101
+ return {
102
+ removed,
103
+ content: [...output, ...suffixLines].join("\n"),
104
+ };
105
+ }
106
+
107
+ function loadCodexConfig() {
108
+ try {
109
+ return readFileSync(CODEX_CONFIG_FILE, "utf-8");
110
+ } catch {
111
+ return "";
112
+ }
113
+ }
114
+
115
+ function saveCodexConfig(content) {
116
+ mkdirSync(CODEX_DIR, { recursive: true });
117
+ const normalized = content.endsWith("\n") ? content : `${content}\n`;
118
+ writeFileSync(CODEX_CONFIG_FILE, normalized);
119
+ }
120
+
121
+ function renderTomlStringArray(values) {
122
+ return `[${values.map((value) => JSON.stringify(value)).join(", ")}]`;
123
+ }
124
+
125
+ export function getCodexConfigPath() {
126
+ return CODEX_CONFIG_FILE;
127
+ }
128
+
129
+ export function hasCodexNotify() {
130
+ if (!existsSync(CODEX_CONFIG_FILE)) return false;
131
+ const current = loadCodexConfig();
132
+ return /^\s*notify\s*=/m.test(splitTopLevelPrefix(current).prefixLines.join("\n"));
133
+ }
134
+
135
+ export function registerCodexNotify(commandArgs) {
136
+ const current = loadCodexConfig();
137
+ const next = replaceTopLevelArrayAssignment(current, "notify", renderTomlStringArray(commandArgs));
138
+ saveCodexConfig(next);
139
+ return existsSync(CODEX_CONFIG_FILE);
140
+ }
141
+
142
+ export function unregisterCodexNotify() {
143
+ if (!existsSync(CODEX_CONFIG_FILE)) return false;
144
+ const current = loadCodexConfig();
145
+ const { removed, content } = removeTopLevelAssignment(current, "notify");
146
+ if (!removed) return false;
147
+ saveCodexConfig(content);
148
+ return true;
149
+ }
@@ -0,0 +1,70 @@
1
+ import { processHookEvent } from "../voxlert.js";
2
+ import {
3
+ appendHookDebugLine,
4
+ CODEX_NOTIFY_TYPE_TO_EVENT,
5
+ getHookRuntimeInfo,
6
+ normalizeCodexInputMessages,
7
+ stringifyForLog,
8
+ } from "./hook-utils.js";
9
+
10
+ export const codexNotifyCommand = {
11
+ name: "codex-notify",
12
+ aliases: [],
13
+ help: [
14
+ " voxlert codex-notify Process a notify payload from argv (used by OpenAI Codex notify)",
15
+ ],
16
+ skipSetupWizard: true,
17
+ skipUpgradeCheck: true,
18
+ async run() {
19
+ const argvTail = process.argv.slice(3);
20
+ const rawArg = argvTail[0] === "--" ? argvTail[1] : argvTail[0];
21
+ const raw = typeof rawArg === "string" ? rawArg : "";
22
+ appendHookDebugLine(`voxlert codex-notify runtime ${stringifyForLog(getHookRuntimeInfo())}`);
23
+ appendHookDebugLine(`voxlert codex-notify argv_tail=${stringifyForLog(argvTail)} raw=${stringifyForLog(raw)}`);
24
+ if (!raw || typeof raw !== "string") {
25
+ appendHookDebugLine("voxlert codex-notify exiting: missing raw payload");
26
+ process.exit(0);
27
+ }
28
+ let payload;
29
+ try {
30
+ payload = JSON.parse(raw);
31
+ appendHookDebugLine(`voxlert codex-notify parsed payload ${stringifyForLog(payload)}`);
32
+ appendHookDebugLine(`voxlert codex-notify payload summary ${stringifyForLog({
33
+ type: payload.type || "",
34
+ cwd: payload.cwd || "",
35
+ hasLastAssistantMessage: Boolean(payload["last-assistant-message"] || payload.last_assistant_message),
36
+ inputMessageCount: Array.isArray(payload["input-messages"] || payload.input_messages || payload.inputMessages)
37
+ ? (payload["input-messages"] || payload.input_messages || payload.inputMessages).length
38
+ : 0,
39
+ threadId: payload["thread-id"] || payload.thread_id || "",
40
+ turnId: payload["turn-id"] || payload.turn_id || "",
41
+ })}`);
42
+ } catch (err) {
43
+ appendHookDebugLine(`voxlert codex-notify parse error ${err && err.message}`);
44
+ process.exit(0);
45
+ }
46
+ const codexType = payload.type || "";
47
+ const ourEvent = CODEX_NOTIFY_TYPE_TO_EVENT[codexType];
48
+ if (!ourEvent) {
49
+ appendHookDebugLine(`voxlert codex-notify exiting: unsupported type=${codexType || "(empty)"}`);
50
+ process.exit(0);
51
+ }
52
+ const translated = {
53
+ hook_event_name: ourEvent,
54
+ cwd: payload.cwd || "",
55
+ source: "codex",
56
+ last_assistant_message: payload["last-assistant-message"] || payload.last_assistant_message || "",
57
+ input_messages: normalizeCodexInputMessages(payload),
58
+ codex_thread_id: payload["thread-id"] || payload.thread_id || "",
59
+ codex_turn_id: payload["turn-id"] || payload.turn_id || "",
60
+ };
61
+ appendHookDebugLine(`voxlert codex-notify translated event ${stringifyForLog(translated)}`);
62
+ try {
63
+ await processHookEvent(translated);
64
+ appendHookDebugLine(`voxlert codex-notify processHookEvent completed type=${codexType} event=${ourEvent}`);
65
+ } catch (err) {
66
+ appendHookDebugLine(`voxlert codex-notify processHookEvent error ${err && err.message}`);
67
+ }
68
+ process.exit(0);
69
+ },
70
+ };