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 +1 -1
- package/dist/agent/instructions.d.ts +2 -1
- package/dist/agent/instructions.d.ts.map +1 -1
- package/dist/agent/instructions.js +31 -5
- package/dist/commands/agent/engineResolver.d.ts +1 -1
- package/dist/commands/agent/engineResolver.d.ts.map +1 -1
- package/dist/commands/agent/engineResolver.js +6 -20
- package/dist/commands/agent/repl.d.ts.map +1 -1
- package/dist/commands/agent/repl.js +177 -67
- package/dist/commands/interview.d.ts.map +1 -1
- package/dist/commands/interview.js +97 -23
- package/dist/lib/shell.d.ts +13 -0
- package/dist/lib/shell.d.ts.map +1 -0
- package/dist/lib/shell.js +30 -0
- package/dist/lib/tts.d.ts +26 -0
- package/dist/lib/tts.d.ts.map +1 -0
- package/dist/lib/tts.js +153 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/careervivid)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](https://nodejs.org)
|
|
8
|
-
[](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
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
44
|
-
console.log(chalk.bold.
|
|
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.
|
|
48
|
+
chalk.dim(` [${cost} credit${cost !== 1 ? "s" : ""}/turn via CareerVivid Cloud]`));
|
|
49
49
|
}
|
|
50
50
|
else {
|
|
51
|
-
console.log(chalk.
|
|
51
|
+
console.log(chalk.dim(` Provider: ${selectedProvider} · Model: ${selectedModel} [0 credits]`));
|
|
52
52
|
}
|
|
53
|
-
|
|
54
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
556
|
+
thinkingSpinner.stop();
|
|
557
|
+
process.stdout.write("\n" + chalk.hex("#6366f1")("\u2726 "));
|
|
462
558
|
}
|
|
463
559
|
if (result.text) {
|
|
464
|
-
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
|
403
|
-
|
|
404
|
-
|
|
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 (
|
|
441
|
+
// ── Output transcription (Vivid's words) — stream in real-time ──
|
|
434
442
|
const outText = msg.serverContent?.outputTranscription?.text;
|
|
435
|
-
if (outText)
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 💡
|
|
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"}
|
package/dist/lib/tts.js
ADDED
|
@@ -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.
|
|
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": "
|
|
34
|
+
"playwright-core": "1.59.1",
|
|
35
35
|
"semver": "7.7.4",
|
|
36
36
|
"update-notifier": "^7.3.1"
|
|
37
37
|
},
|