codex2voice 0.1.1

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/README.md ADDED
@@ -0,0 +1,141 @@
1
+ # Codex2Voice
2
+
3
+ Welcome to **Codex2Voice**.
4
+
5
+ If you use Codex heavily, this project adds one missing piece: your assistant can **speak final answers out loud** while you keep coding.
6
+
7
+ ## What This Project Is
8
+ Codex2Voice is a macOS-first companion CLI for Codex.
9
+ It wraps Codex sessions and turns final assistant responses into audio using ElevenLabs.
10
+
11
+ ## What It Does
12
+ - Speaks **final assistant answers only**.
13
+ - Avoids speaking UI/status noise.
14
+ - Works with interactive Codex sessions and `codex exec` flows.
15
+ - Supports multiple open Codex terminals.
16
+ - Lets you toggle voice instantly (`on` / `off`).
17
+ - Keeps text workflow intact even if voice fails.
18
+
19
+ ## Why It Matters
20
+ - Faster feedback loop while coding.
21
+ - Better hands-free workflow when using dictation/voice input.
22
+ - Keeps everything in terminal, no context switching.
23
+
24
+ ## Prerequisites
25
+ - macOS
26
+ - Node.js 20+
27
+ - npm
28
+ - Codex CLI installed and working (`codex --version`)
29
+ - ElevenLabs account (API key + voice ID)
30
+
31
+ ## Install
32
+ ```bash
33
+ npm i -g codex2voice
34
+ ```
35
+
36
+ If npm publish is pending, use:
37
+ ```bash
38
+ npm i -g https://codeload.github.com/goyo-lp/codex2voice/tar.gz/main
39
+ ```
40
+
41
+ ## Step-by-Step Setup
42
+
43
+ ### 1. Run guided setup
44
+ ```bash
45
+ codex2voice init
46
+ ```
47
+
48
+ You will be prompted for:
49
+ - ElevenLabs API key
50
+ - ElevenLabs voice ID
51
+ - Voice default state (on/off)
52
+ - Speech speed
53
+ - Optional alias setup so `codex` automatically routes through Codex2Voice
54
+
55
+ ### 2. Run health checks
56
+ ```bash
57
+ codex2voice doctor
58
+ ```
59
+
60
+ This verifies:
61
+ - `codex` command exists
62
+ - `afplay` is available
63
+ - config is readable/writable
64
+ - API key is present
65
+ - ElevenLabs is reachable
66
+ - voice ID is configured
67
+
68
+ ### 3. Confirm status
69
+ ```bash
70
+ codex2voice status
71
+ ```
72
+
73
+ ### 4. Start using Codex with voice
74
+ If alias setup was enabled during `init`, just run:
75
+ ```bash
76
+ codex
77
+ ```
78
+
79
+ If alias setup was skipped, run:
80
+ ```bash
81
+ codex2voice codex --
82
+ ```
83
+
84
+ ## Daily Workflow
85
+ ```bash
86
+ codex2voice on # enable voice
87
+ codex # ask Codex normally
88
+ codex2voice off # disable voice in public
89
+ codex2voice speak # replay last answer
90
+ codex2voice stop # stop playback immediately
91
+ ```
92
+
93
+ ## Command Quick Reference
94
+ - `codex2voice init`
95
+ - `codex2voice on`
96
+ - `codex2voice off`
97
+ - `codex2voice status`
98
+ - `codex2voice doctor`
99
+ - `codex2voice speak "text"`
100
+ - `codex2voice speak`
101
+ - `codex2voice stop`
102
+ - `codex2voice codex -- "prompt"`
103
+ - `codex2voice codex --debug-events -- "prompt"`
104
+
105
+ Full command docs: [CLI.md](./CLI.md)
106
+
107
+ ## Configuration and Local State
108
+ Default path: `~/.codex`
109
+ - `voice.json` (settings)
110
+ - `voice-cache.json` (last answer cache)
111
+ - `voice-playback.json` (playback metadata)
112
+ - `voice-audio/` (temporary audio)
113
+
114
+ ## Secrets and Security
115
+ - Preferred API key storage: macOS Keychain (via `init`)
116
+ - Fallback: `ELEVENLABS_API_KEY` environment variable
117
+ - Do not commit `.env` or real keys
118
+ - Use [.env.example](./.env.example) as template only
119
+
120
+ ## Environment Variables
121
+ See [.env.example](./.env.example) for supported variables:
122
+ - `ELEVENLABS_API_KEY`
123
+ - `ELEVENLABS_VOICE_ID`
124
+ - `CODEX2VOICE_DEBUG`
125
+ - `CODEX2VOICE_CACHE_DEBOUNCE_MS`
126
+ - `CODEX2VOICE_HOME`
127
+
128
+ ## Troubleshooting
129
+ - No voice output: run `codex2voice doctor`
130
+ - Need parser diagnostics: `codex2voice codex --debug-events --`
131
+ - Wrong voice: rerun `codex2voice init`
132
+ - Stop audio immediately: `codex2voice stop`
133
+
134
+ ## Development
135
+ ```bash
136
+ npm install
137
+ npm run check
138
+ npm test
139
+ npm run build
140
+ npm link
141
+ ```
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/cli.js ADDED
@@ -0,0 +1,862 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/commands/init.ts
7
+ import fs4 from "fs/promises";
8
+ import os2 from "os";
9
+ import path3 from "path";
10
+ import inquirer from "inquirer";
11
+
12
+ // src/state/config.ts
13
+ import fs3 from "fs/promises";
14
+ import { z } from "zod";
15
+
16
+ // src/state/paths.ts
17
+ import os from "os";
18
+ import path from "path";
19
+ import fs from "fs/promises";
20
+ var OVERRIDE_HOME = process.env.CODEX2VOICE_HOME;
21
+ var CODEX_DIR = OVERRIDE_HOME ?? path.join(os.homedir(), ".codex");
22
+ var PATHS = {
23
+ codexDir: CODEX_DIR,
24
+ config: path.join(CODEX_DIR, "voice.json"),
25
+ cache: path.join(CODEX_DIR, "voice-cache.json"),
26
+ playback: path.join(CODEX_DIR, "voice-playback.json"),
27
+ tempAudioDir: path.join(CODEX_DIR, "voice-audio")
28
+ };
29
+ async function ensureCodexDir() {
30
+ await fs.mkdir(PATHS.codexDir, { recursive: true });
31
+ await fs.mkdir(PATHS.tempAudioDir, { recursive: true });
32
+ }
33
+
34
+ // src/state/json.ts
35
+ import fs2 from "fs/promises";
36
+ import path2 from "path";
37
+ import { randomUUID } from "crypto";
38
+ async function writeJsonAtomic(filePath, value) {
39
+ const dir = path2.dirname(filePath);
40
+ const tmpPath = path2.join(dir, `.tmp-${process.pid}-${Date.now()}-${randomUUID()}.json`);
41
+ await fs2.mkdir(dir, { recursive: true });
42
+ await fs2.writeFile(tmpPath, JSON.stringify(value, null, 2), "utf8");
43
+ await fs2.rename(tmpPath, filePath);
44
+ }
45
+
46
+ // src/state/config.ts
47
+ var configSchema = z.object({
48
+ enabled: z.boolean().default(true),
49
+ autoSpeak: z.boolean().default(true),
50
+ voiceId: z.string().default(""),
51
+ modelId: z.string().min(1).default("eleven_flash_v2_5"),
52
+ speed: z.number().min(0.7).max(2).default(1.25),
53
+ skipCodeHeavy: z.boolean().default(false),
54
+ summarizeCodeHeavy: z.boolean().default(true),
55
+ maxCharsPerSynthesis: z.number().int().min(200).max(6e3).default(2500),
56
+ playbackConflictPolicy: z.enum(["stop-and-replace", "ignore"]).default("stop-and-replace")
57
+ });
58
+ var defaultConfig = configSchema.parse({});
59
+ async function readConfig() {
60
+ await ensureCodexDir();
61
+ try {
62
+ const raw = await fs3.readFile(PATHS.config, "utf8");
63
+ return configSchema.parse(JSON.parse(raw));
64
+ } catch {
65
+ await writeConfig(defaultConfig);
66
+ return defaultConfig;
67
+ }
68
+ }
69
+ async function writeConfig(config) {
70
+ await ensureCodexDir();
71
+ const parsed = configSchema.parse(config);
72
+ await writeJsonAtomic(PATHS.config, parsed);
73
+ }
74
+ async function updateConfig(partial) {
75
+ const current = await readConfig();
76
+ const next = configSchema.parse({ ...current, ...partial });
77
+ await writeConfig(next);
78
+ return next;
79
+ }
80
+
81
+ // src/state/keychain.ts
82
+ var SERVICE = "codex2voice";
83
+ var ACCOUNT = "elevenlabs_api_key";
84
+ var keytarPromise = null;
85
+ async function getKeytar() {
86
+ if (!keytarPromise) {
87
+ keytarPromise = Function("moduleName", "return import(moduleName)")("keytar").then((mod) => mod.default).catch(() => null);
88
+ }
89
+ return keytarPromise;
90
+ }
91
+ async function setApiKey(key) {
92
+ try {
93
+ const keytar = await getKeytar();
94
+ if (!keytar) return false;
95
+ await keytar.setPassword(SERVICE, ACCOUNT, key);
96
+ return true;
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+ async function getApiKey() {
102
+ try {
103
+ const keytar = await getKeytar();
104
+ if (!keytar) return process.env.ELEVENLABS_API_KEY ?? null;
105
+ const fromKeychain = await keytar.getPassword(SERVICE, ACCOUNT);
106
+ if (fromKeychain) return fromKeychain;
107
+ } catch {
108
+ }
109
+ return process.env.ELEVENLABS_API_KEY ?? null;
110
+ }
111
+ async function deleteApiKey() {
112
+ try {
113
+ const keytar = await getKeytar();
114
+ if (!keytar) return;
115
+ await keytar.deletePassword(SERVICE, ACCOUNT);
116
+ } catch {
117
+ }
118
+ }
119
+
120
+ // src/commands/init.ts
121
+ async function appendAliasIfMissing() {
122
+ const zshrc = path3.join(os2.homedir(), ".zshrc");
123
+ const marker = "# codex2voice wrapper";
124
+ const aliasLine = "alias codex='codex2voice codex --'";
125
+ try {
126
+ let content = "";
127
+ try {
128
+ content = await fs4.readFile(zshrc, "utf8");
129
+ } catch {
130
+ content = "";
131
+ }
132
+ if (content.includes(aliasLine) || content.includes(marker)) {
133
+ return "exists";
134
+ }
135
+ const block = `
136
+ ${marker}
137
+ ${aliasLine}
138
+ `;
139
+ await fs4.appendFile(zshrc, block, "utf8");
140
+ return "added";
141
+ } catch {
142
+ return "failed";
143
+ }
144
+ }
145
+ async function runInit() {
146
+ const current = await readConfig();
147
+ const answers = await inquirer.prompt([
148
+ {
149
+ type: "password",
150
+ name: "apiKey",
151
+ message: "Enter your ElevenLabs API key:",
152
+ mask: "*",
153
+ validate: (input) => input.trim().length > 10 ? true : "API key looks too short"
154
+ },
155
+ {
156
+ type: "input",
157
+ name: "voiceId",
158
+ message: "Enter ElevenLabs voice ID:",
159
+ default: current.voiceId || process.env.ELEVENLABS_VOICE_ID || ""
160
+ },
161
+ {
162
+ type: "confirm",
163
+ name: "enabled",
164
+ message: "Enable voice by default?",
165
+ default: true
166
+ },
167
+ {
168
+ type: "input",
169
+ name: "speed",
170
+ message: "Speech speed (0.7 - 2.0):",
171
+ default: String(current.speed),
172
+ validate: (input) => {
173
+ const value = Number.parseFloat(input);
174
+ if (Number.isNaN(value)) return "Enter a numeric value, for example 1.25";
175
+ return value >= 0.7 && value <= 2 ? true : "Use a value between 0.7 and 2.0";
176
+ },
177
+ filter: (input) => Number.parseFloat(input)
178
+ },
179
+ {
180
+ type: "confirm",
181
+ name: "setupWrapper",
182
+ message: "Set up codex wrapper alias in ~/.zshrc by default?",
183
+ default: true
184
+ }
185
+ ]);
186
+ const savedToKeychain = await setApiKey(answers.apiKey.trim());
187
+ await writeConfig({
188
+ ...current,
189
+ enabled: Boolean(answers.enabled),
190
+ autoSpeak: true,
191
+ voiceId: String(answers.voiceId).trim(),
192
+ speed: Number(answers.speed),
193
+ summarizeCodeHeavy: true,
194
+ skipCodeHeavy: false,
195
+ playbackConflictPolicy: "stop-and-replace"
196
+ });
197
+ let aliasStatus = "skipped";
198
+ if (answers.setupWrapper) {
199
+ aliasStatus = await appendAliasIfMissing();
200
+ }
201
+ console.log("Initialization complete.");
202
+ console.log(savedToKeychain ? "API key stored in macOS Keychain." : "Could not store key in Keychain. Set ELEVENLABS_API_KEY in your shell env.");
203
+ if (aliasStatus === "added") console.log("Added codex wrapper alias to ~/.zshrc. Open a new shell session.");
204
+ if (aliasStatus === "exists") console.log("Wrapper alias already exists in ~/.zshrc.");
205
+ if (aliasStatus === "failed") console.log("Could not update ~/.zshrc automatically. Add alias manually: alias codex='codex2voice codex --'");
206
+ console.log("Next: run `codex2voice doctor` then `codex2voice status`.");
207
+ }
208
+
209
+ // src/commands/state.ts
210
+ async function setVoiceOn() {
211
+ await updateConfig({ enabled: true, autoSpeak: true });
212
+ console.log("Voice is ON.");
213
+ }
214
+ async function setVoiceOff() {
215
+ await updateConfig({ enabled: false });
216
+ console.log("Voice is OFF.");
217
+ }
218
+ async function showStatus() {
219
+ const config = await readConfig();
220
+ console.log("codex2voice status");
221
+ console.log(`enabled: ${config.enabled}`);
222
+ console.log(`autoSpeak: ${config.autoSpeak}`);
223
+ console.log(`voiceId: ${config.voiceId || "(not set)"}`);
224
+ console.log(`modelId: ${config.modelId}`);
225
+ console.log(`summarizeCodeHeavy: ${config.summarizeCodeHeavy}`);
226
+ console.log(`playbackConflictPolicy: ${config.playbackConflictPolicy}`);
227
+ }
228
+
229
+ // src/commands/doctor.ts
230
+ import { access } from "fs/promises";
231
+ import { constants } from "fs";
232
+ import { execa } from "execa";
233
+ async function checkCommand(cmd) {
234
+ try {
235
+ await execa("which", [cmd]);
236
+ return true;
237
+ } catch {
238
+ return false;
239
+ }
240
+ }
241
+ async function checkElevenLabs(apiKey) {
242
+ const controller = new AbortController();
243
+ const timeout = setTimeout(() => controller.abort(), 5e3);
244
+ try {
245
+ const response = await fetch("https://api.elevenlabs.io/v1/user", {
246
+ headers: { "xi-api-key": apiKey },
247
+ signal: controller.signal
248
+ });
249
+ return response.ok;
250
+ } catch {
251
+ return false;
252
+ } finally {
253
+ clearTimeout(timeout);
254
+ }
255
+ }
256
+ async function runDoctor() {
257
+ await ensureCodexDir();
258
+ const config = await readConfig();
259
+ const hasCodex = await checkCommand("codex");
260
+ const hasAfplay = await checkCommand("afplay");
261
+ const apiKey = await getApiKey();
262
+ const canReadConfig = await access(PATHS.config, constants.R_OK).then(() => true).catch(() => false);
263
+ const canWriteDir = await access(PATHS.codexDir, constants.W_OK).then(() => true).catch(() => false);
264
+ const apiReachable = apiKey ? await checkElevenLabs(apiKey) : false;
265
+ console.log("codex2voice doctor");
266
+ console.log(`codex command: ${hasCodex ? "PASS" : "FAIL"}`);
267
+ console.log(`afplay available (macOS): ${hasAfplay ? "PASS" : "FAIL"}`);
268
+ console.log(`config readable: ${canReadConfig ? "PASS" : "FAIL"}`);
269
+ console.log(`codex dir writable: ${canWriteDir ? "PASS" : "FAIL"}`);
270
+ console.log(`api key present: ${apiKey ? "PASS" : "FAIL"}`);
271
+ console.log(`elevenlabs reachable: ${apiReachable ? "PASS" : "FAIL"}`);
272
+ console.log(`voice id configured: ${config.voiceId ? "PASS" : "FAIL"}`);
273
+ if (!apiKey) {
274
+ console.log("Remediation: run `codex2voice init` or export ELEVENLABS_API_KEY.");
275
+ }
276
+ }
277
+
278
+ // src/state/cache.ts
279
+ import fs5 from "fs/promises";
280
+ import { z as z2 } from "zod";
281
+ var cacheSchema = z2.object({
282
+ lastText: z2.string().default(""),
283
+ updatedAt: z2.string().default("")
284
+ });
285
+ var defaultCache = { lastText: "", updatedAt: "" };
286
+ var CACHE_DEBOUNCE_MS = Math.max(0, Number.parseInt(process.env.CODEX2VOICE_CACHE_DEBOUNCE_MS ?? "0", 10) || 0);
287
+ var debounceTimer = null;
288
+ var pendingText = null;
289
+ var pendingResolvers = [];
290
+ var pendingRejectors = [];
291
+ async function readCache() {
292
+ await ensureCodexDir();
293
+ try {
294
+ const raw = await fs5.readFile(PATHS.cache, "utf8");
295
+ return cacheSchema.parse(JSON.parse(raw));
296
+ } catch {
297
+ await writeCache(defaultCache);
298
+ return defaultCache;
299
+ }
300
+ }
301
+ async function writeCache(cache) {
302
+ await ensureCodexDir();
303
+ const parsed = cacheSchema.parse(cache);
304
+ await writeJsonAtomic(PATHS.cache, parsed);
305
+ }
306
+ async function flushPendingText() {
307
+ const text = pendingText;
308
+ pendingText = null;
309
+ if (!text) return;
310
+ await writeCache({ lastText: text, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
311
+ }
312
+ function resolveAllPending() {
313
+ const resolves = pendingResolvers;
314
+ pendingResolvers = [];
315
+ pendingRejectors = [];
316
+ resolves.forEach((resolve) => resolve());
317
+ }
318
+ function rejectAllPending(error) {
319
+ const rejects = pendingRejectors;
320
+ pendingResolvers = [];
321
+ pendingRejectors = [];
322
+ rejects.forEach((reject) => reject(error));
323
+ }
324
+ async function setLastText(text) {
325
+ const normalized = text.trim();
326
+ if (!normalized) return;
327
+ if (CACHE_DEBOUNCE_MS <= 0) {
328
+ await writeCache({ lastText: normalized, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
329
+ return;
330
+ }
331
+ pendingText = normalized;
332
+ const waitForFlush = new Promise((resolve, reject) => {
333
+ pendingResolvers.push(resolve);
334
+ pendingRejectors.push(reject);
335
+ });
336
+ if (debounceTimer) clearTimeout(debounceTimer);
337
+ debounceTimer = setTimeout(() => {
338
+ debounceTimer = null;
339
+ void flushPendingText().then(() => resolveAllPending()).catch((error) => rejectAllPending(error));
340
+ }, CACHE_DEBOUNCE_MS);
341
+ await waitForFlush;
342
+ }
343
+
344
+ // src/core/filter.ts
345
+ function countMatches(text, regex) {
346
+ const matches = text.match(regex);
347
+ return matches ? matches.length : 0;
348
+ }
349
+ function summarizeCodeHeavyText(text) {
350
+ const lines = text.split("\n").map((line) => line.trim()).filter(Boolean);
351
+ const fileMentions = lines.filter((line) => /\b(src|app|lib|test|package|README|\.ts|\.js|\.tsx|\.jsx|\.py|\.md)\b/i.test(line)).slice(0, 3);
352
+ if (fileMentions.length > 0) {
353
+ return `I made code-focused updates. Key areas touched include ${fileMentions.join(", ")}. Please review the terminal for exact code changes.`;
354
+ }
355
+ return "I made code-focused updates. Please review the terminal for exact diffs and file edits.";
356
+ }
357
+ function toSpeechDecision(text, summarizeCodeHeavy = true) {
358
+ const trimmed = text.trim();
359
+ if (!trimmed) {
360
+ return { shouldSpeak: false, reason: "empty", textForSpeech: "" };
361
+ }
362
+ const lineCount = trimmed.split("\n").length;
363
+ const codeFenceCount = countMatches(trimmed, /```/g);
364
+ const diffLikeLineCount = countMatches(trimmed, /^\+|^\-|^@@|^diff\s|^index\s/mg);
365
+ const toolLineCount = countMatches(trimmed, /^\$|^npm\s|^yarn\s|^pnpm\s|^git\s/mg);
366
+ const heavySignal = codeFenceCount * 8 + diffLikeLineCount + toolLineCount;
367
+ const heavyThreshold = Math.max(12, Math.floor(lineCount * 0.4));
368
+ if (heavySignal >= heavyThreshold) {
369
+ if (!summarizeCodeHeavy) {
370
+ return { shouldSpeak: false, reason: "code-heavy", textForSpeech: "" };
371
+ }
372
+ return {
373
+ shouldSpeak: true,
374
+ reason: "code-heavy-summary",
375
+ textForSpeech: summarizeCodeHeavyText(trimmed)
376
+ };
377
+ }
378
+ const cleaned = trimmed.replace(/```[\s\S]*?```/g, " code block omitted ").replace(/`([^`]+)`/g, "$1").replace(/\[(.*?)\]\((.*?)\)/g, "$1").replace(/^#{1,6}\s+/gm, "").replace(/^[-*]\s+/gm, "").replace(/\s+/g, " ").trim();
379
+ if (cleaned.length < 2) {
380
+ return { shouldSpeak: false, reason: "too-short", textForSpeech: "" };
381
+ }
382
+ return { shouldSpeak: true, reason: "natural-language", textForSpeech: cleaned };
383
+ }
384
+
385
+ // src/integrations/elevenlabs.ts
386
+ var ElevenLabsError = class extends Error {
387
+ constructor(message) {
388
+ super(message);
389
+ this.name = "ElevenLabsError";
390
+ }
391
+ };
392
+ async function synthesizeSpeech(text, config) {
393
+ const apiKey = await getApiKey();
394
+ if (!apiKey) {
395
+ throw new ElevenLabsError("Missing ElevenLabs API key. Run `codex2voice init` or set ELEVENLABS_API_KEY.");
396
+ }
397
+ if (!config.voiceId) {
398
+ throw new ElevenLabsError("Missing voiceId in config. Run `codex2voice init` to set it.");
399
+ }
400
+ const clipped = text.slice(0, config.maxCharsPerSynthesis).trim();
401
+ if (!clipped) {
402
+ throw new ElevenLabsError("Cannot synthesize empty text.");
403
+ }
404
+ const controller = new AbortController();
405
+ const timeout = setTimeout(() => controller.abort(), 12e3);
406
+ const ttsSpeed = Math.max(0.7, Math.min(1.2, config.speed));
407
+ let response;
408
+ try {
409
+ response = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(config.voiceId)}`, {
410
+ method: "POST",
411
+ headers: {
412
+ "xi-api-key": apiKey,
413
+ "content-type": "application/json",
414
+ accept: "audio/mpeg"
415
+ },
416
+ signal: controller.signal,
417
+ body: JSON.stringify({
418
+ text: clipped,
419
+ model_id: config.modelId,
420
+ voice_settings: {
421
+ stability: 0.4,
422
+ similarity_boost: 0.7,
423
+ speed: ttsSpeed,
424
+ style: 0.4,
425
+ use_speaker_boost: true
426
+ }
427
+ })
428
+ });
429
+ } finally {
430
+ clearTimeout(timeout);
431
+ }
432
+ if (!response.ok) {
433
+ const body = await response.text();
434
+ if (response.status === 401 || response.status === 403) {
435
+ throw new ElevenLabsError("ElevenLabs auth failed. Check your API key.");
436
+ }
437
+ if (response.status === 429) {
438
+ throw new ElevenLabsError("ElevenLabs rate limit reached. Retry shortly.");
439
+ }
440
+ throw new ElevenLabsError(`ElevenLabs request failed (${response.status}): ${body.slice(0, 200)}`);
441
+ }
442
+ const arrayBuffer = await response.arrayBuffer();
443
+ return Buffer.from(arrayBuffer);
444
+ }
445
+
446
+ // src/audio/playback.ts
447
+ import fs6 from "fs/promises";
448
+ import path4 from "path";
449
+ import { spawn } from "child_process";
450
+ import { randomUUID as randomUUID2 } from "crypto";
451
+
452
+ // src/core/logger.ts
453
+ import pino from "pino";
454
+ var isDebug = process.env.CODEX2VOICE_DEBUG === "1";
455
+ var logger = pino({
456
+ level: isDebug ? "debug" : "silent"
457
+ });
458
+
459
+ // src/audio/playback.ts
460
+ async function readPlaybackState() {
461
+ try {
462
+ const raw = await fs6.readFile(PATHS.playback, "utf8");
463
+ return JSON.parse(raw);
464
+ } catch {
465
+ return null;
466
+ }
467
+ }
468
+ async function writePlaybackState(state) {
469
+ await writeJsonAtomic(PATHS.playback, state);
470
+ }
471
+ function isPidAlive(pid) {
472
+ try {
473
+ process.kill(pid, 0);
474
+ return true;
475
+ } catch {
476
+ return false;
477
+ }
478
+ }
479
+ async function cleanupStaleState() {
480
+ const state = await readPlaybackState();
481
+ if (!state) return;
482
+ if (!isPidAlive(state.pid)) {
483
+ await fs6.rm(state.filePath, { force: true });
484
+ await fs6.rm(PATHS.playback, { force: true });
485
+ }
486
+ }
487
+ async function stopPlayback() {
488
+ await cleanupStaleState();
489
+ const state = await readPlaybackState();
490
+ if (!state) return false;
491
+ try {
492
+ process.kill(state.pid, "SIGTERM");
493
+ } catch {
494
+ }
495
+ await fs6.rm(state.filePath, { force: true });
496
+ await fs6.rm(PATHS.playback, { force: true });
497
+ return true;
498
+ }
499
+ async function playAudioBuffer(buffer) {
500
+ await ensureCodexDir();
501
+ await cleanupStaleState();
502
+ const config = await readConfig();
503
+ const current = await readPlaybackState();
504
+ if (current && config.playbackConflictPolicy === "ignore") {
505
+ logger.debug("Playback active and policy=ignore. Skipping new playback.");
506
+ return;
507
+ }
508
+ if (current && config.playbackConflictPolicy === "stop-and-replace") {
509
+ await stopPlayback();
510
+ }
511
+ const filePath = path4.join(PATHS.tempAudioDir, `${randomUUID2()}.mp3`);
512
+ await fs6.writeFile(filePath, buffer);
513
+ const playbackRate = Math.max(0.5, Math.min(2.5, config.speed));
514
+ const child = spawn("afplay", ["-r", playbackRate.toFixed(2), filePath], {
515
+ detached: true,
516
+ stdio: "ignore"
517
+ });
518
+ child.unref();
519
+ await writePlaybackState({
520
+ pid: child.pid ?? -1,
521
+ filePath,
522
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
523
+ });
524
+ }
525
+
526
+ // src/core/speech.ts
527
+ async function speakTextIfEligible(text, force = false) {
528
+ const config = await readConfig();
529
+ const decision = toSpeechDecision(text, config.summarizeCodeHeavy);
530
+ if (!decision.shouldSpeak) {
531
+ return { spoken: false, reason: decision.reason };
532
+ }
533
+ await setLastText(text);
534
+ if (!force && (!config.enabled || !config.autoSpeak)) {
535
+ return { spoken: false, reason: "voice-disabled" };
536
+ }
537
+ const audio = await synthesizeSpeech(decision.textForSpeech, config);
538
+ await playAudioBuffer(audio);
539
+ return { spoken: true, reason: decision.reason };
540
+ }
541
+ async function speakTextNow(text) {
542
+ const config = await readConfig();
543
+ const decision = toSpeechDecision(text, config.summarizeCodeHeavy);
544
+ if (!decision.shouldSpeak) {
545
+ return { spoken: false, reason: decision.reason };
546
+ }
547
+ const audio = await synthesizeSpeech(decision.textForSpeech, config);
548
+ await playAudioBuffer(audio);
549
+ await setLastText(text);
550
+ return { spoken: true, reason: decision.reason };
551
+ }
552
+
553
+ // src/commands/speak.ts
554
+ async function runSpeak(textArg) {
555
+ const text = textArg?.trim() || (await readCache()).lastText;
556
+ if (!text) {
557
+ console.log("No cached response found. Use codex2voice codex -- <args> first, or pass text directly.");
558
+ return;
559
+ }
560
+ const result = await speakTextNow(text);
561
+ if (!result.spoken) {
562
+ console.log(`Nothing spoken (${result.reason}).`);
563
+ return;
564
+ }
565
+ console.log(`Speaking now (${result.reason}).`);
566
+ }
567
+
568
+ // src/commands/stop.ts
569
+ async function runStop() {
570
+ const stopped = await stopPlayback();
571
+ console.log(stopped ? "Stopped active playback." : "No active playback found.");
572
+ }
573
+
574
+ // src/commands/uninstall.ts
575
+ import fs7 from "fs/promises";
576
+ import os3 from "os";
577
+ import path5 from "path";
578
+ import inquirer2 from "inquirer";
579
+ async function removeAliasBlock() {
580
+ const zshrc = path5.join(os3.homedir(), ".zshrc");
581
+ try {
582
+ const content = await fs7.readFile(zshrc, "utf8");
583
+ const cleaned = content.replace(/\n# codex2voice wrapper\nalias codex='codex2voice codex --'\n?/g, "\n").replace(/\n{3,}/g, "\n\n");
584
+ await fs7.writeFile(zshrc, cleaned, "utf8");
585
+ } catch {
586
+ }
587
+ }
588
+ async function runUninstall() {
589
+ const answers = await inquirer2.prompt([
590
+ {
591
+ type: "confirm",
592
+ name: "confirm",
593
+ message: "Remove codex2voice config, cache, keychain secret, and wrapper alias?",
594
+ default: false
595
+ }
596
+ ]);
597
+ if (!answers.confirm) {
598
+ console.log("Uninstall canceled.");
599
+ return;
600
+ }
601
+ await fs7.rm(PATHS.config, { force: true });
602
+ await fs7.rm(PATHS.cache, { force: true });
603
+ await fs7.rm(PATHS.playback, { force: true });
604
+ await fs7.rm(PATHS.tempAudioDir, { recursive: true, force: true });
605
+ await deleteApiKey();
606
+ await removeAliasBlock();
607
+ console.log("codex2voice local state removed.");
608
+ }
609
+
610
+ // src/commands/codex.ts
611
+ import fs8 from "fs/promises";
612
+ import path6 from "path";
613
+ import os4 from "os";
614
+ import { spawn as spawn2 } from "child_process";
615
+
616
+ // src/commands/codex-events.ts
617
+ function extractFinalAnswerFromResponseItem(payload) {
618
+ if (!payload) return "";
619
+ if (payload.type !== "message") return "";
620
+ if (payload.role !== "assistant") return "";
621
+ if (payload.phase !== "final_answer") return "";
622
+ return (payload.content ?? []).filter((item) => item.type === "output_text" && typeof item.text === "string").map((item) => item.text?.trim() ?? "").filter(Boolean).join("\n").trim();
623
+ }
624
+ function parseSpeechCandidatesDetailed(jsonlChunk, options = {}) {
625
+ const candidates = [];
626
+ const traces = [];
627
+ const debug = Boolean(options.debug);
628
+ const lines = jsonlChunk.split("\n");
629
+ for (let index = 0; index < lines.length; index += 1) {
630
+ const line = lines[index] ?? "";
631
+ const trimmed = line.trim();
632
+ if (!trimmed) continue;
633
+ let event;
634
+ try {
635
+ event = JSON.parse(trimmed);
636
+ } catch {
637
+ if (debug) traces.push(`line ${index + 1}: skip invalid json`);
638
+ continue;
639
+ }
640
+ if (event.type === "event_msg" && event.payload?.type === "agent_message" && event.payload?.phase === "final_answer") {
641
+ const msg = (event.payload.message ?? event.payload.last_agent_message ?? "").trim();
642
+ if (msg) {
643
+ candidates.push(msg);
644
+ if (debug) traces.push(`line ${index + 1}: accept event_msg.agent_message.final_answer`);
645
+ } else if (debug) {
646
+ traces.push(`line ${index + 1}: reject empty event_msg.agent_message.final_answer`);
647
+ }
648
+ continue;
649
+ }
650
+ if (event.type === "event_msg" && event.payload?.type === "task_complete") {
651
+ const msg = event.payload.last_agent_message?.trim();
652
+ if (msg) {
653
+ candidates.push(msg);
654
+ if (debug) traces.push(`line ${index + 1}: accept event_msg.task_complete.last_agent_message`);
655
+ } else if (debug) {
656
+ traces.push(`line ${index + 1}: reject empty event_msg.task_complete`);
657
+ }
658
+ continue;
659
+ }
660
+ if (event.type === "response_item") {
661
+ const msg = extractFinalAnswerFromResponseItem(event.payload);
662
+ if (msg) {
663
+ candidates.push(msg);
664
+ if (debug) traces.push(`line ${index + 1}: accept response_item.message.final_answer`);
665
+ } else if (debug) {
666
+ traces.push(`line ${index + 1}: reject response_item not final assistant text`);
667
+ }
668
+ continue;
669
+ }
670
+ if (debug && event.type) {
671
+ traces.push(`line ${index + 1}: skip ${event.type}`);
672
+ }
673
+ }
674
+ return { candidates, traces };
675
+ }
676
+
677
+ // src/commands/codex.ts
678
+ var SESSIONS_DIR = path6.join(os4.homedir(), ".codex", "sessions");
679
+ var POLL_INTERVAL_MS = 140;
680
+ var DISCOVERY_INTERVAL_MS = 900;
681
+ function getSessionDayDir(date) {
682
+ const yyyy = date.getFullYear().toString();
683
+ const mm = String(date.getMonth() + 1).padStart(2, "0");
684
+ const dd = String(date.getDate()).padStart(2, "0");
685
+ return path6.join(SESSIONS_DIR, yyyy, mm, dd);
686
+ }
687
+ async function listSessionFilesFast() {
688
+ const today = getSessionDayDir(/* @__PURE__ */ new Date());
689
+ const yesterday = getSessionDayDir(new Date(Date.now() - 24 * 60 * 60 * 1e3));
690
+ const dirs = today === yesterday ? [today] : [today, yesterday];
691
+ const files = [];
692
+ for (const dir of dirs) {
693
+ try {
694
+ const entries = await fs8.readdir(dir, { withFileTypes: true });
695
+ for (const entry of entries) {
696
+ if (!entry.isFile()) continue;
697
+ if (!entry.name.endsWith(".jsonl")) continue;
698
+ files.push(path6.join(dir, entry.name));
699
+ }
700
+ } catch {
701
+ }
702
+ }
703
+ return files;
704
+ }
705
+ function buildCodexArgs(userArgs) {
706
+ const joined = userArgs.join(" ");
707
+ const hasWsOverride = joined.includes("responses_websockets") || joined.includes("responses_websockets_v2");
708
+ if (hasWsOverride) return userArgs;
709
+ return [
710
+ "--disable",
711
+ "responses_websockets",
712
+ "--disable",
713
+ "responses_websockets_v2",
714
+ ...userArgs
715
+ ];
716
+ }
717
+ async function readAppendedChunk(filePath, offset) {
718
+ const stat = await fs8.stat(filePath);
719
+ const size = stat.size;
720
+ const safeOffset = offset > size ? 0 : offset;
721
+ const length = size - safeOffset;
722
+ if (length <= 0) return { nextOffset: size, chunk: "" };
723
+ const handle = await fs8.open(filePath, "r");
724
+ try {
725
+ const buffer = Buffer.alloc(length);
726
+ await handle.read(buffer, 0, length, safeOffset);
727
+ return { nextOffset: size, chunk: buffer.toString("utf8") };
728
+ } finally {
729
+ await handle.close();
730
+ }
731
+ }
732
+ async function runCodexWrapper(args, options = {}) {
733
+ const userArgs = args.length > 0 ? args : [];
734
+ const codexArgs = buildCodexArgs(userArgs);
735
+ const wrapperStartedAt = Date.now();
736
+ const debugEvents = Boolean(options.debugEvents);
737
+ const debug = (line) => {
738
+ if (!debugEvents) return;
739
+ console.error(`[codex2voice debug] ${line}`);
740
+ };
741
+ const trackedFiles = /* @__PURE__ */ new Map();
742
+ const spokenKeys = /* @__PURE__ */ new Set();
743
+ const seedTrackedFiles = async () => {
744
+ const files = await listSessionFilesFast();
745
+ await Promise.all(
746
+ files.map(async (filePath) => {
747
+ if (trackedFiles.has(filePath)) return;
748
+ try {
749
+ const stat = await fs8.stat(filePath);
750
+ const recentMs = Math.max(stat.birthtimeMs || 0, stat.mtimeMs || 0);
751
+ const shouldReplayFromStart = recentMs >= wrapperStartedAt - 5e3;
752
+ trackedFiles.set(filePath, { offset: shouldReplayFromStart ? 0 : stat.size });
753
+ debug(`tracking file: ${filePath} from offset=${shouldReplayFromStart ? 0 : stat.size}`);
754
+ } catch {
755
+ }
756
+ })
757
+ );
758
+ };
759
+ let speechQueue = Promise.resolve();
760
+ const enqueueSpeech = (message, key) => {
761
+ if (spokenKeys.has(key)) return;
762
+ spokenKeys.add(key);
763
+ speechQueue = speechQueue.then(async () => {
764
+ debug(`enqueue speech: ${message.slice(0, 120)}`);
765
+ await setLastText(message);
766
+ await speakTextIfEligible(message, false);
767
+ }).catch((error) => {
768
+ const msg = error instanceof Error ? error.message : String(error);
769
+ console.error(`codex2voice warning: ${msg}`);
770
+ });
771
+ };
772
+ let lastDiscoveryAt = 0;
773
+ const discoverIfNeeded = async () => {
774
+ const now = Date.now();
775
+ if (now - lastDiscoveryAt < DISCOVERY_INTERVAL_MS) return;
776
+ lastDiscoveryAt = now;
777
+ await seedTrackedFiles();
778
+ };
779
+ const pollSession = async () => {
780
+ await discoverIfNeeded();
781
+ for (const [filePath, state] of trackedFiles) {
782
+ let nextOffset = state.offset;
783
+ let chunk = "";
784
+ try {
785
+ const result = await readAppendedChunk(filePath, state.offset);
786
+ nextOffset = result.nextOffset;
787
+ chunk = result.chunk;
788
+ } catch {
789
+ continue;
790
+ }
791
+ trackedFiles.set(filePath, { offset: nextOffset });
792
+ if (!chunk) continue;
793
+ const { candidates, traces } = parseSpeechCandidatesDetailed(chunk, { debug: debugEvents });
794
+ for (const trace of traces) {
795
+ debug(`${path6.basename(filePath)}: ${trace}`);
796
+ }
797
+ for (let i = 0; i < candidates.length; i += 1) {
798
+ const message = candidates[i] ?? "";
799
+ if (!message) continue;
800
+ const key = `${filePath}:${state.offset}:${i}:${message}`;
801
+ enqueueSpeech(message, key);
802
+ }
803
+ }
804
+ };
805
+ await seedTrackedFiles();
806
+ const child = spawn2("codex", codexArgs, {
807
+ stdio: "inherit",
808
+ env: process.env
809
+ });
810
+ let polling = false;
811
+ const timer = setInterval(() => {
812
+ if (polling) return;
813
+ polling = true;
814
+ void pollSession().finally(() => {
815
+ polling = false;
816
+ });
817
+ }, POLL_INTERVAL_MS);
818
+ const exitCode = await new Promise((resolve) => {
819
+ child.on("close", (code) => resolve(code ?? 1));
820
+ });
821
+ clearInterval(timer);
822
+ await pollSession();
823
+ await speechQueue;
824
+ process.exitCode = exitCode;
825
+ }
826
+
827
+ // src/commands/ingest.ts
828
+ async function runIngestFromStdin(force = false) {
829
+ const chunks = [];
830
+ for await (const chunk of process.stdin) {
831
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
832
+ }
833
+ const text = Buffer.concat(chunks).toString("utf8").trim();
834
+ if (!text) {
835
+ console.log("No input text provided on stdin.");
836
+ return;
837
+ }
838
+ await setLastText(text);
839
+ const result = await speakTextIfEligible(text, force);
840
+ console.log(result.spoken ? `Spoken (${result.reason}).` : `Skipped (${result.reason}).`);
841
+ }
842
+
843
+ // src/cli.ts
844
+ var program = new Command();
845
+ program.name("codex2voice").description("ElevenLabs voice companion for Codex CLI").version("0.1.0");
846
+ program.command("init").description("Run guided setup").action(runInit);
847
+ program.command("on").description("Enable voice").action(setVoiceOn);
848
+ program.command("off").description("Disable voice").action(setVoiceOff);
849
+ program.command("status").description("Show current status").action(showStatus);
850
+ program.command("doctor").description("Run diagnostic checks").action(runDoctor);
851
+ program.command("speak [text...]").description("Speak provided text or cached last response").action(async (text) => runSpeak(text?.join(" ")));
852
+ program.command("stop").description("Stop current playback").action(runStop);
853
+ program.command("uninstall").description("Remove codex2voice local config").action(runUninstall);
854
+ program.command("codex [args...]").allowUnknownOption(true).option("--debug-events", "Print event parsing traces for diagnostics").description("Run codex and auto-speak response if enabled").action(
855
+ async (args, opts) => runCodexWrapper(args ?? [], { debugEvents: Boolean(opts.debugEvents) })
856
+ );
857
+ program.command("ingest").option("--force", "Speak even when voice is off").description("Read stdin text, cache it, and optionally speak").action(async (opts) => runIngestFromStdin(Boolean(opts.force)));
858
+ program.parseAsync(process.argv).catch((error) => {
859
+ const message = error instanceof Error ? error.message : String(error);
860
+ console.error(`codex2voice error: ${message}`);
861
+ process.exitCode = 1;
862
+ });
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "codex2voice",
3
+ "version": "0.1.1",
4
+ "description": "ElevenLabs voice companion CLI for Codex",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/goyo-lp/codex2voice.git"
8
+ },
9
+ "bugs": {
10
+ "url": "https://github.com/goyo-lp/codex2voice/issues"
11
+ },
12
+ "homepage": "https://github.com/goyo-lp/codex2voice#readme",
13
+ "type": "module",
14
+ "bin": {
15
+ "codex2voice": "dist/cli.js"
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "scripts": {
21
+ "build": "tsup src/cli.ts --format esm --dts --clean --external keytar",
22
+ "prepack": "npm run build",
23
+ "dev": "tsx src/cli.ts",
24
+ "test": "vitest run",
25
+ "check": "tsc --noEmit"
26
+ },
27
+ "keywords": [
28
+ "codex",
29
+ "voice",
30
+ "elevenlabs",
31
+ "cli"
32
+ ],
33
+ "author": "Goyo Lozano",
34
+ "license": "ISC",
35
+ "engines": {
36
+ "node": ">=20"
37
+ },
38
+ "os": [
39
+ "darwin"
40
+ ],
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "dependencies": {
45
+ "commander": "^14.0.3",
46
+ "execa": "^9.6.1",
47
+ "inquirer": "^13.3.0",
48
+ "pino": "^10.3.1",
49
+ "zod": "^4.3.6"
50
+ },
51
+ "devDependencies": {
52
+ "@types/node": "^25.3.2",
53
+ "tsup": "^8.5.1",
54
+ "tsx": "^4.21.0",
55
+ "typescript": "^5.9.3",
56
+ "vitest": "^4.0.18"
57
+ }
58
+ }