careervivid 2.1.12 → 2.1.16
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/dist/commands/agent/repl.d.ts.map +1 -1
- package/dist/commands/agent/repl.js +96 -1
- package/dist/lib/shell.d.ts +13 -0
- package/dist/lib/shell.d.ts.map +1 -0
- package/dist/lib/shell.js +30 -0
- package/dist/lib/tts.d.ts +27 -0
- package/dist/lib/tts.d.ts.map +1 -0
- package/dist/lib/tts.js +198 -0
- package/package.json +2 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"repl.d.ts","sourceRoot":"","sources":["../../../src/commands/agent/repl.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,sBAAsB,EAAE,MAAM,uCAAuC,CAAC;AAC/E,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAEzD,OAAO,EAA4D,KAAK,WAAW,EAAE,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"repl.d.ts","sourceRoot":"","sources":["../../../src/commands/agent/repl.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,sBAAsB,EAAE,MAAM,uCAAuC,CAAC;AAC/E,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAEzD,OAAO,EAA4D,KAAK,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAS7G,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,GAAE,MAAM,GAAG,IAAW,QAwBtF;AAED,wBAAsB,OAAO,CAC3B,MAAM,EAAE,WAAW,GAAG,sBAAsB,GAAG,IAAI,EACnD,OAAO,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,EAC9K,gBAAgB,EAAE,WAAW,EAC7B,aAAa,EAAE,MAAM,EACrB,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,iBAAiB,EAAE,MAAM,EACzB,KAAK,EAAE,GAAG,EAAE,GACX,OAAO,CAAC,IAAI,CAAC,CA6uBf"}
|
|
@@ -6,6 +6,8 @@ import { CareerVividProxyEngine } from "../../agent/CareerVividProxyEngine.js";
|
|
|
6
6
|
import { CV_MODELS } from "./configurator.js";
|
|
7
7
|
import { loadConfig, getProviderKey, setProviderKey } from "../../config.js";
|
|
8
8
|
import { auditLog, writeSessionSummary, SESSION_ID } from "../../agent/agentAuditLog.js";
|
|
9
|
+
import { runShellEscape } from "../../lib/shell.js";
|
|
10
|
+
import { isVoiceEnabled, setVoiceEnabled, setLastResponse, getLastResponse, speakText, stopPlayback, getCurrentVoice, setCurrentVoice, getCurrentTtsModel, setCurrentTtsModel, AVAILABLE_VOICES, AVAILABLE_TTS_MODELS } from "../../lib/tts.js";
|
|
9
11
|
const { prompt } = pkg;
|
|
10
12
|
export function printCreditStatus(remaining, limit = null) {
|
|
11
13
|
if (remaining === null)
|
|
@@ -165,6 +167,15 @@ export async function askLoop(engine, options, selectedProvider, selectedModel,
|
|
|
165
167
|
chalk.dim("\n Then paste the job description, and press Enter twice to submit.\n"));
|
|
166
168
|
return ask();
|
|
167
169
|
}
|
|
170
|
+
// ── Subshell escape: input starting with ! runs as a raw shell command ──
|
|
171
|
+
if (userInput.startsWith("!")) {
|
|
172
|
+
const shellCmd = userInput.slice(1).trim();
|
|
173
|
+
if (shellCmd) {
|
|
174
|
+
process.stdout.write(chalk.dim(`\n $ ${shellCmd}\n`));
|
|
175
|
+
await runShellEscape(shellCmd);
|
|
176
|
+
}
|
|
177
|
+
return ask();
|
|
178
|
+
}
|
|
168
179
|
// ── Slash commands ──────────────────────────────────────────────
|
|
169
180
|
if (userInput.startsWith("/")) {
|
|
170
181
|
const [cmd, ...rest] = userInput.slice(1).split(" ");
|
|
@@ -173,13 +184,88 @@ export async function askLoop(engine, options, selectedProvider, selectedModel,
|
|
|
173
184
|
console.log(chalk.cyan("\n Slash commands:"));
|
|
174
185
|
console.log(chalk.dim(" /model <name> — Switch to a different model mid-session"));
|
|
175
186
|
console.log(chalk.dim(" /models — List all available CareerVivid models"));
|
|
187
|
+
console.log(chalk.dim(" /voice on|off — Toggle automatic TTS for agent responses"));
|
|
188
|
+
console.log(chalk.dim(" /speak — Read the last agent response aloud"));
|
|
176
189
|
console.log(chalk.dim(" /help — Show this help message"));
|
|
177
190
|
console.log(chalk.dim(" exit — End the session"));
|
|
178
|
-
console.log(chalk.cyan("\n
|
|
191
|
+
console.log(chalk.cyan("\n Shell escape (run terminal commands without leaving the agent):"));
|
|
192
|
+
console.log(chalk.dim(" !<command> — e.g. !ls -la or !git status\n"));
|
|
193
|
+
console.log(chalk.cyan(" Paste long content (job descriptions, cover letters):"));
|
|
179
194
|
console.log(chalk.dim(" <<< — Open multi-line paste mode; press Enter twice when done"));
|
|
180
195
|
console.log(chalk.dim(" <<<your text — Start with text directly after <<<\n"));
|
|
181
196
|
return ask();
|
|
182
197
|
}
|
|
198
|
+
if (cmd === "voice") {
|
|
199
|
+
if (arg === "on") {
|
|
200
|
+
setVoiceEnabled(true);
|
|
201
|
+
console.log(chalk.green(`\n 🔊 Voice enabled (${getCurrentVoice()} · ${getCurrentTtsModel()}).\n`));
|
|
202
|
+
}
|
|
203
|
+
else if (arg === "off") {
|
|
204
|
+
setVoiceEnabled(false);
|
|
205
|
+
stopPlayback();
|
|
206
|
+
console.log(chalk.yellow("\n 🔇 Voice disabled.\n"));
|
|
207
|
+
}
|
|
208
|
+
else if (arg === "list-voices" || arg === "voices") {
|
|
209
|
+
console.log(chalk.cyan("\n Available voices:"));
|
|
210
|
+
for (const v of AVAILABLE_VOICES) {
|
|
211
|
+
const active = v === getCurrentVoice() ? chalk.green(" ← active") : "";
|
|
212
|
+
console.log(chalk.dim(` ${v}${active}`));
|
|
213
|
+
}
|
|
214
|
+
console.log(chalk.dim("\n Usage: /voice set-voice Puck\n"));
|
|
215
|
+
}
|
|
216
|
+
else if (arg.startsWith("set-voice ")) {
|
|
217
|
+
const name = arg.slice("set-voice ".length).trim();
|
|
218
|
+
const match = AVAILABLE_VOICES.find(v => v.toLowerCase() === name.toLowerCase());
|
|
219
|
+
if (!match) {
|
|
220
|
+
console.log(chalk.red(`\n Unknown voice: "${name}". Run /voice list-voices to see options.\n`));
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
setCurrentVoice(match);
|
|
224
|
+
console.log(chalk.green(`\n 🎵 Voice set to ${chalk.bold(match)}.\n`));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
else if (arg === "list-models" || arg === "models") {
|
|
228
|
+
console.log(chalk.cyan("\n Available TTS models:"));
|
|
229
|
+
for (const m of AVAILABLE_TTS_MODELS) {
|
|
230
|
+
const active = m === getCurrentTtsModel() ? chalk.green(" ← active") : "";
|
|
231
|
+
console.log(chalk.dim(` ${m}${active}`));
|
|
232
|
+
}
|
|
233
|
+
console.log(chalk.dim("\n Usage: /voice set-model gemini-2.5-pro-preview-tts\n"));
|
|
234
|
+
}
|
|
235
|
+
else if (arg.startsWith("set-model ")) {
|
|
236
|
+
const name = arg.slice("set-model ".length).trim();
|
|
237
|
+
const match = AVAILABLE_TTS_MODELS.find(m => m === name);
|
|
238
|
+
if (!match) {
|
|
239
|
+
console.log(chalk.red(`\n Unknown model: "${name}". Run /voice list-models to see options.\n`));
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
setCurrentTtsModel(match);
|
|
243
|
+
console.log(chalk.green(`\n ⚙️ TTS model set to ${chalk.bold(match)}.\n`));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
const status = isVoiceEnabled() ? chalk.green("on") : chalk.yellow("off");
|
|
248
|
+
console.log(chalk.dim(`\n Voice is ${status} · Voice: ${chalk.white(getCurrentVoice())} · Model: ${chalk.white(getCurrentTtsModel())}`));
|
|
249
|
+
console.log(chalk.dim("\n Commands:"));
|
|
250
|
+
console.log(chalk.dim(" /voice on | off"));
|
|
251
|
+
console.log(chalk.dim(" /voice set-voice <name> e.g. /voice set-voice Puck"));
|
|
252
|
+
console.log(chalk.dim(" /voice list-voices"));
|
|
253
|
+
console.log(chalk.dim(" /voice set-model <name> e.g. /voice set-model gemini-2.5-pro-preview-tts"));
|
|
254
|
+
console.log(chalk.dim(" /voice list-models\n"));
|
|
255
|
+
}
|
|
256
|
+
return ask();
|
|
257
|
+
}
|
|
258
|
+
if (cmd === "speak") {
|
|
259
|
+
const last = getLastResponse();
|
|
260
|
+
if (!last) {
|
|
261
|
+
console.log(chalk.dim("\n Nothing to speak yet. Ask the agent something first.\n"));
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
speakText(last).catch(() => { });
|
|
265
|
+
console.log(chalk.dim("\n 🔊 Speaking last response...\n"));
|
|
266
|
+
}
|
|
267
|
+
return ask();
|
|
268
|
+
}
|
|
183
269
|
if (cmd === "models") {
|
|
184
270
|
console.log(chalk.cyan("\n Available CareerVivid models:"));
|
|
185
271
|
for (const m of CV_MODELS) {
|
|
@@ -418,6 +504,7 @@ export async function askLoop(engine, options, selectedProvider, selectedModel,
|
|
|
418
504
|
};
|
|
419
505
|
if (engine) {
|
|
420
506
|
sessionTurns++;
|
|
507
|
+
let responseAccumulator = "";
|
|
421
508
|
const sharedOnChunk = (text) => {
|
|
422
509
|
if (firstChunk) {
|
|
423
510
|
thinkingSpinner.stop();
|
|
@@ -426,6 +513,7 @@ export async function askLoop(engine, options, selectedProvider, selectedModel,
|
|
|
426
513
|
firstChunk = false;
|
|
427
514
|
}
|
|
428
515
|
process.stdout.write(text);
|
|
516
|
+
responseAccumulator += text; // Accumulate for TTS
|
|
429
517
|
};
|
|
430
518
|
const sharedOnError = (error) => {
|
|
431
519
|
thinkingSpinner.stop();
|
|
@@ -476,6 +564,13 @@ export async function askLoop(engine, options, selectedProvider, selectedModel,
|
|
|
476
564
|
onError: sharedOnError,
|
|
477
565
|
});
|
|
478
566
|
}
|
|
567
|
+
// ── TTS: store last response + auto-speak if voice enabled ──────
|
|
568
|
+
if (responseAccumulator) {
|
|
569
|
+
setLastResponse(responseAccumulator);
|
|
570
|
+
if (isVoiceEnabled()) {
|
|
571
|
+
speakText(responseAccumulator).catch(() => { });
|
|
572
|
+
}
|
|
573
|
+
}
|
|
479
574
|
// ── Clean turn separator after every AI reply ─────────────────────────────
|
|
480
575
|
process.stdout.write("\n" + chalk.dim("─".repeat(48)) + "\n");
|
|
481
576
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shell.ts — Subshell escape for the CareerVivid REPL
|
|
3
|
+
*
|
|
4
|
+
* Intercepts user input starting with `!` and spawns it as a raw
|
|
5
|
+
* shell command. stdout/stderr are piped directly to the terminal.
|
|
6
|
+
* The agent session and conversation history are never affected.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Runs a shell command and streams its output to the terminal.
|
|
10
|
+
* Returns a promise that resolves when the command exits.
|
|
11
|
+
*/
|
|
12
|
+
export declare function runShellEscape(command: string): Promise<void>;
|
|
13
|
+
//# sourceMappingURL=shell.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shell.d.ts","sourceRoot":"","sources":["../../src/lib/shell.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH;;;GAGG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAmB7D"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shell.ts — Subshell escape for the CareerVivid REPL
|
|
3
|
+
*
|
|
4
|
+
* Intercepts user input starting with `!` and spawns it as a raw
|
|
5
|
+
* shell command. stdout/stderr are piped directly to the terminal.
|
|
6
|
+
* The agent session and conversation history are never affected.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "child_process";
|
|
9
|
+
/**
|
|
10
|
+
* Runs a shell command and streams its output to the terminal.
|
|
11
|
+
* Returns a promise that resolves when the command exits.
|
|
12
|
+
*/
|
|
13
|
+
export function runShellEscape(command) {
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
const child = spawn(command, {
|
|
16
|
+
shell: true,
|
|
17
|
+
stdio: "inherit", // pipe stdin/stdout/stderr directly to TTY
|
|
18
|
+
});
|
|
19
|
+
child.on("error", (err) => {
|
|
20
|
+
process.stderr.write(`\n⚠️ Shell error: ${err.message}\n`);
|
|
21
|
+
resolve();
|
|
22
|
+
});
|
|
23
|
+
child.on("close", (code) => {
|
|
24
|
+
if (code !== 0 && code !== null) {
|
|
25
|
+
process.stdout.write(`\n [exit ${code}]\n`);
|
|
26
|
+
}
|
|
27
|
+
resolve();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tts.ts — Text-to-Speech engine for the CareerVivid REPL
|
|
3
|
+
*
|
|
4
|
+
* Authenticates using the user's CareerVivid API key (cv_live_...) to fetch
|
|
5
|
+
* a short-lived Gemini key from the backend — exactly like `cv interview`.
|
|
6
|
+
* No separate GEMINI_API_KEY required.
|
|
7
|
+
*
|
|
8
|
+
* Toggle: /voice on | /voice off
|
|
9
|
+
* Replay: /speak
|
|
10
|
+
*/
|
|
11
|
+
export declare const AVAILABLE_VOICES: readonly ["Zephyr", "Puck", "Charon", "Kore", "Fenrir", "Aoede", "Orbit", "Stellar", "Leda", "Orus"];
|
|
12
|
+
export declare const AVAILABLE_TTS_MODELS: readonly ["gemini-2.5-flash-preview-tts", "gemini-2.5-pro-preview-tts"];
|
|
13
|
+
export declare function isVoiceEnabled(): boolean;
|
|
14
|
+
export declare function setVoiceEnabled(on: boolean): void;
|
|
15
|
+
export declare function setLastResponse(text: string): void;
|
|
16
|
+
export declare function getLastResponse(): string;
|
|
17
|
+
export declare function getCurrentVoice(): string;
|
|
18
|
+
export declare function setCurrentVoice(v: string): void;
|
|
19
|
+
export declare function getCurrentTtsModel(): string;
|
|
20
|
+
export declare function setCurrentTtsModel(m: string): void;
|
|
21
|
+
export declare function stopPlayback(): void;
|
|
22
|
+
/**
|
|
23
|
+
* Synthesizes `text` via Gemini TTS using the CareerVivid API key for auth.
|
|
24
|
+
* Non-blocking — errors are silently swallowed so the REPL is never disrupted.
|
|
25
|
+
*/
|
|
26
|
+
export declare function speakText(text: string, _unusedKey?: string): Promise<void>;
|
|
27
|
+
//# sourceMappingURL=tts.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tts.d.ts","sourceRoot":"","sources":["../../src/lib/tts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAgBH,eAAO,MAAM,gBAAgB,sGAWnB,CAAC;AAEX,eAAO,MAAM,oBAAoB,yEAGvB,CAAC;AAYX,wBAAgB,cAAc,YAA2B;AACzD,wBAAgB,eAAe,CAAC,EAAE,EAAE,OAAO,QAAwB;AACnE,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,QAA0B;AACtE,wBAAgB,eAAe,WAA2B;AAC1D,wBAAgB,eAAe,WAA2B;AAC1D,wBAAgB,eAAe,CAAC,CAAC,EAAE,MAAM,QAAuB;AAChE,wBAAgB,kBAAkB,WAA8B;AAChE,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,MAAM,QAA0B;AA8BtE,wBAAgB,YAAY,SAK3B;AA2DD;;;GAGG;AACH,wBAAsB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAuDhF"}
|
package/dist/lib/tts.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tts.ts — Text-to-Speech engine for the CareerVivid REPL
|
|
3
|
+
*
|
|
4
|
+
* Authenticates using the user's CareerVivid API key (cv_live_...) to fetch
|
|
5
|
+
* a short-lived Gemini key from the backend — exactly like `cv interview`.
|
|
6
|
+
* No separate GEMINI_API_KEY required.
|
|
7
|
+
*
|
|
8
|
+
* Toggle: /voice on | /voice off
|
|
9
|
+
* Replay: /speak
|
|
10
|
+
*/
|
|
11
|
+
import { writeFileSync, unlinkSync } from "fs";
|
|
12
|
+
import { spawn } from "child_process";
|
|
13
|
+
import { tmpdir } from "os";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
import { GoogleGenAI, Modality } from "@google/genai";
|
|
16
|
+
import { getApiKey } from "../config.js";
|
|
17
|
+
// ── Backend endpoint (same as interview token vend) ───────────────────────────
|
|
18
|
+
const TTS_TOKEN_URL = process.env.CV_FUNCTIONS_URL
|
|
19
|
+
? `${process.env.CV_FUNCTIONS_URL}/cliGetInterviewToken`
|
|
20
|
+
: "https://us-west1-jastalk-firebase.cloudfunctions.net/cliGetInterviewToken";
|
|
21
|
+
// ── Available options ────────────────────────────────────────────────────────
|
|
22
|
+
export const AVAILABLE_VOICES = [
|
|
23
|
+
"Zephyr", // Bright, energetic
|
|
24
|
+
"Puck", // Upbeat, playful
|
|
25
|
+
"Charon", // Informative, measured
|
|
26
|
+
"Kore", // Firm, confident
|
|
27
|
+
"Fenrir", // Excitable, dynamic
|
|
28
|
+
"Aoede", // Breezy, easy-going
|
|
29
|
+
"Orbit", // Friendly, relaxed
|
|
30
|
+
"Stellar", // Smooth, polished
|
|
31
|
+
"Leda", // Warm, natural
|
|
32
|
+
"Orus", // Confident, authoritative
|
|
33
|
+
];
|
|
34
|
+
export const AVAILABLE_TTS_MODELS = [
|
|
35
|
+
"gemini-2.5-flash-preview-tts", // Fast, efficient (default)
|
|
36
|
+
"gemini-2.5-pro-preview-tts", // Higher quality, slower
|
|
37
|
+
];
|
|
38
|
+
// ── State ────────────────────────────────────────────────────────────────────
|
|
39
|
+
let voiceEnabled = false;
|
|
40
|
+
let lastResponse = "";
|
|
41
|
+
let playbackProcess = null;
|
|
42
|
+
let currentVoice = "Zephyr";
|
|
43
|
+
let currentTtsModel = "gemini-2.5-flash-preview-tts";
|
|
44
|
+
// Cache the Gemini key for the session so we don't hit the endpoint every turn
|
|
45
|
+
let cachedGeminiKey = null;
|
|
46
|
+
export function isVoiceEnabled() { return voiceEnabled; }
|
|
47
|
+
export function setVoiceEnabled(on) { voiceEnabled = on; }
|
|
48
|
+
export function setLastResponse(text) { lastResponse = text; }
|
|
49
|
+
export function getLastResponse() { return lastResponse; }
|
|
50
|
+
export function getCurrentVoice() { return currentVoice; }
|
|
51
|
+
export function setCurrentVoice(v) { currentVoice = v; }
|
|
52
|
+
export function getCurrentTtsModel() { return currentTtsModel; }
|
|
53
|
+
export function setCurrentTtsModel(m) { currentTtsModel = m; }
|
|
54
|
+
// ── Gemini key via CV API key ─────────────────────────────────────────────────
|
|
55
|
+
async function fetchGeminiKey() {
|
|
56
|
+
if (cachedGeminiKey)
|
|
57
|
+
return cachedGeminiKey;
|
|
58
|
+
const apiKey = getApiKey();
|
|
59
|
+
if (!apiKey)
|
|
60
|
+
return null;
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetch(TTS_TOKEN_URL, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: { "Content-Type": "application/json" },
|
|
65
|
+
body: JSON.stringify({ apiKey, role: "tts" }),
|
|
66
|
+
});
|
|
67
|
+
if (!res.ok)
|
|
68
|
+
return null;
|
|
69
|
+
const data = await res.json();
|
|
70
|
+
if (data?.geminiKey) {
|
|
71
|
+
cachedGeminiKey = data.geminiKey;
|
|
72
|
+
return cachedGeminiKey;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// Network error — fall through
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
// ── Audio Playback ────────────────────────────────────────────────────────────
|
|
81
|
+
export function stopPlayback() {
|
|
82
|
+
if (playbackProcess && !playbackProcess.killed) {
|
|
83
|
+
playbackProcess.kill("SIGKILL");
|
|
84
|
+
playbackProcess = null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function playWav(wavBuffer) {
|
|
88
|
+
const tmpFile = join(tmpdir(), `cv-tts-${Date.now()}.wav`);
|
|
89
|
+
writeFileSync(tmpFile, wavBuffer);
|
|
90
|
+
const platform = process.platform;
|
|
91
|
+
let playerCmd;
|
|
92
|
+
let playerArgs;
|
|
93
|
+
if (platform === "darwin") {
|
|
94
|
+
playerCmd = "afplay";
|
|
95
|
+
playerArgs = [tmpFile];
|
|
96
|
+
}
|
|
97
|
+
else if (platform === "linux") {
|
|
98
|
+
playerCmd = "aplay";
|
|
99
|
+
playerArgs = ["-q", tmpFile];
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
playerCmd = "powershell";
|
|
103
|
+
playerArgs = ["-c", `(New-Object System.Media.SoundPlayer '${tmpFile}').PlaySync()`];
|
|
104
|
+
}
|
|
105
|
+
stopPlayback();
|
|
106
|
+
const child = spawn(playerCmd, playerArgs, { stdio: "ignore", detached: false });
|
|
107
|
+
playbackProcess = child;
|
|
108
|
+
child.on("close", () => {
|
|
109
|
+
try {
|
|
110
|
+
unlinkSync(tmpFile);
|
|
111
|
+
}
|
|
112
|
+
catch { /* ignore */ }
|
|
113
|
+
if (playbackProcess === child)
|
|
114
|
+
playbackProcess = null;
|
|
115
|
+
});
|
|
116
|
+
child.on("error", () => {
|
|
117
|
+
try {
|
|
118
|
+
unlinkSync(tmpFile);
|
|
119
|
+
}
|
|
120
|
+
catch { /* ignore */ }
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
// ── WAV Builder ───────────────────────────────────────────────────────────────
|
|
124
|
+
function buildWavHeader(dataLength, sampleRate = 24000, channels = 1, bitsPerSample = 16) {
|
|
125
|
+
const byteRate = sampleRate * channels * bitsPerSample / 8;
|
|
126
|
+
const blockAlign = channels * bitsPerSample / 8;
|
|
127
|
+
const header = Buffer.alloc(44);
|
|
128
|
+
header.write("RIFF", 0);
|
|
129
|
+
header.writeUInt32LE(36 + dataLength, 4);
|
|
130
|
+
header.write("WAVE", 8);
|
|
131
|
+
header.write("fmt ", 12);
|
|
132
|
+
header.writeUInt32LE(16, 16);
|
|
133
|
+
header.writeUInt16LE(1, 20); // PCM
|
|
134
|
+
header.writeUInt16LE(channels, 22);
|
|
135
|
+
header.writeUInt32LE(sampleRate, 24);
|
|
136
|
+
header.writeUInt32LE(byteRate, 28);
|
|
137
|
+
header.writeUInt16LE(blockAlign, 32);
|
|
138
|
+
header.writeUInt16LE(bitsPerSample, 34);
|
|
139
|
+
header.write("data", 36);
|
|
140
|
+
header.writeUInt32LE(dataLength, 40);
|
|
141
|
+
return header;
|
|
142
|
+
}
|
|
143
|
+
// ── TTS Synthesis ─────────────────────────────────────────────────────────────
|
|
144
|
+
/**
|
|
145
|
+
* Synthesizes `text` via Gemini TTS using the CareerVivid API key for auth.
|
|
146
|
+
* Non-blocking — errors are silently swallowed so the REPL is never disrupted.
|
|
147
|
+
*/
|
|
148
|
+
export async function speakText(text, _unusedKey) {
|
|
149
|
+
if (!text.trim())
|
|
150
|
+
return;
|
|
151
|
+
const geminiKey = await fetchGeminiKey();
|
|
152
|
+
if (!geminiKey)
|
|
153
|
+
return; // No key available — silently skip
|
|
154
|
+
// Strip markdown for natural-sounding speech
|
|
155
|
+
const cleaned = text
|
|
156
|
+
.replace(/```[\s\S]*?```/g, "")
|
|
157
|
+
.replace(/`[^`]+`/g, "")
|
|
158
|
+
.replace(/\*\*(.*?)\*\*/g, "$1")
|
|
159
|
+
.replace(/\*(.*?)\*/g, "$1")
|
|
160
|
+
.replace(/^[#>•\-*]\s*/gm, "")
|
|
161
|
+
.replace(/\s+/g, " ")
|
|
162
|
+
.trim()
|
|
163
|
+
.slice(0, 1000);
|
|
164
|
+
if (!cleaned)
|
|
165
|
+
return;
|
|
166
|
+
try {
|
|
167
|
+
const ai = new GoogleGenAI({ apiKey: geminiKey });
|
|
168
|
+
const response = await ai.models.generateContent({
|
|
169
|
+
model: currentTtsModel,
|
|
170
|
+
contents: [{ parts: [{ text: cleaned }] }],
|
|
171
|
+
config: {
|
|
172
|
+
responseModalities: [Modality.AUDIO],
|
|
173
|
+
speechConfig: {
|
|
174
|
+
voiceConfig: {
|
|
175
|
+
prebuiltVoiceConfig: { voiceName: currentVoice },
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
const parts = response?.candidates?.[0]?.content?.parts ?? [];
|
|
181
|
+
const audioParts = [];
|
|
182
|
+
for (const part of parts) {
|
|
183
|
+
if (part.inlineData?.data) {
|
|
184
|
+
audioParts.push(Buffer.from(part.inlineData.data, "base64"));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (audioParts.length === 0)
|
|
188
|
+
return;
|
|
189
|
+
const pcmData = Buffer.concat(audioParts);
|
|
190
|
+
const wavBuffer = Buffer.concat([buildWavHeader(pcmData.length), pcmData]);
|
|
191
|
+
playWav(wavBuffer);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// Silently ignore — TTS errors must never crash the agent REPL
|
|
195
|
+
// Invalidate cached key so we retry fetching on the next call
|
|
196
|
+
cachedGeminiKey = null;
|
|
197
|
+
}
|
|
198
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "careervivid",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.16",
|
|
4
4
|
"description": "Official CLI for CareerVivid — AI voice interviews, autonomous job applications, resume editing, and portfolio publishing from your terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"mermaid": "11.14.0",
|
|
32
32
|
"open": "^10.1.0",
|
|
33
33
|
"ora": "^8.1.0",
|
|
34
|
-
"playwright-core": "
|
|
34
|
+
"playwright-core": "1.59.1",
|
|
35
35
|
"semver": "7.7.4",
|
|
36
36
|
"update-notifier": "^7.3.1"
|
|
37
37
|
},
|