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