careervivid 1.12.48 → 2.0.0

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.
@@ -0,0 +1,738 @@
1
+ /**
2
+ * cv interview — interactive AI mock interview with real-time voice audio.
3
+ *
4
+ * Usage:
5
+ * cv interview Prompt for role interactively (voice mode)
6
+ * cv interview --role "Sr SWE" Specify role directly (voice mode)
7
+ * cv interview --role "PM" --text Text-only fallback (no audio required)
8
+ * cv interview --role "SDE" --resume <id> Load resume for context
9
+ *
10
+ * Voice mode requires sox (handles both mic input and speaker output):
11
+ * macOS: brew install sox
12
+ * Linux: sudo apt install sox
13
+ *
14
+ * AI calls:
15
+ * - Token vend: cliGetInterviewToken Cloud Function (validates cv_live_ key, deducts credits)
16
+ * - Voice session: gemini-3.1-flash-live-preview via @google/genai Live API (direct WebSocket)
17
+ * - Feedback: agentProxy Cloud Function (standard HTTP, existing pattern)
18
+ */
19
+ import chalk from "chalk";
20
+ import readline from "readline";
21
+ import ora from "ora";
22
+ import { spawn } from "child_process";
23
+ import { readFileSync } from "fs";
24
+ import { join, dirname } from "path";
25
+ import { fileURLToPath } from "url";
26
+ import { GoogleGenAI, Modality } from "@google/genai";
27
+ import { getApiKey } from "../config.js";
28
+ import { isApiError, resumeGet } from "../api.js";
29
+ import { createLogger } from "../lib/logger.js";
30
+ /** Read CLI version from package.json (ESM-compatible) */
31
+ const __dirname_iv = dirname(fileURLToPath(import.meta.url));
32
+ let _cliVersion = "unknown";
33
+ try {
34
+ _cliVersion = JSON.parse(readFileSync(join(__dirname_iv, "../../package.json"), "utf-8")).version ?? "unknown";
35
+ }
36
+ catch { /* ignore */ }
37
+ const CLI_VERSION = _cliVersion;
38
+ /** Strip ANSI escape codes for accurate string length measurement */
39
+ const stripAnsi = (s) => s.replace(/\x1B\[[0-9;]*m/g, "");
40
+ // ─── Constants ────────────────────────────────────────────────────────────────
41
+ const AGENT_PROXY_URL = process.env.CV_FUNCTIONS_URL
42
+ ? `${process.env.CV_FUNCTIONS_URL}/agentProxy`
43
+ : "https://us-west1-jastalk-firebase.cloudfunctions.net/agentProxy";
44
+ const CLI_TOKEN_URL = process.env.CV_FUNCTIONS_URL
45
+ ? `${process.env.CV_FUNCTIONS_URL}/cliGetInterviewToken`
46
+ : "https://us-west1-jastalk-firebase.cloudfunctions.net/cliGetInterviewToken";
47
+ const CLI_BILL_URL = process.env.CV_FUNCTIONS_URL
48
+ ? `${process.env.CV_FUNCTIONS_URL}/cliInterviewBill`
49
+ : "https://us-west1-jastalk-firebase.cloudfunctions.net/cliInterviewBill";
50
+ const LIVE_MODEL = "gemini-3.1-flash-live-preview";
51
+ const FEEDBACK_MODEL = "gemini-2.5-flash";
52
+ const END_TOKEN = "<END_INTERVIEW>";
53
+ const WRAP_WIDTH = 80;
54
+ // Audio constants (matching tts.py)
55
+ const SEND_SAMPLE_RATE = 16000; // mic → Gemini (16kHz PCM)
56
+ const RECV_SAMPLE_RATE = 24000; // Gemini → speaker (24kHz PCM)
57
+ const CHUNK_MS = 100; // send audio in 100ms chunks
58
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
59
+ function wordWrap(text, width = WRAP_WIDTH) {
60
+ const lines = [];
61
+ for (const paragraph of text.split("\n")) {
62
+ const words = paragraph.split(" ");
63
+ let current = "";
64
+ for (const word of words) {
65
+ if (stripAnsi(current + " " + word).length > width && current.length > 0) {
66
+ lines.push(current);
67
+ current = word;
68
+ }
69
+ else {
70
+ current = current.length === 0 ? word : current + " " + word;
71
+ }
72
+ }
73
+ if (current.length > 0)
74
+ lines.push(current);
75
+ if (paragraph === "")
76
+ lines.push("");
77
+ }
78
+ return lines.join("\n");
79
+ }
80
+ function printAI(text) {
81
+ const clean = text.replace(END_TOKEN, "").trim();
82
+ if (!clean)
83
+ return;
84
+ console.log("");
85
+ console.log(chalk.cyan.bold(" Vivid ❯"));
86
+ wordWrap(clean).split("\n").forEach(l => console.log(` ${chalk.cyan(l)}`));
87
+ console.log("");
88
+ }
89
+ function printUser(text) {
90
+ if (!text.trim())
91
+ return;
92
+ console.log(chalk.dim("\n [You said] ") + chalk.white(text.trim()));
93
+ }
94
+ function printSystem(msg) {
95
+ console.log(chalk.dim(`\n ${msg}\n`));
96
+ }
97
+ function printBanner(role, mode) {
98
+ const modeLabel = mode === "voice"
99
+ ? chalk.green("🎙 Voice Mode")
100
+ : chalk.yellow("⌨ Text Mode");
101
+ console.log("\n" + chalk.bold.bgHex("#4f46e5").white(" CareerVivid — Interview Studio "));
102
+ console.log(chalk.dim(` Role: ${role}`));
103
+ console.log(chalk.dim(` ${modeLabel}`));
104
+ console.log(chalk.dim(" ─────────────────────────────────────────────────"));
105
+ if (mode === "voice") {
106
+ console.log(chalk.dim(" Speak your answers naturally."));
107
+ console.log(chalk.dim(" Press Ctrl+C to end and generate your feedback report."));
108
+ }
109
+ else {
110
+ console.log(chalk.dim(` Type your answers. Type ${chalk.white("exit")} or press Ctrl+C to end.`));
111
+ }
112
+ console.log("");
113
+ }
114
+ function printReport(report) {
115
+ const header = chalk.bgHex("#4f46e5").white.bold;
116
+ console.log("\n" + header(" ═══════════════════════════════════════ "));
117
+ console.log(header(" 📋 Interview Feedback Report "));
118
+ console.log(header(" ═══════════════════════════════════════ ") + "\n");
119
+ const score = (val) => {
120
+ const color = val >= 80 ? chalk.green : val >= 60 ? chalk.yellow : chalk.red;
121
+ return color.bold(`${val}/100`);
122
+ };
123
+ console.log(chalk.bold(" Scores"));
124
+ console.log(` Overall ${score(report.overallScore)}`);
125
+ console.log(` Communication ${score(report.communicationScore)}`);
126
+ console.log(` Confidence ${score(report.confidenceScore)}`);
127
+ console.log(` Relevance ${score(report.relevanceScore)}`);
128
+ console.log("\n" + chalk.green.bold(" ✅ Strengths"));
129
+ wordWrap(report.strengths, 72).split("\n").forEach(l => console.log(` ${chalk.green(l)}`));
130
+ console.log("\n" + chalk.yellow.bold(" 💡 Areas for Improvement"));
131
+ wordWrap(report.areasForImprovement, 72).split("\n").forEach(l => console.log(` ${chalk.yellow(l)}`));
132
+ console.log("\n" + chalk.dim(" ─────────────────────────────────────────────────────────────"));
133
+ console.log(chalk.dim(" View full history at: https://careervivid.app/interview"));
134
+ console.log("");
135
+ }
136
+ // ─── sox audio check ──────────────────────────────────────────────────────────
137
+ /** Check if sox is available on PATH. Returns its path or null. */
138
+ async function findSox() {
139
+ const candidates = [
140
+ "/opt/homebrew/bin/sox", // Apple Silicon brew
141
+ "/usr/local/bin/sox", // Intel brew
142
+ "/usr/bin/sox", // Linux
143
+ "sox", // if on $PATH
144
+ ];
145
+ for (const p of candidates) {
146
+ try {
147
+ await new Promise((resolve, reject) => {
148
+ const probe = spawn(p, ["--version"]);
149
+ probe.on("close", (code) => (code === 0 ? resolve() : reject()));
150
+ probe.on("error", reject);
151
+ });
152
+ return p;
153
+ }
154
+ catch { /* try next */ }
155
+ }
156
+ return null;
157
+ }
158
+ // ─── Sox audio I/O ────────────────────────────────────────────────────────────
159
+ /**
160
+ * Start microphone recording via sox.
161
+ * Returns a ChildProcess whose stdout emits raw 16kHz/16-bit/mono PCM.
162
+ */
163
+ function startMic(soxPath) {
164
+ // sox -t coreaudio default → raw PCM 16kHz 16-bit signed mono
165
+ // Falls back to -t alsa on Linux
166
+ const inputType = process.platform === "darwin" ? "coreaudio" : "alsa";
167
+ const inputDevice = process.platform === "darwin" ? "default" : "default";
168
+ return spawn(soxPath, [
169
+ "-q",
170
+ "-t", inputType, inputDevice,
171
+ "-r", String(SEND_SAMPLE_RATE),
172
+ "-b", "16",
173
+ "-e", "signed",
174
+ "-c", "1",
175
+ "-t", "raw", "-",
176
+ ]);
177
+ }
178
+ /**
179
+ * Start a sox speaker subprocess.
180
+ * Returns a ChildProcess whose stdin accepts raw 24kHz/16-bit/mono PCM.
181
+ */
182
+ function startSpeaker(soxPath) {
183
+ const outputType = process.platform === "darwin" ? "coreaudio" : "alsa";
184
+ const outputDevice = process.platform === "darwin" ? "default" : "default";
185
+ return spawn(soxPath, [
186
+ "-q",
187
+ "-t", "raw",
188
+ "-r", String(RECV_SAMPLE_RATE),
189
+ "-b", "16",
190
+ "-e", "signed",
191
+ "-c", "1", "-",
192
+ "-t", outputType, outputDevice,
193
+ ]);
194
+ }
195
+ // ─── Token Vend ───────────────────────────────────────────────────────────────
196
+ async function getGeminiToken(role) {
197
+ const apiKey = getApiKey();
198
+ if (!apiKey)
199
+ throw new Error("No API key. Run: cv login");
200
+ const res = await fetch(CLI_TOKEN_URL, {
201
+ method: "POST",
202
+ headers: { "Content-Type": "application/json" },
203
+ body: JSON.stringify({ apiKey, role }),
204
+ });
205
+ const data = await res.json();
206
+ if (res.status === 402) {
207
+ throw new Error("AI credit limit reached. Upgrade at https://careervivid.app/pricing");
208
+ }
209
+ if (!res.ok) {
210
+ throw new Error(data?.error || `Token vend failed (${res.status})`);
211
+ }
212
+ return { geminiKey: data.geminiKey, sessionId: data.sessionId };
213
+ }
214
+ // ─── Duration Billing ─────────────────────────────────────────────────────────
215
+ /** Call cliInterviewBill at session end. Returns credit summary for display. */
216
+ async function billSession(sessionId) {
217
+ const apiKey = getApiKey();
218
+ if (!apiKey || !sessionId)
219
+ return null;
220
+ try {
221
+ const res = await fetch(CLI_BILL_URL, {
222
+ method: "POST",
223
+ headers: { "Content-Type": "application/json" },
224
+ body: JSON.stringify({ apiKey, sessionId }),
225
+ });
226
+ if (!res.ok)
227
+ return null;
228
+ return await res.json();
229
+ }
230
+ catch {
231
+ return null; // billing failure is non-fatal for UX
232
+ }
233
+ }
234
+ // ─── agentProxy (question gen + feedback) ────────────────────────────────────
235
+ async function callAgentProxy(opts) {
236
+ const apiKey = getApiKey();
237
+ if (!apiKey)
238
+ throw new Error("No API key. Run: cv login");
239
+ const body = {
240
+ apiKey,
241
+ model: FEEDBACK_MODEL,
242
+ contents: opts.contents,
243
+ };
244
+ if (opts.systemInstruction)
245
+ body.systemInstruction = opts.systemInstruction;
246
+ if (opts.responseSchema) {
247
+ body.generationConfig = {
248
+ responseMimeType: opts.responseMimeType ?? "application/json",
249
+ responseSchema: opts.responseSchema,
250
+ };
251
+ }
252
+ const res = await fetch(AGENT_PROXY_URL, {
253
+ method: "POST",
254
+ headers: { "Content-Type": "application/json" },
255
+ body: JSON.stringify(body),
256
+ });
257
+ const data = await res.json();
258
+ if (!res.ok)
259
+ throw new Error(data?.error || `agentProxy error (${res.status})`);
260
+ const parts = data?.candidates?.[0]?.content?.parts ?? [];
261
+ return parts.map((p) => p.text ?? "").join("").trim();
262
+ }
263
+ // ─── Build system prompt ──────────────────────────────────────────────────────
264
+ function buildSystemPrompt(role, questions, resumeContext) {
265
+ let prompt = `You are an expert AI interviewer. Your name is Vivid. If the candidate asks your name, say "My name is Vivid." Do not say Gemini or any other name.
266
+
267
+ You are conducting a real-time voice interview for the position of: "${role}".
268
+
269
+ Start with a warm, polished introduction that outlines the role and key responsibilities. Then ask: "Do you have any questions before we begin the interview?" Wait briefly before proceeding.
270
+
271
+ Ask the questions below one at a time. You may ask one or two follow-up questions when a candidate's answer invites it.
272
+
273
+ After the final question and the candidate's response:
274
+ 1. Give a 2–3 sentence summary of overall performance.
275
+ 2. Provide 2–3 short, personalized improvement tips.
276
+ 3. End with: "Thank you for your time today! Your feedback report is being generated."
277
+ 4. Append the exact token ${END_TOKEN} at the very end (do not narrate this token).
278
+
279
+ Interview Questions:
280
+ ${questions.map((q, i) => `${i + 1}. ${q}`).join("\n")}
281
+
282
+ **Policies:**
283
+ - Never fabricate company details; suggest candidates verify with their recruiter.
284
+ - Maintain a polite, professional, encouraging tone.
285
+ - Keep responses concise — this is a voice interview, so avoid long monologues.
286
+ - Do not use markdown formatting. Speak naturally.`;
287
+ if (resumeContext) {
288
+ prompt += `\n\nCandidate resume (use for targeted follow-ups):\n--- RESUME ---\n${resumeContext}`;
289
+ }
290
+ return prompt;
291
+ }
292
+ // ─── Generate Questions (via agentProxy) ─────────────────────────────────────
293
+ async function generateQuestions(role, numQuestions) {
294
+ const spinner = ora(chalk.dim("Generating interview questions...")).start();
295
+ try {
296
+ const prompt = `Based on the following role, generate ${numQuestions} insightful interview questions covering technical skills, behavioral competencies, and role-specific scenarios. Return ONLY a valid JSON array of strings.\n\nRole: "${role}"`;
297
+ const text = await callAgentProxy({
298
+ contents: [{ role: "user", parts: [{ text: prompt }] }],
299
+ responseSchema: { type: "ARRAY", items: { type: "STRING" } },
300
+ responseMimeType: "application/json",
301
+ });
302
+ let clean = text.trim().replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "");
303
+ const questions = JSON.parse(clean);
304
+ spinner.succeed(chalk.dim(`Generated ${questions.length} questions`));
305
+ return questions;
306
+ }
307
+ catch (err) {
308
+ spinner.fail("Failed to generate questions.");
309
+ throw err;
310
+ }
311
+ }
312
+ // ─── Analyze Transcript (via agentProxy) ─────────────────────────────────────
313
+ async function analyzeTranscript(transcript, role) {
314
+ const spinner = ora(chalk.dim("Generating feedback report...")).start();
315
+ try {
316
+ const formatted = transcript
317
+ .map(e => `${e.speaker === "ai" ? "Interviewer" : "Candidate"}: ${e.text}`)
318
+ .join("\n\n");
319
+ const prompt = `You are an expert interview coach. Analyze this interview transcript for the role "${role}" and return a JSON object with: overallScore, communicationScore, confidenceScore, relevanceScore (numbers 0-100), strengths (string), areasForImprovement (string).\n\nTranscript:\n---\n${formatted}\n---`;
320
+ const text = await callAgentProxy({
321
+ contents: [{ role: "user", parts: [{ text: prompt }] }],
322
+ responseSchema: {
323
+ type: "OBJECT",
324
+ properties: {
325
+ overallScore: { type: "NUMBER" },
326
+ communicationScore: { type: "NUMBER" },
327
+ confidenceScore: { type: "NUMBER" },
328
+ relevanceScore: { type: "NUMBER" },
329
+ strengths: { type: "STRING" },
330
+ areasForImprovement: { type: "STRING" },
331
+ },
332
+ required: ["overallScore", "communicationScore", "confidenceScore", "relevanceScore", "strengths", "areasForImprovement"],
333
+ },
334
+ });
335
+ let clean = text.trim().replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "");
336
+ const report = JSON.parse(clean);
337
+ spinner.succeed(chalk.dim("Feedback report ready"));
338
+ return report;
339
+ }
340
+ catch (err) {
341
+ spinner.fail("Failed to generate feedback.");
342
+ throw err;
343
+ }
344
+ }
345
+ // ─── VOICE SESSION ────────────────────────────────────────────────────────────
346
+ async function runVoiceSession(opts) {
347
+ const { role, questions, resumeContext, soxPath } = opts;
348
+ printBanner(role, "voice");
349
+ // Create logger (sessionId not yet known — will be set after token vend)
350
+ const log = createLogger("interview", {
351
+ apiKey: getApiKey(),
352
+ version: CLI_VERSION,
353
+ });
354
+ const sessionStart = Date.now();
355
+ // Get Gemini token from Cloud Function
356
+ const connectSpinner = ora(chalk.dim("Connecting to Vivid...")).start();
357
+ let geminiKey;
358
+ let sessionId;
359
+ try {
360
+ ({ geminiKey, sessionId } = await getGeminiToken(role));
361
+ log.setSessionId(sessionId);
362
+ log.info("session_start", { role, numQuestions: questions.length, mode: "voice", soxPath });
363
+ }
364
+ catch (err) {
365
+ log.error("token_vend_failed", err, { role });
366
+ await log.dispose();
367
+ connectSpinner.fail(chalk.red(err.message));
368
+ throw err;
369
+ }
370
+ const ai = new GoogleGenAI({ apiKey: geminiKey });
371
+ const systemInstruction = buildSystemPrompt(role, questions, resumeContext);
372
+ const transcript = [];
373
+ let ended = false;
374
+ let outputBuf = ""; // accumulates AI transcription
375
+ let inputBuf = ""; // accumulates user transcription
376
+ // Half-duplex mute: stop sending mic audio while Vivid is speaking
377
+ // to prevent the mic picking up speaker output (echo loop).
378
+ let vividSpeaking = false;
379
+ let muteTimer = null;
380
+ // ── Audio processes ──────────────────────────────────────────────────
381
+ const micProc = startMic(soxPath);
382
+ const speakerProc = startSpeaker(soxPath);
383
+ micProc.stderr.on("data", () => { });
384
+ speakerProc.stderr.on("data", () => { });
385
+ // ── Connect to Live API ──────────────────────────────────────────────
386
+ let session;
387
+ try {
388
+ session = await ai.live.connect({
389
+ model: LIVE_MODEL,
390
+ callbacks: {
391
+ onopen: () => {
392
+ connectSpinner.succeed(chalk.green("✅ Vivid is live — start speaking!"));
393
+ process.stdout.write(chalk.green("\n ● Listening...\r"));
394
+ // Pipe mic PCM → Gemini, muted while Vivid is speaking
395
+ micProc.stdout.on("data", (chunk) => {
396
+ if (ended || chunk.length === 0 || vividSpeaking)
397
+ return;
398
+ try {
399
+ session.sendRealtimeInput({
400
+ audio: {
401
+ data: chunk.toString("base64"),
402
+ mimeType: `audio/pcm;rate=${SEND_SAMPLE_RATE}`,
403
+ },
404
+ });
405
+ }
406
+ catch { /* session may be closing */ }
407
+ });
408
+ },
409
+ onmessage: (msg) => {
410
+ // ── Audio output (Vivid speaking) → sox speaker ───
411
+ const audioPart = msg.serverContent?.modelTurn?.parts?.[0]?.inlineData?.data;
412
+ if (audioPart) {
413
+ // Mute mic: cancel any pending unmute and stay muted
414
+ vividSpeaking = true;
415
+ if (muteTimer) {
416
+ clearTimeout(muteTimer);
417
+ muteTimer = null;
418
+ }
419
+ process.stdout.write(chalk.blue(" ◈ Vivid speaking...\r"));
420
+ const pcmBuf = Buffer.from(audioPart, "base64");
421
+ speakerProc.stdin.write(pcmBuf);
422
+ }
423
+ // ── Output transcription (what Vivid said) ────────
424
+ const outText = msg.serverContent?.outputTranscription?.text;
425
+ if (outText)
426
+ outputBuf += outText;
427
+ // ── Input transcription (what user said) ──────────
428
+ const inText = msg.serverContent?.inputTranscription?.text;
429
+ if (inText)
430
+ inputBuf += inText;
431
+ // ── Turn complete ─────────────────────────────────
432
+ if (msg.serverContent?.turnComplete) {
433
+ if (outputBuf.trim()) {
434
+ const aiText = outputBuf.trim();
435
+ printAI(aiText);
436
+ transcript.push({ speaker: "ai", text: aiText.replace(END_TOKEN, "").trim() });
437
+ if (aiText.includes(END_TOKEN))
438
+ ended = true;
439
+ outputBuf = "";
440
+ }
441
+ if (inputBuf.trim()) {
442
+ printUser(inputBuf.trim());
443
+ transcript.push({ speaker: "user", text: inputBuf.trim() });
444
+ inputBuf = "";
445
+ }
446
+ if (!ended) {
447
+ // Unmute mic after a short delay so the speaker
448
+ // tail doesn't get captured (echo suppression buffer)
449
+ muteTimer = setTimeout(() => {
450
+ vividSpeaking = false;
451
+ muteTimer = null;
452
+ process.stdout.write(chalk.green(" ● Listening...\r"));
453
+ }, 800);
454
+ }
455
+ }
456
+ },
457
+ onerror: (e) => {
458
+ console.log(chalk.red(`\n Connection error: ${e.message ?? e}`));
459
+ ended = true;
460
+ },
461
+ onclose: () => { ended = true; },
462
+ },
463
+ config: {
464
+ responseModalities: [Modality.AUDIO],
465
+ inputAudioTranscription: {},
466
+ outputAudioTranscription: {},
467
+ speechConfig: {
468
+ voiceConfig: {
469
+ prebuiltVoiceConfig: { voiceName: "Zephyr" },
470
+ },
471
+ },
472
+ systemInstruction: { parts: [{ text: systemInstruction }] },
473
+ contextWindowCompression: {
474
+ triggerTokens: "104857",
475
+ slidingWindow: { targetTokens: "52428" },
476
+ },
477
+ },
478
+ });
479
+ }
480
+ catch (err) {
481
+ log.error("live_connect_failed", err, { role, sessionId });
482
+ await log.dispose();
483
+ connectSpinner.fail(chalk.red(`Failed to connect: ${err.message}`));
484
+ micProc.kill();
485
+ speakerProc.kill();
486
+ throw err;
487
+ }
488
+ // ── Wait until interview ends or Ctrl+C (single-SIGINT guard) ───────
489
+ let shuttingDown = false;
490
+ await new Promise((resolve) => {
491
+ const check = setInterval(() => {
492
+ if (ended) {
493
+ clearInterval(check);
494
+ resolve();
495
+ }
496
+ }, 200);
497
+ const onSigInt = () => {
498
+ if (shuttingDown)
499
+ return; // ignore double Ctrl+C
500
+ shuttingDown = true;
501
+ clearInterval(check);
502
+ printSystem("Interview ended by user.");
503
+ log.info("session_interrupted", { role, sessionId, elapsedMs: Date.now() - sessionStart });
504
+ ended = true;
505
+ resolve();
506
+ };
507
+ process.once("SIGINT", onSigInt);
508
+ });
509
+ // ── Cleanup ──────────────────────────────────────────────────────────
510
+ try {
511
+ micProc.kill("SIGTERM");
512
+ }
513
+ catch { /* ignore */ }
514
+ try {
515
+ speakerProc.stdin.end();
516
+ }
517
+ catch { /* ignore */ }
518
+ try {
519
+ session.close();
520
+ }
521
+ catch { /* ignore */ }
522
+ // Wait a moment for final audio to drain
523
+ await new Promise(r => setTimeout(r, 800));
524
+ const sessionDurationMs = Date.now() - sessionStart;
525
+ // ── Bill session (duration-based, 10s timeout to prevent hang) ──────
526
+ const billSpinner = ora(chalk.dim("Calculating session cost...")).start();
527
+ const bill = await Promise.race([
528
+ billSession(sessionId),
529
+ new Promise(r => setTimeout(() => r(null), 10_000)),
530
+ ]);
531
+ if (bill) {
532
+ billSpinner.succeed(chalk.dim(`Session: ${bill.durationMinutes}min · `) +
533
+ chalk.hex("#4f46e5").bold(`${bill.creditsCharged} credits used`) +
534
+ chalk.dim(` · ${bill.creditsRemaining} remaining`));
535
+ log.info("billing_complete", {
536
+ sessionId, durationMinutes: bill.durationMinutes,
537
+ creditsCharged: bill.creditsCharged, creditsRemaining: bill.creditsRemaining,
538
+ });
539
+ log.metric("credits_charged", bill.creditsCharged, { sessionId });
540
+ log.metric("session_duration_ms", sessionDurationMs, { sessionId });
541
+ }
542
+ else {
543
+ billSpinner.warn(chalk.dim("Session cost could not be calculated (timeout)."));
544
+ log.warn("billing_timeout", { sessionId, sessionDurationMs });
545
+ }
546
+ // ── Feedback report ──────────────────────────────────────────────────
547
+ const userTurns = transcript.filter(t => t.speaker === "user").length;
548
+ log.info("session_end", { sessionId, userTurns, aiTurns: transcript.filter(t => t.speaker === "ai").length, sessionDurationMs });
549
+ if (userTurns < 1) {
550
+ printSystem("Not enough conversation to generate a feedback report.");
551
+ await log.dispose();
552
+ return;
553
+ }
554
+ console.log(chalk.dim("\n Generating your personalized feedback report..."));
555
+ try {
556
+ const report = await analyzeTranscript(transcript, role);
557
+ printReport(report);
558
+ log.info("feedback_complete", { sessionId, overallScore: report.overallScore });
559
+ }
560
+ catch (err) {
561
+ log.error("feedback_failed", err, { sessionId });
562
+ console.log(chalk.red(` Failed to generate feedback: ${err.message}`));
563
+ }
564
+ finally {
565
+ await log.dispose();
566
+ }
567
+ }
568
+ // ─── TEXT SESSION (fallback) ──────────────────────────────────────────────────
569
+ async function runTextSession(opts) {
570
+ const { role, questions, resumeContext } = opts;
571
+ const systemInstruction = buildSystemPrompt(role, questions, resumeContext);
572
+ printBanner(role, "text");
573
+ const history = [];
574
+ const transcript = [];
575
+ let ended = false;
576
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
577
+ const askUser = () => new Promise(resolve => {
578
+ if (process.stdin.isTTY)
579
+ process.stdout.write(chalk.white.bold("\n you ❯ "));
580
+ rl.once("line", line => resolve(line.trim()));
581
+ rl.once("close", () => resolve(null));
582
+ });
583
+ const spinner = ora(chalk.dim("Vivid is connecting...")).start();
584
+ try {
585
+ const greeting = await callAgentProxy({
586
+ contents: [{ role: "user", parts: [{ text: "Hello" }] }],
587
+ systemInstruction,
588
+ });
589
+ spinner.stop();
590
+ history.push({ role: "user", parts: [{ text: "Hello" }] });
591
+ history.push({ role: "model", parts: [{ text: greeting }] });
592
+ transcript.push({ speaker: "user", text: "Hello" });
593
+ transcript.push({ speaker: "ai", text: greeting });
594
+ if (greeting.includes(END_TOKEN))
595
+ ended = true;
596
+ printAI(greeting);
597
+ }
598
+ catch (err) {
599
+ spinner.fail(chalk.red("Failed to connect to AI interviewer."));
600
+ throw err;
601
+ }
602
+ while (!ended) {
603
+ const input = await askUser();
604
+ if (input === null || input.toLowerCase() === "exit" || input.toLowerCase() === "q") {
605
+ printSystem("Interview ended early.");
606
+ break;
607
+ }
608
+ if (input === "")
609
+ continue;
610
+ history.push({ role: "user", parts: [{ text: input }] });
611
+ transcript.push({ speaker: "user", text: input });
612
+ const aiSpinner = ora({ text: "" }).start();
613
+ try {
614
+ const aiResponse = await callAgentProxy({ contents: history, systemInstruction });
615
+ aiSpinner.stop();
616
+ history.push({ role: "model", parts: [{ text: aiResponse }] });
617
+ transcript.push({ speaker: "ai", text: aiResponse.replace(END_TOKEN, "").trim() });
618
+ if (aiResponse.includes(END_TOKEN))
619
+ ended = true;
620
+ printAI(aiResponse);
621
+ }
622
+ catch (err) {
623
+ aiSpinner.stop();
624
+ console.log(chalk.red(`\n Error: ${err.message}\n`));
625
+ }
626
+ }
627
+ rl.close();
628
+ const userTurns = transcript.filter(t => t.speaker === "user").length;
629
+ if (userTurns < 2) {
630
+ printSystem("Not enough conversation to generate a feedback report.");
631
+ return;
632
+ }
633
+ console.log(chalk.dim("\n Generating your personalized feedback report..."));
634
+ try {
635
+ const report = await analyzeTranscript(transcript, role);
636
+ printReport(report);
637
+ }
638
+ catch (err) {
639
+ console.log(chalk.red(` Failed to generate feedback: ${err.message}`));
640
+ }
641
+ }
642
+ // ─── Command Registration ─────────────────────────────────────────────────────
643
+ export function registerInterviewCommand(program) {
644
+ program
645
+ .command("interview")
646
+ .description("Start an interactive AI voice interview session in the terminal")
647
+ .option("-r, --role <role>", "Role or job description to practice for")
648
+ .option("-q, --questions <n>", "Number of interview questions to generate", "5")
649
+ .option("--resume <id>", "Load a specific resume ID for context (from cv resumes list)")
650
+ .option("--text", "Use text-only mode (no audio required)")
651
+ .addHelpText("after", `
652
+ Examples:
653
+ cv interview
654
+ cv interview --role "Senior Software Engineer at Stripe"
655
+ cv interview --role "Product Manager" --questions 7
656
+ cv interview --role "Data Scientist" --resume my-resume-id
657
+ cv interview --role "SWE" --text (text-only, no sox needed)
658
+
659
+ Voice mode setup (one-time):
660
+ macOS: brew install sox
661
+ Linux: sudo apt install sox
662
+ `)
663
+ .action(async (opts) => {
664
+ if (!getApiKey()) {
665
+ console.error(chalk.red("\nNo API key configured.\n\n" +
666
+ " Run: cv login (browser login)\n" +
667
+ " cv auth set-key <key> (API key)\n"));
668
+ process.exit(1);
669
+ }
670
+ // ── Role prompt ──────────────────────────────────────────────────
671
+ let role = opts.role?.trim();
672
+ if (!role) {
673
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
674
+ role = await new Promise(resolve => {
675
+ rl.question(chalk.bold("\n What role are you interviewing for?\n ❯ "), answer => {
676
+ rl.close();
677
+ resolve(answer.trim());
678
+ });
679
+ });
680
+ }
681
+ if (!role) {
682
+ console.error(chalk.red(" Role is required."));
683
+ process.exit(1);
684
+ }
685
+ const numQuestions = Math.min(Math.max(parseInt(opts.questions, 10) || 5, 1), 12);
686
+ // ── Optional resume context ──────────────────────────────────────
687
+ let resumeContext;
688
+ if (opts.resume) {
689
+ const spinner = ora(chalk.dim("Loading resume...")).start();
690
+ try {
691
+ const result = await resumeGet(opts.resume);
692
+ if (isApiError(result)) {
693
+ spinner.warn(chalk.yellow(`Could not load resume: ${result.message}. Continuing without it.`));
694
+ }
695
+ else {
696
+ resumeContext = result.cvMarkdown;
697
+ spinner.succeed(chalk.dim(`Resume loaded: ${result.title}`));
698
+ }
699
+ }
700
+ catch {
701
+ spinner.warn(chalk.yellow("Could not load resume. Continuing without it."));
702
+ }
703
+ }
704
+ // ── Generate questions ───────────────────────────────────────────
705
+ let questions;
706
+ try {
707
+ questions = await generateQuestions(role, numQuestions);
708
+ }
709
+ catch (err) {
710
+ console.error(chalk.red(`\n Failed to generate questions: ${err.message}\n`));
711
+ process.exit(1);
712
+ }
713
+ // ── Determine mode ───────────────────────────────────────────────
714
+ if (opts.text) {
715
+ await runTextSession({ role, questions, resumeContext });
716
+ return;
717
+ }
718
+ // Probe for sox
719
+ const soxPath = await findSox();
720
+ if (!soxPath) {
721
+ console.log(chalk.yellow("\n ⚠ sox not found — falling back to text mode.\n" +
722
+ "\n To enable voice, install sox:\n" +
723
+ " macOS: brew install sox\n" +
724
+ " Linux: sudo apt install sox\n" +
725
+ "\n Or run in text mode: cv interview --text\n"));
726
+ await runTextSession({ role, questions, resumeContext });
727
+ return;
728
+ }
729
+ // Voice mode
730
+ try {
731
+ await runVoiceSession({ role, questions, resumeContext, soxPath });
732
+ }
733
+ catch (err) {
734
+ console.error(chalk.red(`\n Interview error: ${err.message}\n`));
735
+ process.exit(1);
736
+ }
737
+ });
738
+ }