careervivid 2.1.1 → 2.1.13

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 CHANGED
@@ -5,7 +5,7 @@
5
5
  [![npm version](https://img.shields.io/npm/v/careervivid?color=0ea5e9&label=careervivid)](https://www.npmjs.com/package/careervivid)
6
6
  [![License: MIT](https://img.shields.io/badge/license-MIT-brightgreen)](LICENSE)
7
7
  [![Node ≥18](https://img.shields.io/badge/node-%3E%3D18-blue)](https://nodejs.org)
8
- [![v2.1.1](https://img.shields.io/badge/version-2.1.1-6366f1?logo=sparkles)](https://www.npmjs.com/package/careervivid)
8
+ [![v2.1.5](https://img.shields.io/badge/version-2.1.5-6366f1?logo=sparkles)](https://www.npmjs.com/package/careervivid)
9
9
 
10
10
  ---
11
11
 
@@ -10,7 +10,8 @@
10
10
  *
11
11
  * DO NOT scatter instructions across QueryEngine.ts, engineResolver.ts, or anywhere else.
12
12
  */
13
- export declare const BASE_IDENTITY: string;
13
+ /** @deprecated Keep for any external callers that import baseIdentity directly. */
14
+ export declare const baseIdentity: string;
14
15
  export declare const RESUME_SECTION: string;
15
16
  export declare const CODING_SECTION: string;
16
17
  export declare const JOBS_TOOLS_SECTION: string;
@@ -1 +1 @@
1
- {"version":3,"file":"instructions.d.ts","sourceRoot":"","sources":["../../src/agent/instructions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAKH,eAAO,MAAM,aAAa,QAalB,CAAC;AAMT,eAAO,MAAM,cAAc,QAcnB,CAAC;AAMT,eAAO,MAAM,cAAc,QAqCnB,CAAC;AAMT,eAAO,MAAM,kBAAkB,QA0DvB,CAAC;AAMT,eAAO,MAAM,YAAY,QA6CjB,CAAC;AAMT,eAAO,MAAM,iBAAiB,QAkBtB,CAAC;AAOT;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE;IACzC,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB,GAAG,MAAM,CAkCT"}
1
+ {"version":3,"file":"instructions.d.ts","sourceRoot":"","sources":["../../src/agent/instructions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAsCH,mFAAmF;AACnF,eAAO,MAAM,YAAY,QAAsB,CAAC;AAMhD,eAAO,MAAM,cAAc,QAcnB,CAAC;AAMT,eAAO,MAAM,cAAc,QAqCnB,CAAC;AAMT,eAAO,MAAM,kBAAkB,QA0DvB,CAAC;AAMT,eAAO,MAAM,YAAY,QA6CjB,CAAC;AAMT,eAAO,MAAM,iBAAiB,QAuBtB,CAAC;AAOT;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE;IACzC,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB,GAAG,MAAM,CAqCT"}
@@ -11,10 +11,26 @@
11
11
  * DO NOT scatter instructions across QueryEngine.ts, engineResolver.ts, or anywhere else.
12
12
  */
13
13
  import { buildMemoryBlock, MEMORY_SECTION } from "./memory.js";
14
- export const BASE_IDENTITY = `
14
+ /** Returns today's date as YYYY-MM-DD in the local timezone. */
15
+ function todayStr() {
16
+ const d = new Date();
17
+ return [
18
+ d.getFullYear(),
19
+ String(d.getMonth() + 1).padStart(2, '0'),
20
+ String(d.getDate()).padStart(2, '0'),
21
+ ].join('-');
22
+ }
23
+ /** @internal — call via buildSystemPrompt(), not directly */
24
+ function buildBaseIdentity() {
25
+ return `
15
26
  You are CareerVivid AI — an autonomous career intelligence agent built into the CareerVivid CLI.
16
27
  You help users manage their resume, track job applications, find new opportunities, prep for interviews, and grow their career.
17
28
 
29
+ ## Today's Date
30
+ **TODAY IS: ${todayStr()}**
31
+ Use this exact date whenever you need "today", "now", or the current date.
32
+ NEVER guess, infer, or assume a date — always use the value above.
33
+
18
34
  ## Core Behavioral Rules
19
35
 
20
36
  1. **ANSWER FIRST** — If the user asks a direct question (e.g., "Did you find X?", "What is Y?"), answer it explicitly and immediately. Do not ignore the question or bury it behind a massive multi-step automated tangent.
@@ -25,6 +41,9 @@ You help users manage their resume, track job applications, find new opportuniti
25
41
  6. **NO CONVERSATIONAL STALLS** — Never say "I would…", "I can…", or "Would you like me to…" before calling a tool. Just call it.
26
42
  7. **TRANSPARENCY** — If uncertain, list options and your recommendation. Never silently choose.
27
43
  `.trim();
44
+ }
45
+ /** @deprecated Keep for any external callers that import baseIdentity directly. */
46
+ export const baseIdentity = buildBaseIdentity();
28
47
  // ---------------------------------------------------------------------------
29
48
  // §2 — Resume section (appended in --resume and --jobs modes)
30
49
  // ---------------------------------------------------------------------------
@@ -215,7 +234,12 @@ When the user sends a generic greeting ("hey", "hi", "hello", "start"), respond
215
234
 
216
235
  Just tell me what you need!"
217
236
 
218
- If the user says "pick up where we left off" or similar, immediately reference the Recent Session Memory block (if any) and resume the most recent task. If there is no memory yet, say so and offer to start fresh.
237
+ If the user says "pick up where we left off" or similar:
238
+ 1. First check the Career Context block below for a "Last session" line and reference it.
239
+ 2. If the Career Context block is empty or has no "Last session", do NOT say "I have no memory."
240
+ Instead, immediately call tracker_list_jobs (or tracker_dashboard) + list_resumes in parallel
241
+ to reconstruct the user's current state from live data, then summarize what you find.
242
+ 3. Only say "nothing saved yet" if BOTH the memory block AND all tool calls return empty results.
219
243
  `.trim();
220
244
  // ---------------------------------------------------------------------------
221
245
  // §7 — Assembled system prompts per mode (the public API)
@@ -226,6 +250,8 @@ If the user says "pick up where we left off" or similar, immediately reference t
226
250
  * This is the ONLY function that engineResolver.ts should call.
227
251
  */
228
252
  export function buildSystemPrompt(options) {
253
+ // Always build fresh so today's date is injected at call time, not module-load time.
254
+ const baseIdentity = buildBaseIdentity();
229
255
  // Load memory block (empty string if no memory file exists yet)
230
256
  const memoryBlock = buildMemoryBlock();
231
257
  const memorySections = memoryBlock
@@ -233,7 +259,7 @@ export function buildSystemPrompt(options) {
233
259
  : [];
234
260
  if (options.jobs) {
235
261
  return [
236
- BASE_IDENTITY,
262
+ baseIdentity,
237
263
  RESUME_SECTION,
238
264
  JOBS_TOOLS_SECTION,
239
265
  JOBS_HARNESS,
@@ -243,7 +269,7 @@ export function buildSystemPrompt(options) {
243
269
  }
244
270
  if (options.resume) {
245
271
  return [
246
- BASE_IDENTITY,
272
+ baseIdentity,
247
273
  RESUME_SECTION,
248
274
  ...memorySections,
249
275
  GREETING_PROTOCOL,
@@ -251,7 +277,7 @@ export function buildSystemPrompt(options) {
251
277
  }
252
278
  // Default: coding / general mode
253
279
  return [
254
- BASE_IDENTITY,
280
+ baseIdentity,
255
281
  CODING_SECTION,
256
282
  ...memorySections,
257
283
  GREETING_PROTOCOL,
@@ -21,5 +21,5 @@ export declare function printBanner(options: {
21
21
  resume?: boolean;
22
22
  pro?: boolean;
23
23
  think?: number;
24
- }, selectedProvider: string, selectedModel: string, thinkingBudget: number): void;
24
+ }, selectedProvider: string, selectedModel: string, _thinkingBudget: number): void;
25
25
  //# sourceMappingURL=engineResolver.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"engineResolver.d.ts","sourceRoot":"","sources":["../../../src/commands/agent/engineResolver.ts"],"names":[],"mappings":"AACA,OAAO,EAAyC,KAAK,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAC1F,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AACzD,OAAO,EAAE,sBAAsB,EAAE,MAAM,uCAAuC,CAAC;AAC/E,OAAO,EAAE,IAAI,EAAE,MAAM,qBAAqB,CAAC;AAI3C;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE;IAAE,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,MAAM,CAE5G;AAED,wBAAgB,WAAW,CACzB,gBAAgB,EAAE,WAAW,EAC7B,aAAa,EAAE,MAAM,EACrB,iBAAiB,EAAE,MAAM,EACzB,KAAK,EAAE,IAAI,EAAE,EACb,cAAc,EAAE,MAAM,EACtB,eAAe,EAAE,OAAO,EACxB,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,YAAY,EAAE,MAAM,GAAG,SAAS,EAChC,OAAO,EAAE,MAAM,GAAG,SAAS,GAC1B,WAAW,GAAG,sBAAsB,GAAG,IAAI,CA2B7C;AAED,wBAAgB,WAAW,CACzB,OAAO,EAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,GAAG,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,EAC9F,gBAAgB,EAAE,MAAM,EACxB,aAAa,EAAE,MAAM,EACrB,cAAc,EAAE,MAAM,QAwBvB"}
1
+ {"version":3,"file":"engineResolver.d.ts","sourceRoot":"","sources":["../../../src/commands/agent/engineResolver.ts"],"names":[],"mappings":"AACA,OAAO,EAAyC,KAAK,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAC1F,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AACzD,OAAO,EAAE,sBAAsB,EAAE,MAAM,uCAAuC,CAAC;AAC/E,OAAO,EAAE,IAAI,EAAE,MAAM,qBAAqB,CAAC;AAI3C;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE;IAAE,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,MAAM,CAE5G;AAED,wBAAgB,WAAW,CACzB,gBAAgB,EAAE,WAAW,EAC7B,aAAa,EAAE,MAAM,EACrB,iBAAiB,EAAE,MAAM,EACzB,KAAK,EAAE,IAAI,EAAE,EACb,cAAc,EAAE,MAAM,EACtB,eAAe,EAAE,OAAO,EACxB,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,YAAY,EAAE,MAAM,GAAG,SAAS,EAChC,OAAO,EAAE,MAAM,GAAG,SAAS,GAC1B,WAAW,GAAG,sBAAsB,GAAG,IAAI,CA2B7C;AAED,wBAAgB,WAAW,CACzB,OAAO,EAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,GAAG,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,EAC9F,gBAAgB,EAAE,MAAM,EACxB,aAAa,EAAE,MAAM,EACrB,eAAe,EAAE,MAAM,QAcxB"}
@@ -40,30 +40,16 @@ export function buildEngine(selectedProvider, selectedModel, systemInstruction,
40
40
  }
41
41
  return engine;
42
42
  }
43
- export function printBanner(options, selectedProvider, selectedModel, thinkingBudget) {
44
- console.log(chalk.bold.cyan("\n🤖 CareerVivid Agent"));
43
+ export function printBanner(options, selectedProvider, selectedModel, _thinkingBudget) {
44
+ console.log(chalk.bold.hex("#6366f1")("\n CareerVivid Agent"));
45
45
  if (selectedProvider === "careervivid") {
46
46
  const cost = MODEL_CREDIT_COST[selectedModel] ?? 1;
47
47
  console.log(chalk.dim(` Model: ${selectedModel}`) +
48
- chalk.gray(` [${cost} credit${cost !== 1 ? "s" : ""}/turn via CareerVivid Cloud]`));
48
+ chalk.dim(` [${cost} credit${cost !== 1 ? "s" : ""}/turn via CareerVivid Cloud]`));
49
49
  }
50
50
  else {
51
- console.log(chalk.cyan(` Provider: ${selectedProvider} Model: ${selectedModel} [0 credits]`));
51
+ console.log(chalk.dim(` Provider: ${selectedProvider} · Model: ${selectedModel} [0 credits]`));
52
52
  }
53
- if (options.coding)
54
- console.log(chalk.green(" Coding mode: file I/O, shell, search tools active"));
55
- if (options.jobs) {
56
- console.log(chalk.cyan(" ✔ Job mode: search, score, apply_to_job, openings_scan tools active"));
57
- console.log(chalk.magenta(" ✔ Browser mode: navigate, click, type, select, scroll, screenshot tools active"));
58
- console.log(chalk.yellow(" ✔ Local tracker: tracker_list_jobs · tracker_update_job · tracker_add_job"));
59
- console.log(chalk.yellow(" + tracker_rank_priority · tracker_dashboard · tracker_find_stale"));
60
- console.log(chalk.yellow(" + tracker_recheck_urls · openings_scan · openings_list (jobs.csv v2)"));
61
- }
62
- else if (options.resume)
63
- console.log(chalk.cyan(" ✔ Resume mode: get_resume tool active"));
64
- if (options.pro)
65
- console.log(chalk.magenta(` ✔ Pro mode: ${selectedModel} + thinking (${thinkingBudget} tokens)`));
66
- else if (thinkingBudget > 0)
67
- console.log(chalk.yellow(` ✔ Thinking mode: ${thinkingBudget} token budget`));
68
- console.log(chalk.gray(` Type 'exit' to quit · /model to switch models · /help for commands.\n`));
53
+ const modeLabel = options.jobs ? "Jobs & Applications" : options.resume ? "Resume" : "General";
54
+ console.log(chalk.dim(` Mode: ${modeLabel} · Type 'exit' to quit · /help for commands.\n`));
69
55
  }
@@ -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;AAK7G,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,CAqlBf"}
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;AAO7G,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,CAosBf"}
@@ -4,8 +4,10 @@ import ora from "ora";
4
4
  import { isSafeCommand } from "../../agent/tools/coding.js";
5
5
  import { CareerVividProxyEngine } from "../../agent/CareerVividProxyEngine.js";
6
6
  import { CV_MODELS } from "./configurator.js";
7
- import { loadConfig, getProviderKey, setProviderKey } from "../../config.js";
7
+ import { loadConfig, getGeminiKey, 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 } from "../../lib/tts.js";
9
11
  const { prompt } = pkg;
10
12
  export function printCreditStatus(remaining, limit = null) {
11
13
  if (remaining === null)
@@ -67,65 +69,97 @@ export async function askLoop(engine, options, selectedProvider, selectedModel,
67
69
  .catch(e => { clearTimeout(timer); reject(e); });
68
70
  });
69
71
  }
70
- const ask = async () => {
72
+ // ── First-turn menu items ────────────────────────────────────────────────
73
+ const MENU_ITEMS = [
74
+ "📄 View or update my resume",
75
+ "🔍 Search for job opportunities",
76
+ "📊 Check my job pipeline / tracker",
77
+ "✉️ Draft a cover letter or tailor my resume",
78
+ "🎙 Start an AI mock interview (voice or text)",
79
+ "📈 Get an overview of my job search progress",
80
+ "🗓️ Pick up where we left off",
81
+ ];
82
+ const ask = async (isFirstTurn = false) => {
71
83
  try {
72
- const promptStartTime = Date.now();
73
- const response = await prompt({
74
- type: "input",
75
- name: "query",
76
- message: pasteBuffer.length > 0
77
- ? chalk.dim("... ")
78
- : chalk.bold.cyan("❯") + chalk.dim(" ·"),
79
- });
80
- const duration = Date.now() - promptStartTime;
81
- let userInput = response.query;
82
- // ── Multi-line paste mode: user typed <<< (or <<<paste) ─────────────
83
- // Allows pasting arbitrarily long content (e.g. full JD) without truncation.
84
- if (userInput.trim() === "<<<" || userInput.trim().toLowerCase().startsWith("<<<")) {
85
- const prefix = userInput.trim().slice(3).trim(); // text after <<<
86
- console.log(chalk.dim(" 📋 Multi-line mode: paste your text, then press Enter twice to submit.\n"));
87
- const lines = prefix ? [prefix] : [];
88
- let emptyCount = 0;
89
- while (emptyCount < 1) {
90
- const lineResp = await prompt({
91
- type: "input",
92
- name: "line",
93
- message: chalk.dim(" │"),
94
- });
95
- if (lineResp.line === "") {
96
- emptyCount++;
97
- }
98
- else {
99
- emptyCount = 0;
100
- lines.push(lineResp.line);
101
- }
84
+ let userInput;
85
+ if (isFirstTurn) {
86
+ // ── Hybrid menu: arrow-key select OR free type ─────────────────
87
+ console.log(chalk.dim(" What would you like to do today?\n"));
88
+ for (const item of MENU_ITEMS) {
89
+ console.log(chalk.dim(` ${item}`));
102
90
  }
103
- userInput = lines.join("\n").trim();
104
- pasteBuffer = [];
105
- }
106
- else if (duration < 150) {
107
- // Handle multiline copy & paste: prompt resolves extremely fast if stdin is buffered.
108
- // 150ms threshold gives enough headroom for large pastes (long JDs, cover letters).
109
- pasteBuffer.push(userInput);
110
- return ask();
91
+ console.log("");
92
+ const firstResp = await prompt({
93
+ type: "autocomplete",
94
+ name: "choice",
95
+ message: chalk.bold.hex("#6366f1")("❯") + chalk.dim(" ·"),
96
+ // @ts-ignore enquirer autocomplete supports limit
97
+ limit: 7,
98
+ suggest(input, choices) {
99
+ if (!input)
100
+ return choices;
101
+ return choices.filter((c) => c.value.toLowerCase().includes(input.toLowerCase()));
102
+ },
103
+ choices: MENU_ITEMS.map(item => ({ name: item, value: item })),
104
+ footer: chalk.dim(" ↑↓ to navigate · type to filter · Enter to send"),
105
+ });
106
+ userInput = firstResp.choice?.trim() || "";
107
+ // Strip emoji prefixes so the agent gets clean text
108
+ userInput = userInput.replace(/^[\p{Emoji}\s]+/u, "").trim() || firstResp.choice?.trim() || "";
111
109
  }
112
110
  else {
113
- if (pasteBuffer.length > 0) {
114
- if (userInput)
115
- pasteBuffer.push(userInput);
116
- userInput = pasteBuffer.join("\n");
117
- // Reset buffer
111
+ // ── Normal text input for subsequent turns ──────────────────────
112
+ const promptStartTime = Date.now();
113
+ const response = await prompt({
114
+ type: "input",
115
+ name: "query",
116
+ message: pasteBuffer.length > 0
117
+ ? chalk.dim("... ")
118
+ : chalk.bold.hex("#6366f1")("❯") + chalk.dim(" ·"),
119
+ });
120
+ userInput = response.query;
121
+ const duration = Date.now() - promptStartTime;
122
+ // ── Multi-line paste mode ──────────────────────────────────────
123
+ if (userInput.trim() === "<<<" || userInput.trim().toLowerCase().startsWith("<<<")) {
124
+ const prefix = userInput.trim().slice(3).trim();
125
+ console.log(chalk.dim(" 📋 Multi-line mode: paste your text, then press Enter twice to submit.\n"));
126
+ const lines = prefix ? [prefix] : [];
127
+ let emptyCount = 0;
128
+ while (emptyCount < 1) {
129
+ const lineResp = await prompt({
130
+ type: "input",
131
+ name: "line",
132
+ message: chalk.dim(" │"),
133
+ });
134
+ if (lineResp.line === "") {
135
+ emptyCount++;
136
+ }
137
+ else {
138
+ emptyCount = 0;
139
+ lines.push(lineResp.line);
140
+ }
141
+ }
142
+ userInput = lines.join("\n").trim();
118
143
  pasteBuffer = [];
119
144
  }
120
- }
145
+ else if (duration < 150) {
146
+ pasteBuffer.push(userInput);
147
+ return ask();
148
+ }
149
+ else {
150
+ if (pasteBuffer.length > 0) {
151
+ if (userInput)
152
+ pasteBuffer.push(userInput);
153
+ userInput = pasteBuffer.join("\n");
154
+ pasteBuffer = [];
155
+ }
156
+ }
157
+ } // end else (non-first turn)
121
158
  userInput = userInput.trim();
122
159
  if (!userInput)
123
160
  return ask();
124
161
  // ── Input length guard ──────────────────────────────────────────
125
- // macOS terminal readline has a hard ~4096 char limit per line, meaning
126
- // pasting very long job descriptions gets silently truncated mid-word.
127
- // Detect this early and guide the user to <<< mode instead.
128
- const MAX_INPUT_CHARS = 20_000; // ~3,000 words — safe above typical JD length
162
+ const MAX_INPUT_CHARS = 20_000;
129
163
  if (userInput.length > MAX_INPUT_CHARS) {
130
164
  console.log(chalk.yellow("\n⚠️ Input is too long (" + userInput.length + " chars).") +
131
165
  chalk.dim("\n Use <<< mode for long job descriptions so nothing gets cut off:") +
@@ -133,6 +167,15 @@ export async function askLoop(engine, options, selectedProvider, selectedModel,
133
167
  chalk.dim("\n Then paste the job description, and press Enter twice to submit.\n"));
134
168
  return ask();
135
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
+ }
136
179
  // ── Slash commands ──────────────────────────────────────────────
137
180
  if (userInput.startsWith("/")) {
138
181
  const [cmd, ...rest] = userInput.slice(1).split(" ");
@@ -141,13 +184,45 @@ export async function askLoop(engine, options, selectedProvider, selectedModel,
141
184
  console.log(chalk.cyan("\n Slash commands:"));
142
185
  console.log(chalk.dim(" /model <name> — Switch to a different model mid-session"));
143
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"));
144
189
  console.log(chalk.dim(" /help — Show this help message"));
145
190
  console.log(chalk.dim(" exit — End the session"));
146
- console.log(chalk.cyan("\n Paste long content (job descriptions, cover letters):"));
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):"));
147
194
  console.log(chalk.dim(" <<< — Open multi-line paste mode; press Enter twice when done"));
148
195
  console.log(chalk.dim(" <<<your text — Start with text directly after <<<\n"));
149
196
  return ask();
150
197
  }
198
+ if (cmd === "voice") {
199
+ if (arg === "on") {
200
+ setVoiceEnabled(true);
201
+ console.log(chalk.green("\n 🔊 Voice enabled. Agent responses will be spoken aloud.\n"));
202
+ }
203
+ else if (arg === "off") {
204
+ setVoiceEnabled(false);
205
+ stopPlayback();
206
+ console.log(chalk.yellow("\n 🔇 Voice disabled.\n"));
207
+ }
208
+ else {
209
+ const status = isVoiceEnabled() ? chalk.green("on") : chalk.yellow("off");
210
+ console.log(chalk.dim(`\n Voice is currently ${status}. Usage: /voice on or /voice off\n`));
211
+ }
212
+ return ask();
213
+ }
214
+ if (cmd === "speak") {
215
+ const last = getLastResponse();
216
+ if (!last) {
217
+ console.log(chalk.dim("\n Nothing to speak yet. Ask the agent something first.\n"));
218
+ }
219
+ else {
220
+ const geminiKey = getGeminiKey() || process.env.GEMINI_API_KEY;
221
+ speakText(last, geminiKey ?? undefined).catch(() => { });
222
+ console.log(chalk.dim("\n 🔊 Speaking last response...\n"));
223
+ }
224
+ return ask();
225
+ }
151
226
  if (cmd === "models") {
152
227
  console.log(chalk.cyan("\n Available CareerVivid models:"));
153
228
  for (const m of CV_MODELS) {
@@ -207,7 +282,9 @@ export async function askLoop(engine, options, selectedProvider, selectedModel,
207
282
  }
208
283
  // Reset per-turn mutation counter at the start of each user message
209
284
  turnMutations = 0;
210
- process.stdout.write(chalk.dim("\n⏳ Thinking...\n\n"));
285
+ // ── Clear previous spinner residue then show thinking indicator ──
286
+ process.stdout.write(chalk.dim("\n"));
287
+ const thinkingSpinner = ora({ text: chalk.dim("Vivid is thinking…"), color: "cyan", spinner: "dots" }).start();
211
288
  let firstChunk = true;
212
289
  let currentSpinner = null;
213
290
  let trustAllCommands = false;
@@ -255,6 +332,11 @@ export async function askLoop(engine, options, selectedProvider, selectedModel,
255
332
  get_profile: "👤 Loading profile...",
256
333
  };
257
334
  const handleToolCall = async (name, args) => {
335
+ // Stop the thinking spinner the moment we start a tool — prevents duplication
336
+ if (thinkingSpinner.isSpinning) {
337
+ thinkingSpinner.stop();
338
+ process.stdout.write("\r\x1b[K"); // clear spinner line
339
+ }
258
340
  // #9 Circuit breaker: abort if same tool called 5+ times consecutively with same args
259
341
  const argsHash = JSON.stringify(args).slice(0, 100);
260
342
  if (lastToolCall.name === name && lastToolCall.argsHash === argsHash) {
@@ -283,11 +365,9 @@ export async function askLoop(engine, options, selectedProvider, selectedModel,
283
365
  console.log(chalk.yellow(`\n💡 Heads up: ${SESSION_MAX_MUTATIONS - sessionMutations} writes remaining this session.`));
284
366
  }
285
367
  }
286
- // Show a clean, user-friendly label — never show raw args
368
+ // Print compact tool label — no blank lines, stays tight between steps
287
369
  const label = TOOL_LABELS[name] ?? `⚙️ Working...`;
288
- process.stdout.write(chalk.dim(`
289
- ${label}
290
- `));
370
+ process.stdout.write(chalk.dim(` ${label}\n`));
291
371
  if (name === "run_command") {
292
372
  if (trustAllCommands || isSafeCommand(args.command)) {
293
373
  return true;
@@ -361,7 +441,7 @@ ${label}
361
441
  };
362
442
  const handleToolResult = (name, result) => {
363
443
  if (currentSpinner) {
364
- currentSpinner.succeed(chalk.dim(`Done`));
444
+ currentSpinner.succeed(chalk.dim("Done"));
365
445
  currentSpinner = null;
366
446
  }
367
447
  if (name === "start_interview") {
@@ -381,14 +461,19 @@ ${label}
381
461
  };
382
462
  if (engine) {
383
463
  sessionTurns++;
464
+ let responseAccumulator = "";
384
465
  const sharedOnChunk = (text) => {
385
466
  if (firstChunk) {
386
- process.stdout.write("\r\x1b[K");
467
+ thinkingSpinner.stop();
468
+ // Print a subtle gutter marker so AI response is visually distinct
469
+ process.stdout.write("\n" + chalk.hex("#6366f1")("✦ "));
387
470
  firstChunk = false;
388
471
  }
389
- process.stdout.write(chalk.green(text));
472
+ process.stdout.write(text);
473
+ responseAccumulator += text; // Accumulate for TTS
390
474
  };
391
475
  const sharedOnError = (error) => {
476
+ thinkingSpinner.stop();
392
477
  if (currentSpinner) {
393
478
  currentSpinner.fail("Tool error");
394
479
  currentSpinner = null;
@@ -436,6 +521,16 @@ ${label}
436
521
  onError: sharedOnError,
437
522
  });
438
523
  }
524
+ // ── TTS: store last response + auto-speak if voice enabled ──────
525
+ if (responseAccumulator) {
526
+ setLastResponse(responseAccumulator);
527
+ if (isVoiceEnabled()) {
528
+ const geminiKey = getGeminiKey() || process.env.GEMINI_API_KEY;
529
+ speakText(responseAccumulator, geminiKey ?? undefined).catch(() => { });
530
+ }
531
+ }
532
+ // ── Clean turn separator after every AI reply ─────────────────────────────
533
+ process.stdout.write("\n" + chalk.dim("─".repeat(48)) + "\n");
439
534
  }
440
535
  else {
441
536
  sessionTurns++;
@@ -458,10 +553,11 @@ ${label}
458
553
  while (round < 10) {
459
554
  const result = await withTimeout(provider.generate({ model: currentModel, history: byoHistory, userTurn, tools, systemInstruction }), 45_000, "LLM generate()");
460
555
  if (round === 0) {
461
- process.stdout.write("\r\x1b[K"); // clear initial thinking spinner
556
+ thinkingSpinner.stop();
557
+ process.stdout.write("\n" + chalk.hex("#6366f1")("\u2726 "));
462
558
  }
463
559
  if (result.text) {
464
- console.log(chalk.green(result.text));
560
+ process.stdout.write(result.text);
465
561
  }
466
562
  byoHistory.push(userTurn);
467
563
  byoHistory.push({ role: "model", parts: result.rawParts || [{ text: result.text }] });
@@ -479,11 +575,23 @@ ${label}
479
575
  let out;
480
576
  try {
481
577
  // start_interview is an interactive long-running session — never apply a timeout to it.
482
- out = tool
483
- ? fc.name === "start_interview"
484
- ? await tool.execute(fc.args)
485
- : await withTimeout(tool.execute(fc.args), 45_000, `tool:${fc.name}`)
486
- : { error: "Tool not found" };
578
+ // Also temporarily remove the REPL's SIGINT handler so the interview's own
579
+ // Ctrl+C handler can run cleanly (generate report) without racing against
580
+ // the REPL's "Goodbye! 👋" / process.exit path.
581
+ if (fc.name === "start_interview") {
582
+ process.removeListener("SIGINT", handleSigInt);
583
+ try {
584
+ out = tool ? await tool.execute(fc.args) : { error: "Tool not found" };
585
+ }
586
+ finally {
587
+ process.on("SIGINT", handleSigInt); // always restore, even on throw
588
+ }
589
+ }
590
+ else {
591
+ out = tool
592
+ ? await withTimeout(tool.execute(fc.args), 45_000, `tool:${fc.name}`)
593
+ : { error: "Tool not found" };
594
+ }
487
595
  }
488
596
  catch (e) {
489
597
  if (e.message?.includes("No API key configured")) {
@@ -499,6 +607,8 @@ ${label}
499
607
  userTurn = { role: "user", parts: fnResponses };
500
608
  round++;
501
609
  }
610
+ // ── Clean turn separator after every AI reply ─────────────────────────────
611
+ process.stdout.write("\n" + chalk.dim("─".repeat(48)) + "\n");
502
612
  }
503
613
  return ask();
504
614
  }
@@ -571,5 +681,5 @@ ${label}
571
681
  return ask();
572
682
  }
573
683
  };
574
- return ask();
684
+ return ask(true);
575
685
  }
@@ -1 +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;AAkvBpC,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAmG/D"}
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;AAyzBpC,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAmG/D"}
@@ -133,7 +133,7 @@ function printReport(report) {
133
133
  console.log("\n" + chalk.yellow.bold(" 💡 Areas for Improvement"));
134
134
  wordWrap(report.areasForImprovement, 72).split("\n").forEach(l => console.log(` ${chalk.yellow(l)}`));
135
135
  console.log("\n" + chalk.dim(" ─────────────────────────────────────────────────────────────"));
136
- console.log(chalk.dim(" View full history at: https://careervivid.app/interview"));
136
+ console.log(chalk.dim(" View full history at: https://careervivid.app/interview-studio"));
137
137
  console.log("");
138
138
  }
139
139
  // ─── sox audio check ──────────────────────────────────────────────────────────
@@ -381,12 +381,17 @@ async function runVoiceSession(opts) {
381
381
  const systemInstruction = buildSystemPrompt(role, questions, resumeContext);
382
382
  const transcript = [];
383
383
  let ended = false;
384
- let outputBuf = ""; // accumulates AI transcription
385
- let inputBuf = ""; // accumulates user transcription
386
- // Half-duplex mute: stop sending mic audio while Vivid is speaking
387
- // to prevent the mic picking up speaker output (echo loop).
384
+ let outputBuf = ""; // accumulates AI output transcription for current turn
385
+ let inputBuf = ""; // accumulates user input transcription for current turn
386
+ let streamColPos = 0; // current col position while streaming Vivid's text
388
387
  let vividSpeaking = false;
389
388
  let muteTimer = null;
389
+ // Track whether we're currently showing live user speech in the terminal
390
+ let userSpeechLineActive = false;
391
+ // Detect END_TOKEN in the raw chunk BEFORE it's stripped from the display buffer.
392
+ // (outputBuf never contains END_TOKEN because chunkClean strips it, so the
393
+ // turnComplete check on outputBuf would never fire without this flag.)
394
+ let endTokenSeen = false;
390
395
  // ── Audio processes ──────────────────────────────────────────────────
391
396
  const micProc = startMic(soxPath);
392
397
  const speakerProc = startSpeaker(soxPath);
@@ -399,9 +404,14 @@ async function runVoiceSession(opts) {
399
404
  model: LIVE_MODEL,
400
405
  callbacks: {
401
406
  onopen: () => {
402
- connectSpinner.succeed(chalk.green("✅ Vivid is live — start speaking!"));
403
- process.stdout.write(chalk.green("\n ● Listening...\r"));
404
- // Pipe mic PCM → Gemini, muted while Vivid is speaking
407
+ connectSpinner.succeed(chalk.green("✅ Vivid is ready!"));
408
+ // Show a professional prompt so the user knows to speak first
409
+ console.log("");
410
+ console.log(chalk.bold.white(" 🎙 Greet Vivid to begin your interview."));
411
+ console.log(chalk.dim(' Say \'Hello\' or \'Hi, I\'m ready\' to get started.'));
412
+ console.log("");
413
+ process.stdout.write(chalk.green(" ● Listening...\r"));
414
+ // Pipe mic PCM → Gemini (muted while Vivid is speaking)
405
415
  micProc.stdout.on("data", (chunk) => {
406
416
  if (ended || chunk.length === 0 || vividSpeaking)
407
417
  return;
@@ -420,48 +430,109 @@ async function runVoiceSession(opts) {
420
430
  // ── Audio output (Vivid speaking) → sox speaker ───
421
431
  const audioPart = msg.serverContent?.modelTurn?.parts?.[0]?.inlineData?.data;
422
432
  if (audioPart) {
423
- // Mute mic: cancel any pending unmute and stay muted
424
433
  vividSpeaking = true;
425
434
  if (muteTimer) {
426
435
  clearTimeout(muteTimer);
427
436
  muteTimer = null;
428
437
  }
429
- process.stdout.write(chalk.blue(" ◈ Vivid speaking...\r"));
430
438
  const pcmBuf = Buffer.from(audioPart, "base64");
431
439
  speakerProc.stdin.write(pcmBuf);
432
440
  }
433
- // ── Output transcription (what Vivid said) ────────
441
+ // ── Output transcription (Vivid's words) — stream in real-time ──
434
442
  const outText = msg.serverContent?.outputTranscription?.text;
435
- if (outText)
436
- outputBuf += outText;
437
- // ── Input transcription (what user said) ──────────
443
+ if (outText) {
444
+ // Detect END_TOKEN in raw chunk BEFORE stripping
445
+ if (outText.includes(END_TOKEN))
446
+ endTokenSeen = true;
447
+ // Phrase-based fallback: model sometimes omits the token
448
+ if (outText.toLowerCase().includes("feedback report is being generated"))
449
+ endTokenSeen = true;
450
+ const chunkClean = outText.replace(END_TOKEN, "");
451
+ if (chunkClean) {
452
+ if (!outputBuf) {
453
+ // First chunk: print the speaker header on a fresh line
454
+ process.stdout.write("\n" + chalk.cyan.bold(" Vivid ❯") + "\n");
455
+ streamColPos = 0;
456
+ }
457
+ outputBuf += chunkClean;
458
+ // Stream words inline with soft word-wrap
459
+ for (const word of chunkClean.split(/(\s+)/)) {
460
+ if (!word)
461
+ continue;
462
+ const isWhitespace = /^\s+$/.test(word);
463
+ if (isWhitespace) {
464
+ if (streamColPos > 0) {
465
+ process.stdout.write(chalk.cyan(' '));
466
+ streamColPos += 1;
467
+ }
468
+ }
469
+ else {
470
+ if (streamColPos > 0 && streamColPos + word.length > WRAP_WIDTH) {
471
+ process.stdout.write('\n');
472
+ streamColPos = 0;
473
+ }
474
+ const prefix = streamColPos === 0 ? ' ' : '';
475
+ process.stdout.write(chalk.cyan(prefix + word));
476
+ streamColPos += prefix.length + word.length;
477
+ }
478
+ }
479
+ }
480
+ }
481
+ // ── Input transcription (user's live speech) — stream in real-time ──
438
482
  const inText = msg.serverContent?.inputTranscription?.text;
439
- if (inText)
483
+ if (inText) {
484
+ if (!userSpeechLineActive) {
485
+ // First chunk of user speech: clear the Listening indicator and
486
+ // start a "[You] ❯" prefix on a fresh line
487
+ process.stdout.write("\r\x1B[2K"); // clear current line
488
+ process.stdout.write(chalk.dim(" [You] ❯ ") + chalk.white(""));
489
+ userSpeechLineActive = true;
490
+ }
440
491
  inputBuf += inText;
492
+ // Rewrite the whole user line so far (keeps it clean as chunks arrive)
493
+ process.stdout.write("\r\x1B[2K");
494
+ process.stdout.write(chalk.dim(" [You] ❯ ") + chalk.white(inputBuf.trim()));
495
+ }
441
496
  // ── Turn complete ─────────────────────────────────
442
497
  if (msg.serverContent?.turnComplete) {
443
498
  if (outputBuf.trim()) {
444
499
  const aiText = outputBuf.trim();
445
- printAI(aiText);
446
- transcript.push({ speaker: "ai", text: aiText.replace(END_TOKEN, "").trim() });
447
- if (aiText.includes(END_TOKEN))
500
+ // Close the streamed line cleanly
501
+ if (streamColPos > 0)
502
+ process.stdout.write("\n");
503
+ process.stdout.write("\n"); // blank line after Vivid's turn
504
+ transcript.push({ speaker: "ai", text: aiText });
505
+ // Use the pre-strip flag — NOT aiText.includes(END_TOKEN)
506
+ if (endTokenSeen)
448
507
  ended = true;
449
508
  outputBuf = "";
509
+ streamColPos = 0;
510
+ endTokenSeen = false; // reset for next turn
450
511
  }
451
512
  if (inputBuf.trim()) {
452
- printUser(inputBuf.trim());
513
+ // Finalize the user speech line with a newline
514
+ process.stdout.write("\n");
515
+ userSpeechLineActive = false;
453
516
  transcript.push({ speaker: "user", text: inputBuf.trim() });
454
517
  inputBuf = "";
455
518
  }
456
519
  if (!ended) {
457
- // Unmute mic after a short delay so the speaker
458
- // tail doesn't get captured (echo suppression buffer)
459
520
  muteTimer = setTimeout(() => {
460
521
  vividSpeaking = false;
461
522
  muteTimer = null;
462
- process.stdout.write(chalk.green(" ● Listening...\r"));
523
+ if (!userSpeechLineActive) {
524
+ process.stdout.write(chalk.green("\n ● Listening...\r"));
525
+ }
463
526
  }, 800);
464
527
  }
528
+ else {
529
+ // Interview ended via END_TOKEN — cancel pending mute timer
530
+ // so '● Listening...' never appears after the interview concludes
531
+ if (muteTimer) {
532
+ clearTimeout(muteTimer);
533
+ muteTimer = null;
534
+ }
535
+ }
465
536
  }
466
537
  },
467
538
  onerror: (e) => {
@@ -582,7 +653,10 @@ async function runVoiceSession(opts) {
582
653
  log.warn("billing_timeout", { sessionId, sessionDurationMs });
583
654
  }
584
655
  if (report) {
585
- console.log(chalk.dim("\n 💡 Interview context saved. Ask \`cv agent\` to coach you on your answers."));
656
+ console.log(chalk.dim("\n 💡 Report saved! Continue your prep with:"));
657
+ console.log(chalk.dim(" • ") + chalk.white("`cv agent`") + chalk.dim(" or ") + chalk.white("`cv agent --jobs`") + chalk.dim(" → coach me on my answers"));
658
+ console.log(chalk.dim(" • Rewrite resume, draft cover letter, or start another round"));
659
+ console.log(chalk.dim(" • View report: ") + chalk.cyan("https://careervivid.app/interview-studio"));
586
660
  }
587
661
  await log.dispose();
588
662
  }
@@ -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,26 @@
1
+ /**
2
+ * tts.ts — Text-to-Speech engine for the CareerVivid REPL
3
+ *
4
+ * Uses the Gemini API (TTS model) to synthesize speech from text,
5
+ * writes the result to a temp WAV file, and plays it via the OS
6
+ * audio player (afplay on macOS, aplay on Linux) without blocking
7
+ * the main thread.
8
+ *
9
+ * Toggle: /voice on | /voice off
10
+ * Speak: /speak (reads the last agent response)
11
+ */
12
+ export declare function isVoiceEnabled(): boolean;
13
+ export declare function setVoiceEnabled(on: boolean): void;
14
+ export declare function setLastResponse(text: string): void;
15
+ export declare function getLastResponse(): string;
16
+ /**
17
+ * Stops any currently playing audio immediately.
18
+ */
19
+ export declare function stopPlayback(): void;
20
+ /**
21
+ * Synthesizes `text` via Gemini TTS and plays it asynchronously.
22
+ * Does nothing if voice is disabled. Silently ignores errors so the
23
+ * REPL is never disrupted by TTS failures.
24
+ */
25
+ export declare function speakText(text: string, apiKey?: string): Promise<void>;
26
+ //# sourceMappingURL=tts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tts.d.ts","sourceRoot":"","sources":["../../src/lib/tts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAaH,wBAAgB,cAAc,YAA2B;AACzD,wBAAgB,eAAe,CAAC,EAAE,EAAE,OAAO,QAAwB;AACnE,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,QAA0B;AACtE,wBAAgB,eAAe,WAA2B;AAI1D;;GAEG;AACH,wBAAgB,YAAY,SAK3B;AAoED;;;;GAIG;AACH,wBAAsB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAuD5E"}
@@ -0,0 +1,153 @@
1
+ /**
2
+ * tts.ts — Text-to-Speech engine for the CareerVivid REPL
3
+ *
4
+ * Uses the Gemini API (TTS model) to synthesize speech from text,
5
+ * writes the result to a temp WAV file, and plays it via the OS
6
+ * audio player (afplay on macOS, aplay on Linux) without blocking
7
+ * the main thread.
8
+ *
9
+ * Toggle: /voice on | /voice off
10
+ * Speak: /speak (reads the last agent response)
11
+ */
12
+ import { writeFileSync, unlinkSync } from "fs";
13
+ import { spawn } from "child_process";
14
+ import { tmpdir } from "os";
15
+ import { join } from "path";
16
+ import { GoogleGenAI, Modality } from "@google/genai";
17
+ // ── State ────────────────────────────────────────────────────────────────────
18
+ let voiceEnabled = false;
19
+ let lastResponse = "";
20
+ let playbackProcess = null;
21
+ export function isVoiceEnabled() { return voiceEnabled; }
22
+ export function setVoiceEnabled(on) { voiceEnabled = on; }
23
+ export function setLastResponse(text) { lastResponse = text; }
24
+ export function getLastResponse() { return lastResponse; }
25
+ // ── Audio Playback ────────────────────────────────────────────────────────────
26
+ /**
27
+ * Stops any currently playing audio immediately.
28
+ */
29
+ export function stopPlayback() {
30
+ if (playbackProcess && !playbackProcess.killed) {
31
+ playbackProcess.kill("SIGKILL");
32
+ playbackProcess = null;
33
+ }
34
+ }
35
+ /**
36
+ * Plays a WAV buffer using the OS audio player (non-blocking).
37
+ * afplay on macOS, aplay on Linux, powershell on Windows.
38
+ */
39
+ function playWav(wavBuffer) {
40
+ const tmpFile = join(tmpdir(), `cv-tts-${Date.now()}.wav`);
41
+ writeFileSync(tmpFile, wavBuffer);
42
+ const platform = process.platform;
43
+ let playerCmd;
44
+ let playerArgs;
45
+ if (platform === "darwin") {
46
+ playerCmd = "afplay";
47
+ playerArgs = [tmpFile];
48
+ }
49
+ else if (platform === "linux") {
50
+ playerCmd = "aplay";
51
+ playerArgs = ["-q", tmpFile];
52
+ }
53
+ else {
54
+ // Windows fallback via PowerShell
55
+ playerCmd = "powershell";
56
+ playerArgs = ["-c", `(New-Object System.Media.SoundPlayer '${tmpFile}').PlaySync()`];
57
+ }
58
+ stopPlayback(); // Stop any previous playback
59
+ const child = spawn(playerCmd, playerArgs, { stdio: "ignore", detached: false });
60
+ playbackProcess = child;
61
+ child.on("close", () => {
62
+ try {
63
+ unlinkSync(tmpFile);
64
+ }
65
+ catch { /* ignore cleanup errors */ }
66
+ if (playbackProcess === child)
67
+ playbackProcess = null;
68
+ });
69
+ child.on("error", () => {
70
+ // Silently ignore — player may not be installed
71
+ try {
72
+ unlinkSync(tmpFile);
73
+ }
74
+ catch { /* ignore */ }
75
+ });
76
+ }
77
+ // ── WAV Header Builder ────────────────────────────────────────────────────────
78
+ function buildWavHeader(dataLength, sampleRate = 24000, channels = 1, bitsPerSample = 16) {
79
+ const byteRate = sampleRate * channels * bitsPerSample / 8;
80
+ const blockAlign = channels * bitsPerSample / 8;
81
+ const header = Buffer.alloc(44);
82
+ header.write("RIFF", 0);
83
+ header.writeUInt32LE(36 + dataLength, 4);
84
+ header.write("WAVE", 8);
85
+ header.write("fmt ", 12);
86
+ header.writeUInt32LE(16, 16);
87
+ header.writeUInt16LE(1, 20); // PCM
88
+ header.writeUInt16LE(channels, 22);
89
+ header.writeUInt32LE(sampleRate, 24);
90
+ header.writeUInt32LE(byteRate, 28);
91
+ header.writeUInt16LE(blockAlign, 32);
92
+ header.writeUInt16LE(bitsPerSample, 34);
93
+ header.write("data", 36);
94
+ header.writeUInt32LE(dataLength, 40);
95
+ return header;
96
+ }
97
+ // ── TTS Synthesis ────────────────────────────────────────────────────────────
98
+ /**
99
+ * Synthesizes `text` via Gemini TTS and plays it asynchronously.
100
+ * Does nothing if voice is disabled. Silently ignores errors so the
101
+ * REPL is never disrupted by TTS failures.
102
+ */
103
+ export async function speakText(text, apiKey) {
104
+ if (!text.trim())
105
+ return;
106
+ const key = apiKey || process.env.GEMINI_API_KEY;
107
+ if (!key)
108
+ return; // No key — silently skip
109
+ // Strip markdown so the audio sounds natural
110
+ const cleaned = text
111
+ .replace(/```[\s\S]*?```/g, "") // code blocks
112
+ .replace(/`[^`]+`/g, "") // inline code
113
+ .replace(/\*\*(.*?)\*\*/g, "$1") // bold
114
+ .replace(/\*(.*?)\*/g, "$1") // italic
115
+ .replace(/^[#>•\-*]\s*/gm, "") // headings, quotes, bullets
116
+ .replace(/\s+/g, " ")
117
+ .trim()
118
+ .slice(0, 1000); // Cap to ~1000 chars to keep TTS fast
119
+ if (!cleaned)
120
+ return;
121
+ try {
122
+ const ai = new GoogleGenAI({ apiKey: key });
123
+ const response = await ai.models.generateContent({
124
+ model: "gemini-2.5-flash-preview-tts",
125
+ contents: [{ parts: [{ text: cleaned }] }],
126
+ config: {
127
+ responseModalities: [Modality.AUDIO],
128
+ speechConfig: {
129
+ voiceConfig: {
130
+ prebuiltVoiceConfig: { voiceName: "Zephyr" },
131
+ },
132
+ },
133
+ },
134
+ });
135
+ const parts = response?.candidates?.[0]?.content?.parts ?? [];
136
+ const audioParts = [];
137
+ for (const part of parts) {
138
+ if (part.inlineData?.data) {
139
+ audioParts.push(Buffer.from(part.inlineData.data, "base64"));
140
+ }
141
+ }
142
+ if (audioParts.length === 0)
143
+ return;
144
+ const pcmData = Buffer.concat(audioParts);
145
+ const wavHeader = buildWavHeader(pcmData.length);
146
+ const wavBuffer = Buffer.concat([wavHeader, pcmData]);
147
+ // Fire and forget — does not block the REPL
148
+ playWav(wavBuffer);
149
+ }
150
+ catch {
151
+ // Silently swallow TTS errors — never disrupt the agent session
152
+ }
153
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "careervivid",
3
- "version": "2.1.1",
3
+ "version": "2.1.13",
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": "^1.51.0",
34
+ "playwright-core": "1.59.1",
35
35
  "semver": "7.7.4",
36
36
  "update-notifier": "^7.3.1"
37
37
  },