careervivid 2.1.18 → 2.1.22

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.
@@ -1,14 +1,25 @@
1
+ /**
2
+ * repl.ts — REPL orchestrator for the CareerVivid agent
3
+ *
4
+ * This file is intentionally thin. Each concern lives in its own module:
5
+ *
6
+ * repl/input.ts — User input: first-turn menu, paste buffer, <<<
7
+ * repl/slashCommands.ts — /help, /voice, /speak, /models, /model
8
+ * repl/toolHandlers.ts — Tool confirmation, spinner, mutation budget, audit
9
+ * repl/engineLoop.ts — CareerVivid & BYO provider run loops
10
+ */
1
11
  import chalk from "chalk";
2
12
  import pkg from "enquirer";
3
- import ora from "ora";
4
- import { isSafeCommand } from "../../agent/tools/coding.js";
5
13
  import { CareerVividProxyEngine } from "../../agent/CareerVividProxyEngine.js";
6
- import { CV_MODELS } from "./configurator.js";
7
- import { loadConfig, getProviderKey, setProviderKey } from "../../config.js";
8
- import { auditLog, writeSessionSummary, SESSION_ID } from "../../agent/agentAuditLog.js";
14
+ import { writeSessionSummary } from "../../agent/agentAuditLog.js";
9
15
  import { runShellEscape } from "../../lib/shell.js";
10
- import { isVoiceEnabled, setVoiceEnabled, setLastResponse, getLastResponse, speakText, stopPlayback, getCurrentVoice, setCurrentVoice, getCurrentTtsModel, setCurrentTtsModel, AVAILABLE_VOICES, AVAILABLE_TTS_MODELS } from "../../lib/tts.js";
16
+ import { setLastResponse, isVoiceEnabled, speakText } from "../../lib/tts.js";
17
+ import { readFirstTurnInput, readMultiLineInput, readNormalInput } from "./repl/input.js";
18
+ import { handleSlashCommand } from "./repl/slashCommands.js";
19
+ import { createToolHandlerState } from "./repl/toolHandlers.js";
20
+ import { runEngineLoop } from "./repl/engineLoop.js";
11
21
  const { prompt } = pkg;
22
+ // ── Credit status display (also exported for engineLoop.ts) ───────────────────
12
23
  export function printCreditStatus(remaining, limit = null) {
13
24
  if (remaining === null)
14
25
  return;
@@ -28,25 +39,72 @@ export function printCreditStatus(remaining, limit = null) {
28
39
  }
29
40
  }
30
41
  }
42
+ // ── 401 error handler ─────────────────────────────────────────────────────────
43
+ async function handle401Error(selectedProvider, options) {
44
+ const LABELS = {
45
+ openai: "OpenAI", anthropic: "Anthropic",
46
+ gemini: "Gemini", openrouter: "OpenRouter", custom: "Custom",
47
+ };
48
+ const KEY_URLS = {
49
+ openai: "https://platform.openai.com/api-keys",
50
+ anthropic: "https://console.anthropic.com/settings/keys",
51
+ gemini: "https://aistudio.google.com/app/apikey",
52
+ openrouter: "https://openrouter.ai/settings/keys",
53
+ };
54
+ const { setProviderKey } = await import("../../config.js");
55
+ const label = LABELS[selectedProvider] ?? selectedProvider;
56
+ console.log();
57
+ console.log(chalk.red(`❌ API key rejected by ${label} (401 Unauthorized).`));
58
+ console.log(chalk.dim(" The saved key may be expired or invalid."));
59
+ if (KEY_URLS[selectedProvider]) {
60
+ console.log(chalk.dim(" Get a new key at: ") + chalk.cyan(KEY_URLS[selectedProvider]));
61
+ }
62
+ console.log();
63
+ try {
64
+ const answer = await prompt({
65
+ type: "select",
66
+ name: "action",
67
+ message: "What would you like to do?",
68
+ choices: [
69
+ { name: "reset", message: `🔑 Enter a new ${label} API key` },
70
+ { name: "continue", message: "⏭️ Continue anyway (will keep failing)" },
71
+ { name: "exit", message: "🚪 Exit the agent" },
72
+ ],
73
+ });
74
+ if (answer.action === "reset") {
75
+ const { key } = await prompt({
76
+ type: "password",
77
+ name: "key",
78
+ message: `Enter your new ${label} API key:`,
79
+ });
80
+ const newKey = (key ?? "").trim();
81
+ if (newKey) {
82
+ setProviderKey(selectedProvider, newKey);
83
+ options["api-key"] = newKey;
84
+ console.log(chalk.green(`\n✔ New ${label} key saved. Resuming session...\n`));
85
+ }
86
+ }
87
+ else if (answer.action === "exit") {
88
+ console.log(chalk.gray("\nGoodbye! 👋\n"));
89
+ process.exit(0);
90
+ }
91
+ }
92
+ catch {
93
+ // User cancelled — just continue
94
+ }
95
+ return true; // re-prompt
96
+ }
97
+ // ── Main REPL loop ────────────────────────────────────────────────────────────
31
98
  export async function askLoop(engine, options, selectedProvider, selectedModel, cvApiKey, systemInstruction, tools) {
32
99
  let sessionTurns = 0;
33
100
  let sessionLimit = null;
34
101
  let currentModel = selectedModel;
35
- // #3 Session mutation budget
36
- const WRITE_TOOLS = new Set([
37
- "tracker_add_job", "tracker_update_job", "kanban_add_job", "kanban_update_status",
38
- "save_cover_letter", "delete_cover_letter", "write_file", "patch_file",
39
- "tracker_recheck_urls", "openings_apply",
40
- ]);
41
- const SESSION_MAX_MUTATIONS = 25;
42
- const TURN_MAX_MUTATIONS = 10;
102
+ let currentEngine = engine;
43
103
  let sessionMutations = 0;
44
- let turnMutations = 0;
45
- // #9 Circuit breaker — detect tool call loops
46
- let lastToolCall = { name: "", argsHash: "", count: 0 };
104
+ const toolState = createToolHandlerState();
105
+ const byoHistory = [];
47
106
  let pasteBuffer = [];
48
- let byoHistory = []; // Track history for BYO providers
49
- // ── SIGINT handler: Ctrl+C cancels current operation and returns to prompt ──
107
+ // ── SIGINT: Ctrl+C cancels current op, second exits ──────────────────────
50
108
  let activeAbort = null;
51
109
  const handleSigInt = () => {
52
110
  const ab = activeAbort;
@@ -55,94 +113,27 @@ export async function askLoop(engine, options, selectedProvider, selectedModel,
55
113
  process.stdout.write("\n" + chalk.yellow("⚡ Interrupted. Press Ctrl+C again or type 'exit' to quit.\n"));
56
114
  }
57
115
  else {
58
- // Second Ctrl+C exits
59
116
  console.log(chalk.gray("\nGoodbye! 👋\n"));
60
117
  process.exit(0);
61
118
  }
62
119
  };
63
120
  process.on("SIGINT", handleSigInt);
64
- /** Wraps a promise with a timeout. Rejects with a friendly timeout error. */
65
- function withTimeout(p, ms, label) {
66
- return new Promise((resolve, reject) => {
67
- const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms / 1000}s. Press Ctrl+C if stuck.`)), ms);
68
- p.then(v => { clearTimeout(timer); resolve(v); })
69
- .catch(e => { clearTimeout(timer); reject(e); });
70
- });
71
- }
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
121
  const ask = async (isFirstTurn = false) => {
83
122
  try {
84
123
  let userInput;
124
+ // ── Collect user input ──────────────────────────────────────────────
85
125
  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}`));
90
- }
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() || "";
126
+ userInput = await readFirstTurnInput();
109
127
  }
110
128
  else {
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 ──────────────────────────────────────
129
+ const { text, isFastLine } = await readNormalInput(pasteBuffer.length > 0);
130
+ userInput = text;
123
131
  if (userInput.trim() === "<<<" || userInput.trim().toLowerCase().startsWith("<<<")) {
124
132
  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();
133
+ userInput = await readMultiLineInput(prefix);
143
134
  pasteBuffer = [];
144
135
  }
145
- else if (duration < 150) {
136
+ else if (isFastLine && !userInput.startsWith("!") && !userInput.startsWith("/")) {
146
137
  pasteBuffer.push(userInput);
147
138
  return ask();
148
139
  }
@@ -154,20 +145,19 @@ export async function askLoop(engine, options, selectedProvider, selectedModel,
154
145
  pasteBuffer = [];
155
146
  }
156
147
  }
157
- } // end else (non-first turn)
148
+ }
158
149
  userInput = userInput.trim();
159
150
  if (!userInput)
160
151
  return ask();
161
- // ── Input length guard ──────────────────────────────────────────
162
- const MAX_INPUT_CHARS = 20_000;
163
- if (userInput.length > MAX_INPUT_CHARS) {
164
- console.log(chalk.yellow("\n⚠️ Input is too long (" + userInput.length + " chars).") +
152
+ // ── Input length guard ─────────────────────────────────────────────
153
+ if (userInput.length > 20_000) {
154
+ console.log(chalk.yellow(`\n⚠️ Input is too long (${userInput.length} chars).`) +
165
155
  chalk.dim("\n Use <<< mode for long job descriptions so nothing gets cut off:") +
166
156
  chalk.cyan("\n\n ❯ <<< ") +
167
157
  chalk.dim("\n Then paste the job description, and press Enter twice to submit.\n"));
168
158
  return ask();
169
159
  }
170
- // ── Subshell escape: input starting with ! runs as a raw shell command ──
160
+ // ── Subshell escape: !command ──────────────────────────────────────
171
161
  if (userInput.startsWith("!")) {
172
162
  const shellCmd = userInput.slice(1).trim();
173
163
  if (shellCmd) {
@@ -176,140 +166,24 @@ export async function askLoop(engine, options, selectedProvider, selectedModel,
176
166
  }
177
167
  return ask();
178
168
  }
179
- // ── Slash commands ──────────────────────────────────────────────
169
+ // ── Slash commands ─────────────────────────────────────────────────
180
170
  if (userInput.startsWith("/")) {
181
- const [cmd, ...rest] = userInput.slice(1).split(" ");
182
- const arg = rest.join(" ").trim();
183
- if (cmd === "help") {
184
- console.log(chalk.cyan("\n Slash commands:"));
185
- console.log(chalk.dim(" /model <name> — Switch to a different model mid-session"));
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"));
189
- console.log(chalk.dim(" /help — Show this help message"));
190
- console.log(chalk.dim(" exit — End the session"));
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):"));
194
- console.log(chalk.dim(" <<< — Open multi-line paste mode; press Enter twice when done"));
195
- console.log(chalk.dim(" <<<your text — Start with text directly after <<<\n"));
196
- return ask();
197
- }
198
- if (cmd === "voice") {
199
- if (arg === "on") {
200
- setVoiceEnabled(true);
201
- console.log(chalk.green(`\n 🔊 Voice enabled (${getCurrentVoice()} · ${getCurrentTtsModel()}).\n`));
202
- }
203
- else if (arg === "off") {
204
- setVoiceEnabled(false);
205
- stopPlayback();
206
- console.log(chalk.yellow("\n 🔇 Voice disabled.\n"));
207
- }
208
- else if (arg === "list-voices" || arg === "voices") {
209
- console.log(chalk.cyan("\n Available voices:"));
210
- for (const v of AVAILABLE_VOICES) {
211
- const active = v === getCurrentVoice() ? chalk.green(" ← active") : "";
212
- console.log(chalk.dim(` ${v}${active}`));
213
- }
214
- console.log(chalk.dim("\n Usage: /voice set-voice Puck\n"));
215
- }
216
- else if (arg.startsWith("set-voice ")) {
217
- const name = arg.slice("set-voice ".length).trim();
218
- const match = AVAILABLE_VOICES.find(v => v.toLowerCase() === name.toLowerCase());
219
- if (!match) {
220
- console.log(chalk.red(`\n Unknown voice: "${name}". Run /voice list-voices to see options.\n`));
221
- }
222
- else {
223
- setCurrentVoice(match);
224
- console.log(chalk.green(`\n 🎵 Voice set to ${chalk.bold(match)}.\n`));
225
- }
226
- }
227
- else if (arg === "list-models" || arg === "models") {
228
- console.log(chalk.cyan("\n Available TTS models:"));
229
- for (const m of AVAILABLE_TTS_MODELS) {
230
- const active = m === getCurrentTtsModel() ? chalk.green(" ← active") : "";
231
- console.log(chalk.dim(` ${m}${active}`));
232
- }
233
- console.log(chalk.dim("\n Usage: /voice set-model gemini-2.5-pro-preview-tts\n"));
234
- }
235
- else if (arg.startsWith("set-model ")) {
236
- const name = arg.slice("set-model ".length).trim();
237
- const match = AVAILABLE_TTS_MODELS.find(m => m === name);
238
- if (!match) {
239
- console.log(chalk.red(`\n Unknown model: "${name}". Run /voice list-models to see options.\n`));
240
- }
241
- else {
242
- setCurrentTtsModel(match);
243
- console.log(chalk.green(`\n ⚙️ TTS model set to ${chalk.bold(match)}.\n`));
244
- }
245
- }
246
- else {
247
- const status = isVoiceEnabled() ? chalk.green("on") : chalk.yellow("off");
248
- console.log(chalk.dim(`\n Voice is ${status} · Voice: ${chalk.white(getCurrentVoice())} · Model: ${chalk.white(getCurrentTtsModel())}`));
249
- console.log(chalk.dim("\n Commands:"));
250
- console.log(chalk.dim(" /voice on | off"));
251
- console.log(chalk.dim(" /voice set-voice <name> e.g. /voice set-voice Puck"));
252
- console.log(chalk.dim(" /voice list-voices"));
253
- console.log(chalk.dim(" /voice set-model <name> e.g. /voice set-model gemini-2.5-pro-preview-tts"));
254
- console.log(chalk.dim(" /voice list-models\n"));
255
- }
256
- return ask();
257
- }
258
- if (cmd === "speak") {
259
- const last = getLastResponse();
260
- if (!last) {
261
- console.log(chalk.dim("\n Nothing to speak yet. Ask the agent something first.\n"));
262
- }
263
- else {
264
- speakText(last).catch(() => { });
265
- console.log(chalk.dim("\n 🔊 Speaking last response...\n"));
266
- }
267
- return ask();
268
- }
269
- if (cmd === "models") {
270
- console.log(chalk.cyan("\n Available CareerVivid models:"));
271
- for (const m of CV_MODELS) {
272
- const active = m.value === currentModel ? chalk.green(" ← active") : "";
273
- console.log(` ${m.name}${active}`);
274
- }
275
- console.log(chalk.dim("\n Usage: /model gemini-2.5-flash\n"));
276
- return ask();
277
- }
278
- if (cmd === "model") {
279
- if (!arg) {
280
- console.log(chalk.yellow(`\n Current model: ${chalk.bold(currentModel)}`));
281
- console.log(chalk.dim(" Usage: /model <name> e.g. /model gemini-3.1-pro-preview"));
282
- console.log(chalk.dim(" Run /models to see all available options.\n"));
283
- return ask();
284
- }
285
- const newModel = arg;
286
- const known = CV_MODELS.find((m) => m.value === newModel);
287
- if (!known && !newModel.includes("/") && !newModel.includes("-")) {
288
- console.log(chalk.red(`\n Unknown model: ${newModel}`));
289
- console.log(chalk.dim(" Run /models to see available options.\n"));
290
- return ask();
291
- }
292
- currentModel = newModel;
293
- if (cvApiKey && engine instanceof CareerVividProxyEngine) {
294
- engine = new CareerVividProxyEngine({
295
- cvApiKey,
296
- model: currentModel,
297
- systemInstruction,
298
- tools,
299
- thinkingBudget: newModel.includes("pro") ? (options.think ?? 8192) : 0,
300
- maxHistoryLength: 40,
301
- });
302
- }
303
- const creditInfo = known ? chalk.dim(` (${known.cost} credit/turn)`) : "";
304
- console.log(chalk.green(`\n ✔ Switched to ${chalk.bold(currentModel)}${creditInfo}`));
305
- console.log(chalk.dim(" Conversation history has been reset.\n"));
306
- return ask();
171
+ const result = await handleSlashCommand(userInput, currentModel, {
172
+ cvApiKey,
173
+ engine: currentEngine,
174
+ systemInstruction,
175
+ tools,
176
+ options,
177
+ });
178
+ if (result?.modelSwitch) {
179
+ currentModel = result.modelSwitch.newModel;
180
+ currentEngine = result.modelSwitch.newEngine;
307
181
  }
308
- console.log(chalk.yellow(`\n Unknown command: /${cmd}. Type /help for available commands.\n`));
309
182
  return ask();
310
183
  }
184
+ // ── Exit ───────────────────────────────────────────────────────────
311
185
  if (userInput.toLowerCase() === "exit") {
312
- const proxyEngine = engine instanceof CareerVividProxyEngine ? engine : null;
186
+ const proxyEngine = currentEngine instanceof CareerVividProxyEngine ? currentEngine : null;
313
187
  if (proxyEngine && sessionTurns > 0) {
314
188
  console.log(chalk.dim("\n─────────────────────────────────────────"));
315
189
  console.log(chalk.dim(`Session: ${sessionTurns} turn${sessionTurns !== 1 ? "s" : ""} · `) +
@@ -323,402 +197,52 @@ export async function askLoop(engine, options, selectedProvider, selectedModel,
323
197
  console.log(chalk.gray("\nGoodbye! 👋\n"));
324
198
  process.exit(0);
325
199
  }
326
- // Reset per-turn mutation counter at the start of each user message
327
- turnMutations = 0;
328
- // ── Clear previous spinner residue then show thinking indicator ──
200
+ // ── Run the agent turn ─────────────────────────────────────────────
201
+ sessionTurns++;
202
+ toolState.turnMutations = 0; // reset per-turn counter
329
203
  process.stdout.write(chalk.dim("\n"));
330
- const thinkingSpinner = ora({ text: chalk.dim("Vivid is thinking…"), color: "cyan", spinner: "dots" }).start();
331
- let firstChunk = true;
332
- let currentSpinner = null;
333
- let trustAllCommands = false;
334
- let trustAllWrites = false;
335
- // Map internal tool names to user-friendly labels
336
- const TOOL_LABELS = {
337
- list_directory: "🔍 Scanning workspace...",
338
- read_file: "📖 Reading file...",
339
- run_command: "⚙️ Running command...",
340
- write_file: "✏️ Writing file...",
341
- patch_file: "✏️ Patching file...",
342
- tracker_list_jobs: "📊 Checking job pipeline...",
343
- tracker_add_job: "➕ Adding job to pipeline...",
344
- tracker_update_job: "🔄 Updating job record...",
345
- tracker_rank_priority: "📈 Ranking pipeline...",
346
- tracker_dashboard: "📊 Fetching pipeline analytics...",
347
- tracker_find_stale: "🚩 Checking stale jobs...",
348
- tracker_inspect_quality: "🔍 Inspecting data quality...",
349
- kanban_add_job: "📌 Saving to Kanban board...",
350
- kanban_list_jobs: "📋 Loading Kanban board...",
351
- kanban_update_status: "🔄 Updating Kanban status...",
352
- list_cover_letters: "📄 Loading cover letters...",
353
- get_cover_letter: "📄 Reading cover letter...",
354
- save_cover_letter: "💾 Saving cover letter...",
355
- delete_cover_letter: "🗑️ Deleting cover letter...",
356
- browser_navigate: "🌐 Navigating to page...",
357
- browser_click: "🖱️ Clicking element...",
358
- browser_type: "⌨️ Typing input...",
359
- browser_state: "🌐 Reading browser state...",
360
- browser_screenshot: "📸 Taking screenshot...",
361
- browser_scroll: "📜 Scrolling page...",
362
- browser_wait: "⏳ Waiting...",
363
- browser_close: "🔒 Closing browser...",
364
- browser_select: "🖱️ Selecting option...",
365
- tracker_recheck_urls: "🔗 Re-checking job URLs...",
366
- browser_autofill_application: "📝 Auto-filling application...",
367
- verify_url: "🔍 Verifying URL...",
368
- verify_job_urls: "🔍 Verifying job URLs...",
369
- search_jobs: "🔍 Searching jobs...",
370
- openings_scan: "🎯 Scanning companies for open roles...",
371
- openings_list: "📋 Loading saved openings...",
372
- openings_apply: "✅ Marking opening as applied...",
373
- get_resume: "📄 Loading resume...",
374
- list_resumes: "📄 Loading resumes...",
375
- get_profile: "👤 Loading profile...",
376
- };
377
- const handleToolCall = async (name, args) => {
378
- // Stop the thinking spinner the moment we start a tool — prevents duplication
379
- if (thinkingSpinner.isSpinning) {
380
- thinkingSpinner.stop();
381
- process.stdout.write("\r\x1b[K"); // clear spinner line
204
+ const response = await runEngineLoop({
205
+ engine: currentEngine,
206
+ userInput,
207
+ selectedProvider,
208
+ selectedModel,
209
+ currentModel,
210
+ byoHistory,
211
+ tools,
212
+ systemInstruction,
213
+ verbose: Boolean(options.verbose),
214
+ sessionTurns,
215
+ apiKey: options["api-key"] || options.apiKey,
216
+ baseUrl: options["base-url"] || options.baseUrl,
217
+ handleSigInt,
218
+ toolState,
219
+ onCreditInfo: (remaining, limit) => {
220
+ sessionLimit = limit;
221
+ },
222
+ });
223
+ // ── TTS: store + auto-speak ────────────────────────────────────────
224
+ if (response) {
225
+ setLastResponse(response);
226
+ if (isVoiceEnabled()) {
227
+ speakText(response).catch(() => { });
382
228
  }
383
- // #9 Circuit breaker: abort if same tool called 5+ times consecutively with same args
384
- const argsHash = JSON.stringify(args).slice(0, 100);
385
- if (lastToolCall.name === name && lastToolCall.argsHash === argsHash) {
386
- lastToolCall.count++;
387
- if (lastToolCall.count >= 5) {
388
- console.log(chalk.red(`\n⛔ Loop detected: "${name}" called ${lastToolCall.count} times with identical args. Aborting turn.`));
389
- return false;
390
- }
391
- }
392
- else {
393
- lastToolCall = { name, argsHash, count: 1 };
394
- }
395
- // #3 Per-turn mutation budget
396
- if (WRITE_TOOLS.has(name)) {
397
- turnMutations++;
398
- if (turnMutations > TURN_MAX_MUTATIONS) {
399
- console.log(chalk.red(`\n⛔ Turn mutation limit (${TURN_MAX_MUTATIONS}) reached. The agent has made ${turnMutations} writes this turn.`));
400
- return false;
401
- }
402
- sessionMutations++;
403
- if (sessionMutations >= SESSION_MAX_MUTATIONS) {
404
- console.log(chalk.yellow(`\n⚠️ Session mutation budget exhausted (${SESSION_MAX_MUTATIONS} writes). Restart the agent to continue writing.`));
405
- return false;
406
- }
407
- else if (sessionMutations === SESSION_MAX_MUTATIONS - 5) {
408
- console.log(chalk.yellow(`\n💡 Heads up: ${SESSION_MAX_MUTATIONS - sessionMutations} writes remaining this session.`));
409
- }
410
- }
411
- // Print compact tool label — no blank lines, stays tight between steps
412
- const label = TOOL_LABELS[name] ?? `⚙️ Working...`;
413
- process.stdout.write(chalk.dim(` ${label}\n`));
414
- if (name === "run_command") {
415
- if (trustAllCommands || isSafeCommand(args.command)) {
416
- return true;
417
- }
418
- const confirm = await prompt({
419
- type: "select",
420
- name: "ok",
421
- message: `Allow running: ${chalk.bold(args.command)}?`,
422
- choices: [
423
- "Yes, run it",
424
- "Yes, and trust all commands this session",
425
- "No, skip it",
426
- ],
427
- });
428
- if (confirm.ok === "Yes, and trust all commands this session") {
429
- trustAllCommands = true;
430
- console.log(chalk.dim(" ✅ All commands will run automatically for the rest of this session."));
431
- return true;
432
- }
433
- return confirm.ok === "Yes, run it";
434
- }
435
- if (name === "write_file" || name === "patch_file") {
436
- if (trustAllWrites)
437
- return true;
438
- const target = args.path || "(unknown path)";
439
- const confirm = await prompt({
440
- type: "select",
441
- name: "ok",
442
- message: `Allow writing to: ${chalk.bold(target)}?`,
443
- choices: [
444
- "Yes, write it",
445
- "Yes, and trust all writes this session",
446
- "No, skip it",
447
- ],
448
- });
449
- if (confirm.ok === "Yes, and trust all writes this session") {
450
- trustAllWrites = true;
451
- console.log(chalk.dim(" ✅ All file writes will run automatically for the rest of this session."));
452
- return true;
453
- }
454
- if (confirm.ok !== "Yes, write it")
455
- return false;
456
- }
457
- if (["browser_state", "browser_screenshot", "browser_scroll", "browser_wait"].includes(name)) {
458
- currentSpinner = ora(`Running ${chalk.bold(name)}...`).start();
459
- return true;
460
- }
461
- if (["browser_navigate", "browser_click", "browser_type", "browser_select"].includes(name)) {
462
- currentSpinner = ora(`Running ${chalk.bold(name)}...`).start();
463
- return true;
464
- }
465
- if (name === "browser_close") {
466
- const confirm = await prompt({
467
- type: "select",
468
- name: "ok",
469
- message: `Close the browser?`,
470
- choices: ["Yes, close it", "No, keep it open"],
471
- });
472
- if (confirm.ok !== "Yes, close it")
473
- return false;
474
- }
475
- // Tools that take over the full terminal must NOT have a spinner running —
476
- // the concurrent ora redraw causes constant flashing.
477
- if (name === "start_interview") {
478
- // Clear current line so any previous UI is gone, then yield terminal cleanly.
479
- process.stdout.write("\r\x1b[K");
480
- return true;
481
- }
482
- currentSpinner = ora(`Running ${chalk.bold(name)}...`).start();
483
- return true;
484
- };
485
- const handleToolResult = (name, result) => {
486
- if (currentSpinner) {
487
- currentSpinner.succeed(chalk.dim("Done"));
488
- currentSpinner = null;
489
- }
490
- if (name === "start_interview") {
491
- // Interview already printed its own output — just add a separator.
492
- console.log(chalk.dim("─".repeat(50)));
493
- }
494
- // #4 Audit log — record every completed tool call
495
- // durationMs is approximate since we don't have exact start time here
496
- auditLog({
497
- sessionId: SESSION_ID,
498
- tool: name,
499
- args: typeof result?._args === "object" ? result._args : {},
500
- result: typeof result === "string" ? result : JSON.stringify(result ?? ""),
501
- durationMs: 0, // QueryEngine doesn't expose timing; repl.ts timing TBD
502
- });
503
- // Suppress raw output — the agent will summarize it in natural language
504
- };
505
- if (engine) {
506
- sessionTurns++;
507
- let responseAccumulator = "";
508
- const sharedOnChunk = (text) => {
509
- if (firstChunk) {
510
- thinkingSpinner.stop();
511
- // Print a subtle gutter marker so AI response is visually distinct
512
- process.stdout.write("\n" + chalk.hex("#6366f1")("✦ "));
513
- firstChunk = false;
514
- }
515
- process.stdout.write(text);
516
- responseAccumulator += text; // Accumulate for TTS
517
- };
518
- const sharedOnError = (error) => {
519
- thinkingSpinner.stop();
520
- if (currentSpinner) {
521
- currentSpinner.fail("Tool error");
522
- currentSpinner = null;
523
- }
524
- console.log(chalk.red(`\n❌ Error: ${error.message}\n`));
525
- };
526
- if (engine instanceof CareerVividProxyEngine) {
527
- await engine.runLoopStreaming(userInput, {
528
- onChunk: sharedOnChunk,
529
- onThinking: (thought) => {
530
- if (options.verbose) {
531
- console.log(chalk.dim(`\n[thinking] ${thought.substring(0, 200)}...`));
532
- }
533
- },
534
- onToolCall: handleToolCall,
535
- onToolResult: handleToolResult,
536
- onCompacting: () => {
537
- console.log(chalk.dim("\n📦 Compacting context window...\n"));
538
- },
539
- onError: sharedOnError,
540
- onResponse: async (creditInfo) => {
541
- sessionLimit = creditInfo.monthlyLimit;
542
- printCreditStatus(creditInfo.creditsRemaining, sessionLimit);
543
- },
544
- onCreditLimitReached: (remaining) => {
545
- console.log(chalk.red("\n\n⚠️ Credit limit reached (" + remaining + " remaining).\n" +
546
- chalk.dim(" Upgrade or top up at ") +
547
- chalk.underline.blue("careervivid.app/developer")));
548
- },
549
- });
550
- }
551
- else {
552
- await engine.runLoopStreaming(userInput, {
553
- onChunk: sharedOnChunk,
554
- onThinking: (thought) => {
555
- if (options.verbose) {
556
- console.log(chalk.dim(`\n[thinking] ${thought.substring(0, 200)}...`));
557
- }
558
- },
559
- onToolCall: handleToolCall,
560
- onToolResult: handleToolResult,
561
- onCompacting: () => {
562
- console.log(chalk.dim("\n📦 Compacting context window...\n"));
563
- },
564
- onError: sharedOnError,
565
- });
566
- }
567
- // ── TTS: store last response + auto-speak if voice enabled ──────
568
- if (responseAccumulator) {
569
- setLastResponse(responseAccumulator);
570
- if (isVoiceEnabled()) {
571
- speakText(responseAccumulator).catch(() => { });
572
- }
573
- }
574
- // ── Clean turn separator after every AI reply ─────────────────────────────
575
- process.stdout.write("\n" + chalk.dim("─".repeat(48)) + "\n");
576
- }
577
- else {
578
- sessionTurns++;
579
- const { createOpenAICompatibleProvider } = await import("../../agent/providers/OpenAIProvider.js");
580
- const { AnthropicProvider } = await import("../../agent/providers/AnthropicProvider.js");
581
- const byoApiKey = options["api-key"] || getProviderKey(selectedProvider) || loadConfig().llmApiKey;
582
- const key = byoApiKey || "";
583
- const baseUrl = options["base-url"] || loadConfig().llmBaseUrl;
584
- let provider;
585
- if (selectedProvider === "anthropic") {
586
- provider = new AnthropicProvider({ apiKey: key });
587
- }
588
- else {
589
- const subProvider = (selectedProvider === "openrouter" ? "openrouter" :
590
- selectedProvider === "custom" ? "custom" : "openai");
591
- provider = createOpenAICompatibleProvider(subProvider, key, baseUrl);
592
- }
593
- let userTurn = { role: "user", parts: [{ text: userInput }] };
594
- let round = 0;
595
- while (round < 10) {
596
- const result = await withTimeout(provider.generate({ model: currentModel, history: byoHistory, userTurn, tools, systemInstruction }), 45_000, "LLM generate()");
597
- if (round === 0) {
598
- thinkingSpinner.stop();
599
- process.stdout.write("\n" + chalk.hex("#6366f1")("\u2726 "));
600
- }
601
- if (result.text) {
602
- process.stdout.write(result.text);
603
- }
604
- byoHistory.push(userTurn);
605
- byoHistory.push({ role: "model", parts: result.rawParts || [{ text: result.text }] });
606
- if (!result.functionCalls || result.functionCalls.length === 0) {
607
- break;
608
- }
609
- let fnResponses = [];
610
- for (const fc of result.functionCalls) {
611
- const allow = await handleToolCall(fc.name, fc.args);
612
- if (!allow) {
613
- fnResponses.push({ functionResponse: { id: fc.id, name: fc.name, response: { error: "User denied execution." } } });
614
- continue;
615
- }
616
- const tool = tools.find((t) => t.name === fc.name);
617
- let out;
618
- try {
619
- // start_interview is an interactive long-running session — never apply a timeout to it.
620
- // Also temporarily remove the REPL's SIGINT handler so the interview's own
621
- // Ctrl+C handler can run cleanly (generate report) without racing against
622
- // the REPL's "Goodbye! 👋" / process.exit path.
623
- if (fc.name === "start_interview") {
624
- process.removeListener("SIGINT", handleSigInt);
625
- try {
626
- out = tool ? await tool.execute(fc.args) : { error: "Tool not found" };
627
- }
628
- finally {
629
- process.on("SIGINT", handleSigInt); // always restore, even on throw
630
- }
631
- }
632
- else {
633
- out = tool
634
- ? await withTimeout(tool.execute(fc.args), 45_000, `tool:${fc.name}`)
635
- : { error: "Tool not found" };
636
- }
637
- }
638
- catch (e) {
639
- if (e.message?.includes("No API key configured")) {
640
- out = { error: "CareerVivid API key not found. Run 'cv login' to authenticate." };
641
- }
642
- else {
643
- out = { error: e.message };
644
- }
645
- }
646
- handleToolResult(fc.name, out);
647
- fnResponses.push({ functionResponse: { id: fc.id, name: fc.name, response: out } });
648
- }
649
- userTurn = { role: "user", parts: fnResponses };
650
- round++;
651
- }
652
- // ── Clean turn separator after every AI reply ─────────────────────────────
653
- process.stdout.write("\n" + chalk.dim("─".repeat(48)) + "\n");
654
229
  }
655
230
  return ask();
656
231
  }
657
232
  catch (err) {
658
233
  const msg = err?.message ?? "";
659
- // ── Clean exit on Ctrl+C / enquirer cancel ────────────────────────
660
234
  if (!msg || msg.includes("cancelled") || msg.includes("User force closed")) {
661
235
  console.log(chalk.gray("\nCancelled. Exiting.\n"));
662
236
  process.exit(0);
663
237
  }
664
- // ── 401 unauthorized — offer key reset ───────────────────────────
665
- const is401 = msg.includes("401") || msg.toLowerCase().includes("user not found") ||
666
- msg.toLowerCase().includes("invalid api key") || msg.toLowerCase().includes("unauthorized");
238
+ const is401 = msg.includes("401") ||
239
+ msg.toLowerCase().includes("user not found") ||
240
+ msg.toLowerCase().includes("invalid api key") ||
241
+ msg.toLowerCase().includes("unauthorized");
667
242
  if (is401 && selectedProvider && selectedProvider !== "careervivid") {
668
- const providerLabels = {
669
- openai: "OpenAI", anthropic: "Anthropic",
670
- gemini: "Gemini", openrouter: "OpenRouter", custom: "Custom",
671
- };
672
- const providerKeyUrls = {
673
- openai: "https://platform.openai.com/api-keys",
674
- anthropic: "https://console.anthropic.com/settings/keys",
675
- gemini: "https://aistudio.google.com/app/apikey",
676
- openrouter: "https://openrouter.ai/settings/keys",
677
- };
678
- const label = providerLabels[selectedProvider] ?? selectedProvider;
679
- console.log();
680
- console.log(chalk.red(`❌ API key rejected by ${label} (401 Unauthorized).`));
681
- console.log(chalk.dim(` The saved key may be expired or invalid.`));
682
- if (providerKeyUrls[selectedProvider]) {
683
- console.log(chalk.dim(` Get a new key at: `) + chalk.cyan(providerKeyUrls[selectedProvider]));
684
- }
685
- console.log();
686
- try {
687
- const resetAnswer = await prompt({
688
- type: "select",
689
- name: "action",
690
- message: "What would you like to do?",
691
- choices: [
692
- { name: "reset", message: `🔑 Enter a new ${label} API key` },
693
- { name: "continue", message: "⏭️ Continue anyway (will keep failing)" },
694
- { name: "exit", message: "🚪 Exit the agent" },
695
- ],
696
- });
697
- if (resetAnswer.action === "reset") {
698
- const keyAnswer = await prompt({
699
- type: "password",
700
- name: "key",
701
- message: `Enter your new ${label} API key:`,
702
- });
703
- const newKey = (keyAnswer?.key ?? "").trim();
704
- if (newKey) {
705
- setProviderKey(selectedProvider, newKey);
706
- // Update the key used for subsequent turns this session
707
- options["api-key"] = newKey;
708
- console.log(chalk.green(`\n✔ New ${label} key saved. Resuming session...\n`));
709
- }
710
- }
711
- else if (resetAnswer.action === "exit") {
712
- console.log(chalk.gray("\nGoodbye! 👋\n"));
713
- process.exit(0);
714
- }
715
- }
716
- catch {
717
- // User cancelled the reset prompt — just continue
718
- }
243
+ await handle401Error(selectedProvider, options);
719
244
  return ask();
720
245
  }
721
- // ── Generic error ────────────────────────────────────────────────
722
246
  console.error(chalk.red(`\nAgent encountered an error: ${msg}`));
723
247
  return ask();
724
248
  }