@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/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
|
+
};
|