careervivid 2.1.1 → 2.1.12

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;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,CA+oBf"}
@@ -67,65 +67,97 @@ export async function askLoop(engine, options, selectedProvider, selectedModel,
67
67
  .catch(e => { clearTimeout(timer); reject(e); });
68
68
  });
69
69
  }
70
- const ask = async () => {
70
+ // ── First-turn menu items ────────────────────────────────────────────────
71
+ const MENU_ITEMS = [
72
+ "📄 View or update my resume",
73
+ "🔍 Search for job opportunities",
74
+ "📊 Check my job pipeline / tracker",
75
+ "✉️ Draft a cover letter or tailor my resume",
76
+ "🎙 Start an AI mock interview (voice or text)",
77
+ "📈 Get an overview of my job search progress",
78
+ "🗓️ Pick up where we left off",
79
+ ];
80
+ const ask = async (isFirstTurn = false) => {
71
81
  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
- }
82
+ let userInput;
83
+ if (isFirstTurn) {
84
+ // ── Hybrid menu: arrow-key select OR free type ─────────────────
85
+ console.log(chalk.dim(" What would you like to do today?\n"));
86
+ for (const item of MENU_ITEMS) {
87
+ console.log(chalk.dim(` ${item}`));
102
88
  }
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();
89
+ console.log("");
90
+ const firstResp = await prompt({
91
+ type: "autocomplete",
92
+ name: "choice",
93
+ message: chalk.bold.hex("#6366f1")("❯") + chalk.dim(" ·"),
94
+ // @ts-ignore enquirer autocomplete supports limit
95
+ limit: 7,
96
+ suggest(input, choices) {
97
+ if (!input)
98
+ return choices;
99
+ return choices.filter((c) => c.value.toLowerCase().includes(input.toLowerCase()));
100
+ },
101
+ choices: MENU_ITEMS.map(item => ({ name: item, value: item })),
102
+ footer: chalk.dim(" ↑↓ to navigate · type to filter · Enter to send"),
103
+ });
104
+ userInput = firstResp.choice?.trim() || "";
105
+ // Strip emoji prefixes so the agent gets clean text
106
+ userInput = userInput.replace(/^[\p{Emoji}\s]+/u, "").trim() || firstResp.choice?.trim() || "";
111
107
  }
112
108
  else {
113
- if (pasteBuffer.length > 0) {
114
- if (userInput)
115
- pasteBuffer.push(userInput);
116
- userInput = pasteBuffer.join("\n");
117
- // Reset buffer
109
+ // ── Normal text input for subsequent turns ──────────────────────
110
+ const promptStartTime = Date.now();
111
+ const response = await prompt({
112
+ type: "input",
113
+ name: "query",
114
+ message: pasteBuffer.length > 0
115
+ ? chalk.dim("... ")
116
+ : chalk.bold.hex("#6366f1")("❯") + chalk.dim(" ·"),
117
+ });
118
+ userInput = response.query;
119
+ const duration = Date.now() - promptStartTime;
120
+ // ── Multi-line paste mode ──────────────────────────────────────
121
+ if (userInput.trim() === "<<<" || userInput.trim().toLowerCase().startsWith("<<<")) {
122
+ const prefix = userInput.trim().slice(3).trim();
123
+ console.log(chalk.dim(" 📋 Multi-line mode: paste your text, then press Enter twice to submit.\n"));
124
+ const lines = prefix ? [prefix] : [];
125
+ let emptyCount = 0;
126
+ while (emptyCount < 1) {
127
+ const lineResp = await prompt({
128
+ type: "input",
129
+ name: "line",
130
+ message: chalk.dim(" │"),
131
+ });
132
+ if (lineResp.line === "") {
133
+ emptyCount++;
134
+ }
135
+ else {
136
+ emptyCount = 0;
137
+ lines.push(lineResp.line);
138
+ }
139
+ }
140
+ userInput = lines.join("\n").trim();
118
141
  pasteBuffer = [];
119
142
  }
120
- }
143
+ else if (duration < 150) {
144
+ pasteBuffer.push(userInput);
145
+ return ask();
146
+ }
147
+ else {
148
+ if (pasteBuffer.length > 0) {
149
+ if (userInput)
150
+ pasteBuffer.push(userInput);
151
+ userInput = pasteBuffer.join("\n");
152
+ pasteBuffer = [];
153
+ }
154
+ }
155
+ } // end else (non-first turn)
121
156
  userInput = userInput.trim();
122
157
  if (!userInput)
123
158
  return ask();
124
159
  // ── 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
160
+ const MAX_INPUT_CHARS = 20_000;
129
161
  if (userInput.length > MAX_INPUT_CHARS) {
130
162
  console.log(chalk.yellow("\n⚠️ Input is too long (" + userInput.length + " chars).") +
131
163
  chalk.dim("\n Use <<< mode for long job descriptions so nothing gets cut off:") +
@@ -207,7 +239,9 @@ export async function askLoop(engine, options, selectedProvider, selectedModel,
207
239
  }
208
240
  // Reset per-turn mutation counter at the start of each user message
209
241
  turnMutations = 0;
210
- process.stdout.write(chalk.dim("\n⏳ Thinking...\n\n"));
242
+ // ── Clear previous spinner residue then show thinking indicator ──
243
+ process.stdout.write(chalk.dim("\n"));
244
+ const thinkingSpinner = ora({ text: chalk.dim("Vivid is thinking…"), color: "cyan", spinner: "dots" }).start();
211
245
  let firstChunk = true;
212
246
  let currentSpinner = null;
213
247
  let trustAllCommands = false;
@@ -255,6 +289,11 @@ export async function askLoop(engine, options, selectedProvider, selectedModel,
255
289
  get_profile: "👤 Loading profile...",
256
290
  };
257
291
  const handleToolCall = async (name, args) => {
292
+ // Stop the thinking spinner the moment we start a tool — prevents duplication
293
+ if (thinkingSpinner.isSpinning) {
294
+ thinkingSpinner.stop();
295
+ process.stdout.write("\r\x1b[K"); // clear spinner line
296
+ }
258
297
  // #9 Circuit breaker: abort if same tool called 5+ times consecutively with same args
259
298
  const argsHash = JSON.stringify(args).slice(0, 100);
260
299
  if (lastToolCall.name === name && lastToolCall.argsHash === argsHash) {
@@ -283,11 +322,9 @@ export async function askLoop(engine, options, selectedProvider, selectedModel,
283
322
  console.log(chalk.yellow(`\n💡 Heads up: ${SESSION_MAX_MUTATIONS - sessionMutations} writes remaining this session.`));
284
323
  }
285
324
  }
286
- // Show a clean, user-friendly label — never show raw args
325
+ // Print compact tool label — no blank lines, stays tight between steps
287
326
  const label = TOOL_LABELS[name] ?? `⚙️ Working...`;
288
- process.stdout.write(chalk.dim(`
289
- ${label}
290
- `));
327
+ process.stdout.write(chalk.dim(` ${label}\n`));
291
328
  if (name === "run_command") {
292
329
  if (trustAllCommands || isSafeCommand(args.command)) {
293
330
  return true;
@@ -361,7 +398,7 @@ ${label}
361
398
  };
362
399
  const handleToolResult = (name, result) => {
363
400
  if (currentSpinner) {
364
- currentSpinner.succeed(chalk.dim(`Done`));
401
+ currentSpinner.succeed(chalk.dim("Done"));
365
402
  currentSpinner = null;
366
403
  }
367
404
  if (name === "start_interview") {
@@ -383,12 +420,15 @@ ${label}
383
420
  sessionTurns++;
384
421
  const sharedOnChunk = (text) => {
385
422
  if (firstChunk) {
386
- process.stdout.write("\r\x1b[K");
423
+ thinkingSpinner.stop();
424
+ // Print a subtle gutter marker so AI response is visually distinct
425
+ process.stdout.write("\n" + chalk.hex("#6366f1")("✦ "));
387
426
  firstChunk = false;
388
427
  }
389
- process.stdout.write(chalk.green(text));
428
+ process.stdout.write(text);
390
429
  };
391
430
  const sharedOnError = (error) => {
431
+ thinkingSpinner.stop();
392
432
  if (currentSpinner) {
393
433
  currentSpinner.fail("Tool error");
394
434
  currentSpinner = null;
@@ -436,6 +476,8 @@ ${label}
436
476
  onError: sharedOnError,
437
477
  });
438
478
  }
479
+ // ── Clean turn separator after every AI reply ─────────────────────────────
480
+ process.stdout.write("\n" + chalk.dim("─".repeat(48)) + "\n");
439
481
  }
440
482
  else {
441
483
  sessionTurns++;
@@ -458,10 +500,11 @@ ${label}
458
500
  while (round < 10) {
459
501
  const result = await withTimeout(provider.generate({ model: currentModel, history: byoHistory, userTurn, tools, systemInstruction }), 45_000, "LLM generate()");
460
502
  if (round === 0) {
461
- process.stdout.write("\r\x1b[K"); // clear initial thinking spinner
503
+ thinkingSpinner.stop();
504
+ process.stdout.write("\n" + chalk.hex("#6366f1")("\u2726 "));
462
505
  }
463
506
  if (result.text) {
464
- console.log(chalk.green(result.text));
507
+ process.stdout.write(result.text);
465
508
  }
466
509
  byoHistory.push(userTurn);
467
510
  byoHistory.push({ role: "model", parts: result.rawParts || [{ text: result.text }] });
@@ -479,11 +522,23 @@ ${label}
479
522
  let out;
480
523
  try {
481
524
  // 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" };
525
+ // Also temporarily remove the REPL's SIGINT handler so the interview's own
526
+ // Ctrl+C handler can run cleanly (generate report) without racing against
527
+ // the REPL's "Goodbye! 👋" / process.exit path.
528
+ if (fc.name === "start_interview") {
529
+ process.removeListener("SIGINT", handleSigInt);
530
+ try {
531
+ out = tool ? await tool.execute(fc.args) : { error: "Tool not found" };
532
+ }
533
+ finally {
534
+ process.on("SIGINT", handleSigInt); // always restore, even on throw
535
+ }
536
+ }
537
+ else {
538
+ out = tool
539
+ ? await withTimeout(tool.execute(fc.args), 45_000, `tool:${fc.name}`)
540
+ : { error: "Tool not found" };
541
+ }
487
542
  }
488
543
  catch (e) {
489
544
  if (e.message?.includes("No API key configured")) {
@@ -499,6 +554,8 @@ ${label}
499
554
  userTurn = { role: "user", parts: fnResponses };
500
555
  round++;
501
556
  }
557
+ // ── Clean turn separator after every AI reply ─────────────────────────────
558
+ process.stdout.write("\n" + chalk.dim("─".repeat(48)) + "\n");
502
559
  }
503
560
  return ask();
504
561
  }
@@ -571,5 +628,5 @@ ${label}
571
628
  return ask();
572
629
  }
573
630
  };
574
- return ask();
631
+ return ask(true);
575
632
  }
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "careervivid",
3
- "version": "2.1.1",
3
+ "version": "2.1.12",
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": {