careervivid 2.1.36 → 2.1.39
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/dist/agent/Tool.d.ts +1 -1
- package/dist/agent/Tool.d.ts.map +1 -1
- package/dist/agent/tools/interview.d.ts.map +1 -1
- package/dist/agent/tools/interview.js +5 -2
- package/dist/agent/tools/urlVerifier.d.ts +1 -0
- package/dist/agent/tools/urlVerifier.d.ts.map +1 -1
- package/dist/agent/tools/urlVerifier.js +294 -86
- package/dist/api.d.ts +7 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +4 -0
- package/dist/apply/browser.d.ts +5 -0
- package/dist/apply/browser.d.ts.map +1 -1
- package/dist/apply/browser.js +1 -1
- package/dist/commands/agent/configurator.d.ts.map +1 -1
- package/dist/commands/agent/configurator.js +14 -0
- package/dist/commands/agent/index.d.ts.map +1 -1
- package/dist/commands/agent/index.js +3 -0
- package/dist/commands/agent/repl/engineLoop.d.ts.map +1 -1
- package/dist/commands/agent/repl/engineLoop.js +121 -116
- package/dist/commands/agent/repl/input.d.ts +2 -7
- package/dist/commands/agent/repl/input.d.ts.map +1 -1
- package/dist/commands/agent/repl/input.js +226 -17
- package/dist/commands/agent/repl.d.ts +3 -0
- package/dist/commands/agent/repl.d.ts.map +1 -1
- package/dist/commands/agent/repl.js +28 -1
- package/dist/commands/apply.d.ts.map +1 -1
- package/dist/commands/apply.js +24 -5
- package/dist/commands/auth.d.ts.map +1 -1
- package/dist/commands/auth.js +5 -4
- package/dist/commands/interview.d.ts.map +1 -1
- package/dist/commands/interview.js +101 -31
- package/dist/commands/jobs.d.ts.map +1 -1
- package/dist/commands/jobs.js +147 -4
- package/dist/commands/resumes.js +6 -6
- package/dist/index.js +4 -2
- package/package.json +1 -1
package/dist/commands/apply.js
CHANGED
|
@@ -28,6 +28,7 @@ import { detectPlatform, getAdapter } from "../apply/index.js";
|
|
|
28
28
|
import { loadProfile, saveProfile } from "../apply/gemini-agent.js";
|
|
29
29
|
import { resolveResumePdf } from "../apply/resume-pdf.js";
|
|
30
30
|
import { getOrGenerateAdapter, loadGeneratedAdapter } from "../apply/adapter-generator.js";
|
|
31
|
+
import { verifyUrl } from "../agent/tools/urlVerifier.js";
|
|
31
32
|
const { prompt } = pkg;
|
|
32
33
|
// ── Config helpers ────────────────────────────────────────────────────────────
|
|
33
34
|
function resolveModel(modelFlag) {
|
|
@@ -266,9 +267,27 @@ export function registerApplyCommand(program) {
|
|
|
266
267
|
console.log(chalk.dim(" Example: cv jobs apply https://boards.greenhouse.io/stripe/jobs/7788088"));
|
|
267
268
|
process.exit(1);
|
|
268
269
|
}
|
|
269
|
-
// ── 3.
|
|
270
|
+
// ── 3. Verify direct apply URL before any browser automation ──────────
|
|
271
|
+
console.log(chalk.dim("\n Verifying apply link..."));
|
|
272
|
+
const verification = await verifyUrl(resolvedUrl);
|
|
273
|
+
if (!verification.ok) {
|
|
274
|
+
console.error(chalk.red(`\n❌ Apply link rejected: ${verification.reason}`));
|
|
275
|
+
if (verification.finalUrl && verification.finalUrl !== resolvedUrl) {
|
|
276
|
+
console.error(chalk.dim(` Final URL: ${verification.finalUrl}`));
|
|
277
|
+
}
|
|
278
|
+
console.error(chalk.dim(" Search for a current direct apply page before running the harness.\n"));
|
|
279
|
+
process.exit(1);
|
|
280
|
+
}
|
|
281
|
+
if (verification.warning) {
|
|
282
|
+
console.log(chalk.yellow(` ⚠️ ${verification.warning}`));
|
|
283
|
+
}
|
|
284
|
+
if (verification.finalUrl && verification.finalUrl !== resolvedUrl) {
|
|
285
|
+
console.log(chalk.dim(` Redirect resolved: ${verification.finalUrl}`));
|
|
286
|
+
resolvedUrl = verification.finalUrl;
|
|
287
|
+
}
|
|
288
|
+
// ── 4. Resolve resume PDF ────────────────────────────────────────────
|
|
270
289
|
const resumePdf = await resolveResumePdf();
|
|
271
|
-
// ──
|
|
290
|
+
// ── 5. Detect platform ───────────────────────────────────────────────
|
|
272
291
|
const platform = detectPlatform(resolvedUrl);
|
|
273
292
|
const platformLabel = {
|
|
274
293
|
greenhouse: "Greenhouse 🌱", lever: "Lever", ashby: "Ashby",
|
|
@@ -279,7 +298,7 @@ export function registerApplyCommand(program) {
|
|
|
279
298
|
if (platform === "linkedin") {
|
|
280
299
|
console.log(chalk.yellow("\n⚠️ LinkedIn requires manual apply — opening browser-use agent.\n"));
|
|
281
300
|
}
|
|
282
|
-
// ──
|
|
301
|
+
// ── 6. Dry run ───────────────────────────────────────────────────────
|
|
283
302
|
if (options.dryRun) {
|
|
284
303
|
console.log(chalk.yellow("\n🔍 DRY RUN — nothing will open or be filled.\n"));
|
|
285
304
|
const profileKeys = Object.keys(profile).filter((k) => profile[k]);
|
|
@@ -290,13 +309,13 @@ export function registerApplyCommand(program) {
|
|
|
290
309
|
console.log(chalk.dim(" Re-run without --dry-run to execute.\n"));
|
|
291
310
|
process.exit(0);
|
|
292
311
|
}
|
|
293
|
-
// ──
|
|
312
|
+
// ── 7. Verification ──────────────────────────────────────────────────
|
|
294
313
|
if (!llmConfig.apiKey && llmConfig.provider !== "careervivid") {
|
|
295
314
|
console.log(chalk.red(`\n❌ No API key found for provider: ${llmConfig.provider}`));
|
|
296
315
|
console.log(chalk.dim(" Set it via: cv agent config\n"));
|
|
297
316
|
process.exit(1);
|
|
298
317
|
}
|
|
299
|
-
// ──
|
|
318
|
+
// ── 8. HARNESS ROUTING ───────────────────────────────────────────────
|
|
300
319
|
//
|
|
301
320
|
// Route A: Known platform → TypeScript adapter (fast, no LLM)
|
|
302
321
|
// Route B: Unknown platform → AI-generate new adapter
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/commands/auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAcpC,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/commands/auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAcpC,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAsM1D"}
|
package/dist/commands/auth.js
CHANGED
|
@@ -57,7 +57,7 @@ export function registerAuthCommand(program) {
|
|
|
57
57
|
spinner?.stop();
|
|
58
58
|
if (isApiError(result)) {
|
|
59
59
|
if (jsonMode) {
|
|
60
|
-
console.log(JSON.stringify({ success: false, error: result.message }));
|
|
60
|
+
console.log(JSON.stringify({ success: false, ok: false, error: result.message }));
|
|
61
61
|
}
|
|
62
62
|
else {
|
|
63
63
|
console.error(`\n ${chalk.red("✖")} ${chalk.bold.red("Invalid key:")} ${result.message}`);
|
|
@@ -81,6 +81,7 @@ export function registerAuthCommand(program) {
|
|
|
81
81
|
else {
|
|
82
82
|
console.log(JSON.stringify({
|
|
83
83
|
success: true,
|
|
84
|
+
ok: true,
|
|
84
85
|
name: result.name,
|
|
85
86
|
email: result.email,
|
|
86
87
|
role: result.role,
|
|
@@ -110,7 +111,7 @@ export function registerAuthCommand(program) {
|
|
|
110
111
|
`\n ${chalk.dim("Your key may have been revoked. Run")} ${chalk.cyan("cv login")} ${chalk.dim("to re-authenticate.\n")}`);
|
|
111
112
|
}
|
|
112
113
|
else {
|
|
113
|
-
console.log(JSON.stringify({ success: false, error: result.message }));
|
|
114
|
+
console.log(JSON.stringify({ success: false, ok: false, error: result.message }));
|
|
114
115
|
}
|
|
115
116
|
process.exit(1);
|
|
116
117
|
}
|
|
@@ -127,7 +128,7 @@ export function registerAuthCommand(program) {
|
|
|
127
128
|
}));
|
|
128
129
|
}
|
|
129
130
|
else {
|
|
130
|
-
console.log(JSON.stringify({ success: true, ...result }));
|
|
131
|
+
console.log(JSON.stringify({ success: true, ok: true, ...result }));
|
|
131
132
|
}
|
|
132
133
|
});
|
|
133
134
|
// ── whoami ─────────────────────────────────────────────────────────────────
|
|
@@ -179,7 +180,7 @@ export function registerAuthCommand(program) {
|
|
|
179
180
|
const { saveConfig } = await import("../config.js");
|
|
180
181
|
saveConfig(config);
|
|
181
182
|
if (jsonMode) {
|
|
182
|
-
console.log(JSON.stringify({ success: true }));
|
|
183
|
+
console.log(JSON.stringify({ success: true, ok: true }));
|
|
183
184
|
}
|
|
184
185
|
else {
|
|
185
186
|
console.log();
|
|
@@ -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;AAg6BpC,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAoG/D"}
|
|
@@ -51,7 +51,7 @@ const CLI_BILL_URL = process.env.CV_FUNCTIONS_URL
|
|
|
51
51
|
const CLI_CONTEXT_URL = process.env.CV_FUNCTIONS_URL
|
|
52
52
|
? `${process.env.CV_FUNCTIONS_URL}/cliGetInterviewContext`
|
|
53
53
|
: "https://us-west1-jastalk-firebase.cloudfunctions.net/cliGetInterviewContext";
|
|
54
|
-
const LIVE_MODEL = "gemini-
|
|
54
|
+
const LIVE_MODEL = "gemini-live-2.5-flash-native-audio";
|
|
55
55
|
const FEEDBACK_MODEL = "gemini-2.5-flash";
|
|
56
56
|
const END_TOKEN = "<END_INTERVIEW>";
|
|
57
57
|
const WRAP_WIDTH = 80;
|
|
@@ -252,9 +252,14 @@ async function callAgentProxy(opts) {
|
|
|
252
252
|
const apiKey = getApiKey();
|
|
253
253
|
if (!apiKey)
|
|
254
254
|
throw new Error("No API key. Run: cv login");
|
|
255
|
+
// agentProxy backend only supports Gemini models (e.g., gemini-2.5-flash, gemini-3.5-flash)
|
|
256
|
+
// If a BYO model is passed that is not Gemini (e.g., openai/gpt-oss-120b:free), we fall back to FEEDBACK_MODEL.
|
|
257
|
+
const resolvedModel = opts.model && opts.model.toLowerCase().includes("gemini")
|
|
258
|
+
? opts.model
|
|
259
|
+
: FEEDBACK_MODEL;
|
|
255
260
|
const body = {
|
|
256
261
|
apiKey,
|
|
257
|
-
model:
|
|
262
|
+
model: resolvedModel,
|
|
258
263
|
contents: opts.contents,
|
|
259
264
|
};
|
|
260
265
|
if (opts.systemInstruction)
|
|
@@ -306,7 +311,7 @@ ${questions.map((q, i) => `${i + 1}. ${q}`).join("\n")}
|
|
|
306
311
|
return prompt;
|
|
307
312
|
}
|
|
308
313
|
// ─── Generate Questions (via agentProxy) ─────────────────────────────────────
|
|
309
|
-
async function generateQuestions(role, numQuestions) {
|
|
314
|
+
async function generateQuestions(role, numQuestions, model) {
|
|
310
315
|
const spinner = ora(chalk.dim("Generating interview questions...")).start();
|
|
311
316
|
try {
|
|
312
317
|
const prompt = `Based on the following role, generate ${numQuestions} insightful interview questions covering technical skills, behavioral competencies, and role-specific scenarios. Return ONLY a valid JSON array of strings.\n\nRole: "${role}"`;
|
|
@@ -314,6 +319,7 @@ async function generateQuestions(role, numQuestions) {
|
|
|
314
319
|
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
|
315
320
|
responseSchema: { type: "ARRAY", items: { type: "STRING" } },
|
|
316
321
|
responseMimeType: "application/json",
|
|
322
|
+
model,
|
|
317
323
|
});
|
|
318
324
|
let clean = text.trim().replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "");
|
|
319
325
|
const questions = JSON.parse(clean);
|
|
@@ -326,7 +332,7 @@ async function generateQuestions(role, numQuestions) {
|
|
|
326
332
|
}
|
|
327
333
|
}
|
|
328
334
|
// ─── Analyze Transcript (via agentProxy) ─────────────────────────────────────
|
|
329
|
-
async function analyzeTranscript(transcript, role) {
|
|
335
|
+
async function analyzeTranscript(transcript, role, model) {
|
|
330
336
|
const spinner = ora(chalk.dim("Generating feedback report...")).start();
|
|
331
337
|
try {
|
|
332
338
|
const formatted = transcript
|
|
@@ -347,6 +353,7 @@ async function analyzeTranscript(transcript, role) {
|
|
|
347
353
|
},
|
|
348
354
|
required: ["overallScore", "communicationScore", "confidenceScore", "relevanceScore", "strengths", "areasForImprovement"],
|
|
349
355
|
},
|
|
356
|
+
model,
|
|
350
357
|
});
|
|
351
358
|
let clean = text.trim().replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "");
|
|
352
359
|
const report = JSON.parse(clean);
|
|
@@ -360,7 +367,7 @@ async function analyzeTranscript(transcript, role) {
|
|
|
360
367
|
}
|
|
361
368
|
// ─── VOICE SESSION ────────────────────────────────────────────────────────────
|
|
362
369
|
async function runVoiceSession(opts) {
|
|
363
|
-
const { role, questions, resumeContext, soxPath } = opts;
|
|
370
|
+
const { role, questions, resumeContext, soxPath, model } = opts;
|
|
364
371
|
printBanner(role, "voice");
|
|
365
372
|
// Create logger (sessionId not yet known — will be set after token vend)
|
|
366
373
|
const log = createLogger("interview", {
|
|
@@ -410,6 +417,10 @@ async function runVoiceSession(opts) {
|
|
|
410
417
|
// (outputBuf never contains END_TOKEN because chunkClean strips it, so the
|
|
411
418
|
// turnComplete check on outputBuf would never fire without this flag.)
|
|
412
419
|
let endTokenSeen = false;
|
|
420
|
+
// Track audio bytes sent to speaker this turn to calculate mute duration
|
|
421
|
+
// 24kHz, 16-bit mono = 48000 bytes/second of playback
|
|
422
|
+
const SPEAKER_BYTES_PER_SEC = RECV_SAMPLE_RATE * 2; // 48000
|
|
423
|
+
let pendingAudioBytes = 0;
|
|
413
424
|
// ── Audio processes ──────────────────────────────────────────────────
|
|
414
425
|
const micProc = startMic(soxPath);
|
|
415
426
|
const speakerProc = startSpeaker(soxPath);
|
|
@@ -454,6 +465,7 @@ async function runVoiceSession(opts) {
|
|
|
454
465
|
muteTimer = null;
|
|
455
466
|
}
|
|
456
467
|
const pcmBuf = Buffer.from(audioPart, "base64");
|
|
468
|
+
pendingAudioBytes += pcmBuf.length;
|
|
457
469
|
speakerProc.stdin.write(pcmBuf);
|
|
458
470
|
}
|
|
459
471
|
// ── Output transcription (Vivid's words) — stream in real-time ──
|
|
@@ -535,15 +547,22 @@ async function runVoiceSession(opts) {
|
|
|
535
547
|
inputBuf = "";
|
|
536
548
|
}
|
|
537
549
|
if (!ended) {
|
|
550
|
+
// Calculate exactly how long the buffered audio will take to play.
|
|
551
|
+
// Show "Listening..." and unmute the mic together when playback ends.
|
|
552
|
+
const playbackMs = Math.ceil((pendingAudioBytes / SPEAKER_BYTES_PER_SEC) * 1000);
|
|
553
|
+
const muteMs = Math.max(playbackMs, 800); // minimum 800ms floor
|
|
554
|
+
pendingAudioBytes = 0;
|
|
538
555
|
muteTimer = setTimeout(() => {
|
|
539
556
|
vividSpeaking = false;
|
|
540
557
|
muteTimer = null;
|
|
558
|
+
// Show "Listening..." right as audio finishes playing
|
|
541
559
|
if (!userSpeechLineActive) {
|
|
542
560
|
process.stdout.write(chalk.green("\n ● Listening...\r"));
|
|
543
561
|
}
|
|
544
|
-
},
|
|
562
|
+
}, muteMs);
|
|
545
563
|
}
|
|
546
564
|
else {
|
|
565
|
+
pendingAudioBytes = 0;
|
|
547
566
|
// Interview ended via END_TOKEN — cancel pending mute timer
|
|
548
567
|
// so '● Listening...' never appears after the interview concludes
|
|
549
568
|
if (muteTimer) {
|
|
@@ -554,10 +573,19 @@ async function runVoiceSession(opts) {
|
|
|
554
573
|
}
|
|
555
574
|
},
|
|
556
575
|
onerror: (e) => {
|
|
557
|
-
|
|
576
|
+
const msg = e?.message ?? JSON.stringify(e) ?? "Unknown error";
|
|
577
|
+
console.log(chalk.red(`\n ❌ Live API connection error: ${msg}`));
|
|
578
|
+
console.log(chalk.dim(` Model: ${LIVE_MODEL} | Location: ${location}`));
|
|
579
|
+
ended = true;
|
|
580
|
+
},
|
|
581
|
+
onclose: (e) => {
|
|
582
|
+
if (!ended) {
|
|
583
|
+
const reason = e?.reason ?? e?.code ?? "unexpected close";
|
|
584
|
+
console.log(chalk.yellow(`\n ⚠️ Live API session closed unexpectedly: ${reason}`));
|
|
585
|
+
console.log(chalk.dim(` Model: ${LIVE_MODEL} | Location: ${location}`));
|
|
586
|
+
}
|
|
558
587
|
ended = true;
|
|
559
588
|
},
|
|
560
|
-
onclose: () => { ended = true; },
|
|
561
589
|
},
|
|
562
590
|
config: {
|
|
563
591
|
responseModalities: [Modality.AUDIO],
|
|
@@ -641,7 +669,7 @@ async function runVoiceSession(opts) {
|
|
|
641
669
|
console.log(chalk.dim("\n Generating your personalized feedback report..."));
|
|
642
670
|
let report = null;
|
|
643
671
|
try {
|
|
644
|
-
report = await analyzeTranscript(transcript, role);
|
|
672
|
+
report = await analyzeTranscript(transcript, role, model);
|
|
645
673
|
printReport(report);
|
|
646
674
|
log.info("feedback_complete", { sessionId, overallScore: report.overallScore });
|
|
647
675
|
}
|
|
@@ -680,42 +708,79 @@ async function runVoiceSession(opts) {
|
|
|
680
708
|
}
|
|
681
709
|
// ─── TEXT SESSION (fallback) ──────────────────────────────────────────────────
|
|
682
710
|
async function runTextSession(opts) {
|
|
683
|
-
const { role, questions, resumeContext } = opts;
|
|
711
|
+
const { role, questions, resumeContext, model } = opts;
|
|
684
712
|
const systemInstruction = buildSystemPrompt(role, questions, resumeContext);
|
|
685
713
|
printBanner(role, "text");
|
|
686
714
|
const history = [];
|
|
687
715
|
const transcript = [];
|
|
688
716
|
let ended = false;
|
|
689
717
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
|
|
718
|
+
let sigintReceived = false;
|
|
719
|
+
let resolvePending = null;
|
|
720
|
+
const sigintHandler = () => {
|
|
721
|
+
if (sigintReceived)
|
|
722
|
+
return;
|
|
723
|
+
sigintReceived = true;
|
|
724
|
+
printSystem("Interview ended by user.");
|
|
725
|
+
rl.close();
|
|
726
|
+
if (resolvePending) {
|
|
727
|
+
resolvePending(null);
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
process.on("SIGINT", sigintHandler);
|
|
731
|
+
rl.on("SIGINT", sigintHandler);
|
|
690
732
|
const askUser = () => new Promise(resolve => {
|
|
733
|
+
resolvePending = resolve;
|
|
691
734
|
if (process.stdin.isTTY)
|
|
692
735
|
process.stdout.write(chalk.white.bold("\n you ❯ "));
|
|
693
|
-
rl.once("line", line =>
|
|
694
|
-
|
|
736
|
+
rl.once("line", line => {
|
|
737
|
+
resolvePending = null;
|
|
738
|
+
resolve(line.trim());
|
|
739
|
+
});
|
|
740
|
+
rl.once("close", () => {
|
|
741
|
+
resolvePending = null;
|
|
742
|
+
resolve(null);
|
|
743
|
+
});
|
|
695
744
|
});
|
|
745
|
+
const runAgentCall = async (contents) => {
|
|
746
|
+
return new Promise((resolve, reject) => {
|
|
747
|
+
resolvePending = (val) => resolve(null);
|
|
748
|
+
callAgentProxy({ contents, systemInstruction, model }).then(res => {
|
|
749
|
+
resolvePending = null;
|
|
750
|
+
resolve(res);
|
|
751
|
+
}, err => {
|
|
752
|
+
resolvePending = null;
|
|
753
|
+
reject(err);
|
|
754
|
+
});
|
|
755
|
+
});
|
|
756
|
+
};
|
|
696
757
|
const spinner = ora(chalk.dim("Vivid is connecting...")).start();
|
|
697
758
|
try {
|
|
698
|
-
const greeting = await
|
|
699
|
-
contents: [{ role: "user", parts: [{ text: "Hello" }] }],
|
|
700
|
-
systemInstruction,
|
|
701
|
-
});
|
|
759
|
+
const greeting = await runAgentCall([{ role: "user", parts: [{ text: "Hello" }] }]);
|
|
702
760
|
spinner.stop();
|
|
703
|
-
|
|
704
|
-
history.push({ role: "model", parts: [{ text: greeting }] });
|
|
705
|
-
transcript.push({ speaker: "user", text: "Hello" });
|
|
706
|
-
transcript.push({ speaker: "ai", text: greeting });
|
|
707
|
-
if (greeting.includes(END_TOKEN))
|
|
761
|
+
if (sigintReceived || greeting === null) {
|
|
708
762
|
ended = true;
|
|
709
|
-
|
|
763
|
+
}
|
|
764
|
+
else {
|
|
765
|
+
history.push({ role: "user", parts: [{ text: "Hello" }] });
|
|
766
|
+
history.push({ role: "model", parts: [{ text: greeting }] });
|
|
767
|
+
transcript.push({ speaker: "user", text: "Hello" });
|
|
768
|
+
transcript.push({ speaker: "ai", text: greeting });
|
|
769
|
+
if (greeting.includes(END_TOKEN))
|
|
770
|
+
ended = true;
|
|
771
|
+
printAI(greeting);
|
|
772
|
+
}
|
|
710
773
|
}
|
|
711
774
|
catch (err) {
|
|
712
775
|
spinner.fail(chalk.red("Failed to connect to AI interviewer."));
|
|
713
776
|
throw err;
|
|
714
777
|
}
|
|
715
|
-
while (!ended) {
|
|
778
|
+
while (!ended && !sigintReceived) {
|
|
716
779
|
const input = await askUser();
|
|
717
|
-
if (input === null || input.toLowerCase() === "exit" || input.toLowerCase() === "q") {
|
|
718
|
-
|
|
780
|
+
if (sigintReceived || input === null || input.toLowerCase() === "exit" || input.toLowerCase() === "q") {
|
|
781
|
+
if (!sigintReceived) {
|
|
782
|
+
printSystem("Interview ended early.");
|
|
783
|
+
}
|
|
719
784
|
break;
|
|
720
785
|
}
|
|
721
786
|
if (input === "")
|
|
@@ -724,8 +789,11 @@ async function runTextSession(opts) {
|
|
|
724
789
|
transcript.push({ speaker: "user", text: input });
|
|
725
790
|
const aiSpinner = ora({ text: "" }).start();
|
|
726
791
|
try {
|
|
727
|
-
const aiResponse = await
|
|
792
|
+
const aiResponse = await runAgentCall(history);
|
|
728
793
|
aiSpinner.stop();
|
|
794
|
+
if (sigintReceived || aiResponse === null) {
|
|
795
|
+
break;
|
|
796
|
+
}
|
|
729
797
|
history.push({ role: "model", parts: [{ text: aiResponse }] });
|
|
730
798
|
transcript.push({ speaker: "ai", text: aiResponse.replace(END_TOKEN, "").trim() });
|
|
731
799
|
if (aiResponse.includes(END_TOKEN))
|
|
@@ -737,6 +805,7 @@ async function runTextSession(opts) {
|
|
|
737
805
|
console.log(chalk.red(`\n Error: ${err.message}\n`));
|
|
738
806
|
}
|
|
739
807
|
}
|
|
808
|
+
process.off("SIGINT", sigintHandler);
|
|
740
809
|
rl.close();
|
|
741
810
|
const userTurns = transcript.filter(t => t.speaker === "user").length;
|
|
742
811
|
if (userTurns < 2) {
|
|
@@ -746,7 +815,7 @@ async function runTextSession(opts) {
|
|
|
746
815
|
console.log(chalk.dim("\n Generating your personalized feedback report..."));
|
|
747
816
|
let textReport = null;
|
|
748
817
|
try {
|
|
749
|
-
textReport = await analyzeTranscript(transcript, role);
|
|
818
|
+
textReport = await analyzeTranscript(transcript, role, model);
|
|
750
819
|
printReport(textReport);
|
|
751
820
|
}
|
|
752
821
|
catch (err) {
|
|
@@ -781,6 +850,7 @@ export function registerInterviewCommand(program) {
|
|
|
781
850
|
.option("-q, --questions <n>", "Number of interview questions to generate", "5")
|
|
782
851
|
.option("--resume <id>", "Load a specific resume ID for context (from cv resumes list)")
|
|
783
852
|
.option("--text", "Use text-only mode (no audio required)")
|
|
853
|
+
.option("--model <model>", "Specify a custom model to use for the interview session")
|
|
784
854
|
.addHelpText("after", `
|
|
785
855
|
Examples:
|
|
786
856
|
cv interview
|
|
@@ -837,7 +907,7 @@ Voice mode setup (one-time):
|
|
|
837
907
|
// ── Generate questions ───────────────────────────────────────────
|
|
838
908
|
let questions;
|
|
839
909
|
try {
|
|
840
|
-
questions = await generateQuestions(role, numQuestions);
|
|
910
|
+
questions = await generateQuestions(role, numQuestions, opts.model);
|
|
841
911
|
}
|
|
842
912
|
catch (err) {
|
|
843
913
|
console.error(chalk.red(`\n Failed to generate questions: ${err.message}\n`));
|
|
@@ -845,7 +915,7 @@ Voice mode setup (one-time):
|
|
|
845
915
|
}
|
|
846
916
|
// ── Determine mode ───────────────────────────────────────────────
|
|
847
917
|
if (opts.text) {
|
|
848
|
-
await runTextSession({ role, questions, resumeContext });
|
|
918
|
+
await runTextSession({ role, questions, resumeContext, model: opts.model });
|
|
849
919
|
return;
|
|
850
920
|
}
|
|
851
921
|
// Probe for sox
|
|
@@ -856,12 +926,12 @@ Voice mode setup (one-time):
|
|
|
856
926
|
" macOS: brew install sox\n" +
|
|
857
927
|
" Linux: sudo apt install sox\n" +
|
|
858
928
|
"\n Or run in text mode: cv interview --text\n"));
|
|
859
|
-
await runTextSession({ role, questions, resumeContext });
|
|
929
|
+
await runTextSession({ role, questions, resumeContext, model: opts.model });
|
|
860
930
|
return;
|
|
861
931
|
}
|
|
862
932
|
// Voice mode
|
|
863
933
|
try {
|
|
864
|
-
await runVoiceSession({ role, questions, resumeContext, soxPath });
|
|
934
|
+
await runVoiceSession({ role, questions, resumeContext, soxPath, model: opts.model });
|
|
865
935
|
}
|
|
866
936
|
catch (err) {
|
|
867
937
|
console.error(chalk.red(`\n Interview error: ${err.message}\n`));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"jobs.d.ts","sourceRoot":"","sources":["../../src/commands/jobs.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"jobs.d.ts","sourceRoot":"","sources":["../../src/commands/jobs.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAmFpC,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,QAmtBnD"}
|
package/dist/commands/jobs.js
CHANGED
|
@@ -11,7 +11,7 @@ import { resolve } from "path";
|
|
|
11
11
|
import chalk from "chalk";
|
|
12
12
|
import ora from "ora";
|
|
13
13
|
import boxen from "boxen";
|
|
14
|
-
import { isApiError, resumeGet, jobsHunt, jobsCreate, jobsUpdate, jobsList, resumesList, generateCoverLetter, listCoverLetters, } from "../api.js";
|
|
14
|
+
import { isApiError, resumeGet, jobsHunt, jobsCreate, jobsUpdate, jobsList, jobsDelete, resumesList, generateCoverLetter, listCoverLetters, } from "../api.js";
|
|
15
15
|
import { checkGwsReady, runGwsCommand } from "../utils/gws-runner.js";
|
|
16
16
|
import { printError } from "../output.js";
|
|
17
17
|
import { registerApplyCommand } from "./apply.js";
|
|
@@ -323,15 +323,15 @@ export function registerJobsCommand(program) {
|
|
|
323
323
|
.option("--json", "Output raw JSON")
|
|
324
324
|
.action(async (opts) => {
|
|
325
325
|
const isJson = opts.json ?? process.argv.includes("--json");
|
|
326
|
-
const spinner = ora("Fetching your job tracker…").start();
|
|
326
|
+
const spinner = isJson ? undefined : ora("Fetching your job tracker…").start();
|
|
327
327
|
const result = await jobsList(opts.status);
|
|
328
328
|
if (isApiError(result)) {
|
|
329
|
-
spinner
|
|
329
|
+
spinner?.fail("Failed to fetch tracker.");
|
|
330
330
|
printError(result.message, undefined, isJson);
|
|
331
331
|
process.exit(1);
|
|
332
332
|
}
|
|
333
333
|
const { jobs, total } = result;
|
|
334
|
-
spinner
|
|
334
|
+
spinner?.succeed(`${total} job(s)${opts.status ? ` with status "${opts.status}"` : ""}`);
|
|
335
335
|
if (isJson) {
|
|
336
336
|
console.log(JSON.stringify(result));
|
|
337
337
|
return;
|
|
@@ -354,6 +354,149 @@ export function registerJobsCommand(program) {
|
|
|
354
354
|
console.log(`\n ${statusSummary}\n`);
|
|
355
355
|
console.log(chalk.dim(` View on web: https://careervivid.app/job-tracker\n`));
|
|
356
356
|
});
|
|
357
|
+
// ── cv jobs validate-links ───────────────────────────────────────────────
|
|
358
|
+
jobsCmd
|
|
359
|
+
.command("validate-links")
|
|
360
|
+
.description("Scan job tracker for duplicates, missing, generic, or broken links")
|
|
361
|
+
.option("--remove-stale", "Delete duplicates (keep oldest) and broken/missing/generic URL entries")
|
|
362
|
+
.option("--json", "Output raw JSON")
|
|
363
|
+
.action(async (opts) => {
|
|
364
|
+
const isJson = opts.json ?? process.argv.includes("--json");
|
|
365
|
+
const spinner = isJson ? undefined : ora("Fetching your job tracker…").start();
|
|
366
|
+
const listResult = await jobsList();
|
|
367
|
+
if (isApiError(listResult)) {
|
|
368
|
+
spinner?.fail("Failed to fetch tracker.");
|
|
369
|
+
printError(listResult.message, undefined, isJson);
|
|
370
|
+
process.exit(1);
|
|
371
|
+
}
|
|
372
|
+
const { jobs } = listResult;
|
|
373
|
+
spinner?.succeed(`Retrieved ${jobs.length} job(s) from tracker`);
|
|
374
|
+
if (jobs.length === 0) {
|
|
375
|
+
if (isJson) {
|
|
376
|
+
console.log(JSON.stringify({ jobs: [], duplicates: [], missing: [], generic: [], broken: [], removed: [], failed: [] }));
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
console.log(chalk.yellow("\n Your tracker is empty.\n"));
|
|
380
|
+
}
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const duplicates = [];
|
|
384
|
+
const missing = [];
|
|
385
|
+
const generic = [];
|
|
386
|
+
const broken = [];
|
|
387
|
+
const normalizeString = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
388
|
+
const isFuzzyRoleMatch = (t1, t2) => {
|
|
389
|
+
const n1 = normalizeString(t1);
|
|
390
|
+
const n2 = normalizeString(t2);
|
|
391
|
+
if (n1 === n2)
|
|
392
|
+
return true;
|
|
393
|
+
if (n1.includes(n2) || n2.includes(n1))
|
|
394
|
+
return true;
|
|
395
|
+
// Fuzzy role check based on words of length >= 4
|
|
396
|
+
const w1 = t1.toLowerCase().split(/\s+/).filter(w => w.length >= 4);
|
|
397
|
+
const w2 = t2.toLowerCase().split(/\s+/).filter(w => w.length >= 4);
|
|
398
|
+
const intersection = w1.filter(w => w2.includes(w));
|
|
399
|
+
return intersection.length >= 2;
|
|
400
|
+
};
|
|
401
|
+
// Keep oldest entry: sort by updatedAt/createdAt asc (earliest first)
|
|
402
|
+
const sortedJobs = [...jobs].sort((a, b) => {
|
|
403
|
+
const t1 = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
|
404
|
+
const t2 = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
|
405
|
+
return t1 - t2;
|
|
406
|
+
});
|
|
407
|
+
const uniqueJobs = [];
|
|
408
|
+
for (const job of sortedJobs) {
|
|
409
|
+
const isDup = uniqueJobs.find(u => normalizeString(u.companyName) === normalizeString(job.companyName) &&
|
|
410
|
+
isFuzzyRoleMatch(u.jobTitle, job.jobTitle));
|
|
411
|
+
if (isDup) {
|
|
412
|
+
duplicates.push(job);
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
uniqueJobs.push(job);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
const { verifyUrl, isGenericJobLanding } = await import("../agent/tools/urlVerifier.js");
|
|
419
|
+
const checkSpinner = isJson ? undefined : ora("Verifying links…").start();
|
|
420
|
+
for (const job of uniqueJobs) {
|
|
421
|
+
const url = job.jobPostURL;
|
|
422
|
+
if (!url || url.trim() === "") {
|
|
423
|
+
missing.push(job);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
if (isGenericJobLanding(url)) {
|
|
427
|
+
generic.push(job);
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
// Verify reachability
|
|
431
|
+
const ver = await verifyUrl(url);
|
|
432
|
+
if (!ver.ok) {
|
|
433
|
+
broken.push(job);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
checkSpinner?.stop();
|
|
437
|
+
const toDelete = new Set();
|
|
438
|
+
duplicates.forEach(j => toDelete.add(j.id));
|
|
439
|
+
missing.forEach(j => toDelete.add(j.id));
|
|
440
|
+
generic.forEach(j => toDelete.add(j.id));
|
|
441
|
+
broken.forEach(j => toDelete.add(j.id));
|
|
442
|
+
const removed = [];
|
|
443
|
+
const failed = [];
|
|
444
|
+
if (opts.removeStale && toDelete.size > 0) {
|
|
445
|
+
const delSpinner = isJson ? undefined : ora(`Removing ${toDelete.size} stale/duplicate entries…`).start();
|
|
446
|
+
for (const id of toDelete) {
|
|
447
|
+
const res = await jobsDelete({ jobId: id });
|
|
448
|
+
if (isApiError(res)) {
|
|
449
|
+
failed.push(`${id}: ${res.message}`);
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
removed.push(id);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
delSpinner?.succeed(`Removed ${removed.length} entries (${failed.length} failed)`);
|
|
456
|
+
}
|
|
457
|
+
if (isJson) {
|
|
458
|
+
console.log(JSON.stringify({
|
|
459
|
+
totalFound: jobs.length,
|
|
460
|
+
duplicates: duplicates.map(j => ({ id: j.id, title: j.jobTitle, company: j.companyName })),
|
|
461
|
+
missing: missing.map(j => ({ id: j.id, title: j.jobTitle, company: j.companyName })),
|
|
462
|
+
generic: generic.map(j => ({ id: j.id, title: j.jobTitle, company: j.companyName, url: j.jobPostURL })),
|
|
463
|
+
broken: broken.map(j => ({ id: j.id, title: j.jobTitle, company: j.companyName, url: j.jobPostURL })),
|
|
464
|
+
removed,
|
|
465
|
+
failed
|
|
466
|
+
}));
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
console.log(`\n${chalk.bold(" Job Tracker Audit Summary")}\n`);
|
|
470
|
+
if (duplicates.length > 0) {
|
|
471
|
+
console.log(chalk.yellow(` 👯 Duplicates (${duplicates.length}):`));
|
|
472
|
+
duplicates.forEach(j => console.log(chalk.dim(` - ${j.jobTitle} @ ${j.companyName} (ID: ${j.id})`)));
|
|
473
|
+
}
|
|
474
|
+
if (missing.length > 0) {
|
|
475
|
+
console.log(chalk.yellow(` 🔗 Missing Apply Links (${missing.length}):`));
|
|
476
|
+
missing.forEach(j => console.log(chalk.dim(` - ${j.jobTitle} @ ${j.companyName} (ID: ${j.id})`)));
|
|
477
|
+
}
|
|
478
|
+
if (generic.length > 0) {
|
|
479
|
+
console.log(chalk.yellow(` 🌐 Generic/Careers Landing URLs (${generic.length}):`));
|
|
480
|
+
generic.forEach(j => console.log(chalk.dim(` - ${j.jobTitle} @ ${j.companyName}: ${j.jobPostURL} (ID: ${j.id})`)));
|
|
481
|
+
}
|
|
482
|
+
if (broken.length > 0) {
|
|
483
|
+
console.log(chalk.yellow(` ❌ Broken/Expired URLs (${broken.length}):`));
|
|
484
|
+
broken.forEach(j => console.log(chalk.dim(` - ${j.jobTitle} @ ${j.companyName}: ${j.jobPostURL} (ID: ${j.id})`)));
|
|
485
|
+
}
|
|
486
|
+
const totalStale = toDelete.size;
|
|
487
|
+
if (totalStale === 0) {
|
|
488
|
+
console.log(chalk.green(" ✔ No stale or duplicate job listings found in your tracker!\n"));
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
console.log(`\n Total stale entries found: ${chalk.bold.red(totalStale)}`);
|
|
492
|
+
if (opts.removeStale) {
|
|
493
|
+
console.log(chalk.green(` ✔ Cleaned ${removed.length} entries successfully.\n`));
|
|
494
|
+
}
|
|
495
|
+
else {
|
|
496
|
+
console.log(chalk.cyan(` 💡 Run with ${chalk.bold("--remove-stale")} to automatically delete these entries.\n`));
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
});
|
|
357
500
|
// ── cv jobs sync-gmail (legacy) ───────────────────────────────────────────
|
|
358
501
|
jobsCmd
|
|
359
502
|
.command("sync-gmail")
|
package/dist/commands/resumes.js
CHANGED
|
@@ -59,15 +59,15 @@ export function registerResumesCommand(program) {
|
|
|
59
59
|
.option("--json", "Output raw JSON")
|
|
60
60
|
.action(async (opts) => {
|
|
61
61
|
const isJson = opts.json ?? process.argv.includes("--json");
|
|
62
|
-
const spinner = ora("Fetching resumes…").start();
|
|
62
|
+
const spinner = isJson ? undefined : ora("Fetching resumes…").start();
|
|
63
63
|
const result = await resumesList();
|
|
64
64
|
if (isApiError(result)) {
|
|
65
|
-
spinner
|
|
65
|
+
spinner?.fail("Failed to list resumes.");
|
|
66
66
|
printError(result.message, undefined, isJson);
|
|
67
67
|
process.exit(1);
|
|
68
68
|
}
|
|
69
69
|
const { resumes, total } = result;
|
|
70
|
-
spinner
|
|
70
|
+
spinner?.succeed(`Found ${total} resume(s)`);
|
|
71
71
|
if (isJson) {
|
|
72
72
|
console.log(JSON.stringify(result));
|
|
73
73
|
return;
|
|
@@ -90,14 +90,14 @@ export function registerResumesCommand(program) {
|
|
|
90
90
|
.option("--json", "Output raw JSON")
|
|
91
91
|
.action(async (id, opts) => {
|
|
92
92
|
const isJson = opts.json ?? process.argv.includes("--json");
|
|
93
|
-
const spinner = ora("Fetching resume…").start();
|
|
93
|
+
const spinner = isJson ? undefined : ora("Fetching resume…").start();
|
|
94
94
|
const result = await resumeGet(id);
|
|
95
95
|
if (isApiError(result)) {
|
|
96
|
-
spinner
|
|
96
|
+
spinner?.fail("Failed to get resume.");
|
|
97
97
|
printError(result.message, undefined, isJson);
|
|
98
98
|
process.exit(1);
|
|
99
99
|
}
|
|
100
|
-
spinner
|
|
100
|
+
spinner?.succeed(`Resume loaded: ${result.title}`);
|
|
101
101
|
if (isJson) {
|
|
102
102
|
console.log(JSON.stringify(result));
|
|
103
103
|
return;
|
package/dist/index.js
CHANGED
|
@@ -98,10 +98,12 @@ async function main() {
|
|
|
98
98
|
return;
|
|
99
99
|
}
|
|
100
100
|
// ── Session expiry check (firebase-cli style) ──────────────────────────────
|
|
101
|
-
// Skip for auth/login/logout/config/help commands — they don’t need a live session
|
|
101
|
+
// Skip for auth/login/logout/config/help commands — they don’t need a live session.
|
|
102
|
+
// Subcommand help must work before login, e.g. `cv agent --help`.
|
|
102
103
|
const SESSION_EXEMPT = ["login", "logout", "auth", "config", "upgrade", "-v", "--version", "-h", "--help", "help"];
|
|
103
104
|
const subcommand = process.argv[2] ?? "";
|
|
104
|
-
const
|
|
105
|
+
const wantsHelp = process.argv.includes("-h") || process.argv.includes("--help") || subcommand === "help";
|
|
106
|
+
const isExempt = wantsHelp || SESSION_EXEMPT.includes(subcommand);
|
|
105
107
|
if (!isExempt && !process.env.CV_API_KEY && !isSessionValid()) {
|
|
106
108
|
const { loadConfig } = await import("./config.js");
|
|
107
109
|
const hasKey = !!loadConfig().apiKey;
|