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 +141 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +862 -0
- package/package.json +58 -0
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
|
+
}
|