careervivid 1.12.48 → 1.12.50

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