minovative-mind-cli 1.4.0 → 1.4.2

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.
@@ -17,633 +17,24 @@
17
17
  */
18
18
  import * as p from '@clack/prompts';
19
19
  import pc from 'picocolors';
20
- import { promises as fs } from 'node:fs';
21
20
  import path from 'node:path';
22
- import { exec } from 'node:child_process';
23
- import { promisify } from 'node:util';
24
- import { debugLog, toggleDebugMode } from '../utils/logger.js';
25
- import { ensureProjectStorage, ensureIgnored, readCache, writeCache, invalidateCacheForDependents } from '../utils/projectStorage.js';
26
21
  import * as crypto from 'node:crypto';
27
- const execAsync = promisify(exec);
28
- /**
29
- * Asynchronous Input Handler (AsyncInputHandler)
30
- *
31
- * This class intercepts user keyboard input from standard input (stdin) while
32
- * background processes (e.g., Gemini model generations or long shell tool runs)
33
- * are executing. It provides seamless execution management by letting users:
34
- *
35
- * 1. **Pause execution** at any time by pressing a key.
36
- * 2. **Queue feedback** ("chained messages") without waiting for the entire run to finish.
37
- * 3. **Force abort** the current model or tool run by entering "stop".
38
- *
39
- * ### Raw Mode & Terminal States
40
- * In normal CLI execution, Node.js waits for a line feed (Enter) before emitting input.
41
- * To intercept immediate keystrokes, we put `process.stdin` into *raw mode*.
42
- * While in raw mode, we listen for direct data buffers.
43
- * To avoid visual conflicts with our logging output and Clack's CLI spinners,
44
- * we dynamically pause spinners, detach listeners, disable raw mode, open standard
45
- * interactive text-prompt forms, and resume raw mode and spinners upon completion.
46
- */
47
- export class AsyncInputHandler {
48
- /** Queue of pending user feedback/instructions typed during active background execution */
49
- queue = [];
50
- /** Guard flag preventing multiple simultaneous input prompt overlays */
51
- isPrompting = false;
52
- /** Reference to the Clack CLI spinner which must be paused/restarted during prompts */
53
- spinner = null;
54
- /** Stores the terminal's raw mode configuration state before handler activation */
55
- originalRawMode = false;
56
- /** Indicates whether the input handler is currently inactive/stopped */
57
- stopped = true;
58
- /** Reference to the AbortController controlling the active AI request to trigger cancellations */
59
- ac = null;
60
- /**
61
- * Registers the active AbortController for the current AI request.
62
- * This is triggered when the user commands a process cancel (e.g., typing "stop").
63
- *
64
- * @param ac - The AbortController controlling the current generation.
65
- */
66
- setAbortController(ac) {
67
- this.ac = ac;
68
- }
69
- /**
70
- * Determines if a user-prompt dialog is actively running.
71
- * Useful for coordinating other console logging output to avoid UI overlap.
72
- *
73
- * @returns True if a text prompt is currently displayed, false otherwise.
74
- */
75
- isCurrentlyPrompting() {
76
- return this.isPrompting;
77
- }
78
- /**
79
- * Blocks and waits until any active prompting action is completed.
80
- * Guarantees terminal stdout is clean before resuming logs.
81
- */
82
- async waitForPrompt() {
83
- while (this.isPrompting) {
84
- await new Promise((resolve) => setTimeout(resolve, 100));
85
- }
86
- }
87
- /**
88
- * Core stdin event listener. Detects pressed keys, pauses background visual elements,
89
- * handles exit interrupts, and opens a Clack text dialog for input.
90
- *
91
- * Handles recovery of the standard input stream and raw mode states even if
92
- * errors occur during prompt initialization.
93
- *
94
- * @param chunk - The raw terminal buffer containing keypress data.
95
- * @private
96
- */
97
- onData = async (chunk) => {
98
- try {
99
- if (this.isPrompting)
100
- return;
101
- const char = chunk.toString();
102
- // Handle Ctrl+C (End of Text ASCII 0x03) immediately
103
- if (char === '\u0003') {
104
- process.exit(0);
105
- }
106
- // Ignore standard non-printable control keys, escape sequences, etc.
107
- if (char.charCodeAt(0) < 32 || char === '\u007f')
108
- return;
109
- this.isPrompting = true;
110
- process.stdin.removeListener('data', this.onData);
111
- if (process.stdin.isTTY) {
112
- process.stdin.setRawMode(false);
113
- }
114
- // Hide the background spinner before prompt output to avoid corrupting terminal lines
115
- if (this.spinner) {
116
- this.spinner.stop();
117
- }
118
- p.log.step(pc.cyan('Paused to receive input'));
119
- // Capture the user feedback. The character typed to trigger this event is passed
120
- // as the initial value of the prompt to avoid losing the first keystroke.
121
- const userInput = await p.text({
122
- message: 'Add chained message:',
123
- placeholder: '(Leave blank and press Enter to cancel)',
124
- initialValue: char,
125
- });
126
- let wasAborted = false;
127
- if (!p.isCancel(userInput) && userInput.trim()) {
128
- const text = userInput.trim();
129
- if (text.toLowerCase() === 'stop') {
130
- if (this.ac) {
131
- this.ac.abort();
132
- p.log.warn(pc.yellow(`Generation aborted by user.`));
133
- wasAborted = true;
134
- }
135
- }
136
- else {
137
- this.queue.push(text);
138
- p.log.info(pc.cyan(`📥 Queued message: "${text}"`));
139
- }
140
- }
141
- if (this.spinner && !wasAborted) {
142
- const resumeMsg = this.spinner._lastMessage || 'Resuming execution...';
143
- this.spinner.start(resumeMsg);
144
- }
145
- }
146
- catch (err) {
147
- // Absorb and suppress errors safely during raw stream intercepts
148
- }
149
- finally {
150
- if (!this.stopped) {
151
- if (process.stdin.isTTY) {
152
- process.stdin.setRawMode(true);
153
- }
154
- process.stdin.resume();
155
- // Small delay before reattaching listener to avoid capturing duplicate keypress frames
156
- setTimeout(() => {
157
- if (!this.stopped) {
158
- process.stdin.on('data', this.onData);
159
- }
160
- this.isPrompting = false;
161
- }, 50);
162
- }
163
- else {
164
- this.isPrompting = false;
165
- }
166
- }
167
- };
168
- /**
169
- * Starts intercepting keystrokes and enables raw terminal processing.
170
- * Saves the original raw mode configuration to ensure a clean restoration later.
171
- *
172
- * @param spinner - The current active Clack spinner UI reference, if any.
173
- */
174
- start(spinner) {
175
- this.stopped = false;
176
- if (spinner) {
177
- this.spinner = spinner;
178
- // Monkey-patch to track the latest message for un-pausing
179
- if (!spinner._isPatched) {
180
- ;
181
- spinner._isPatched = true;
182
- spinner._lastMessage = 'Executing...';
183
- const originalMessage = spinner.message.bind(spinner);
184
- spinner.message = (msg) => {
185
- if (msg) {
186
- ;
187
- spinner._lastMessage = msg;
188
- }
189
- originalMessage(msg);
190
- };
191
- const originalStart = spinner.start.bind(spinner);
192
- spinner.start = (msg) => {
193
- if (msg) {
194
- ;
195
- spinner._lastMessage = msg;
196
- }
197
- originalStart(msg);
198
- };
199
- }
200
- }
201
- if (process.stdin.isTTY) {
202
- this.originalRawMode = process.stdin.isRaw;
203
- process.stdin.setRawMode(true);
204
- }
205
- process.stdin.resume();
206
- process.stdin.on('data', this.onData);
207
- }
208
- /**
209
- * Disables raw mode, stops intercepting keystrokes, and restores
210
- * the terminal stdin stream to its original raw/cooked state.
211
- */
212
- stop() {
213
- this.stopped = true;
214
- process.stdin.removeListener('data', this.onData);
215
- if (process.stdin.isTTY) {
216
- process.stdin.setRawMode(this.originalRawMode);
217
- }
218
- this.spinner = null;
219
- }
220
- /**
221
- * Retrieves and flushes all user feedback messages accumulated during execution.
222
- *
223
- * @returns A concatenated string of all queued user messages separated by newlines,
224
- * or an empty string if nothing was queued.
225
- */
226
- getAndClear() {
227
- if (this.queue.length === 0)
228
- return '';
229
- const messages = this.queue.join('\n');
230
- this.queue = [];
231
- return messages;
232
- }
233
- }
234
- import { consumeSkipOnce, executeTool, getApprovalMode, setApprovalMode } from './agent-tools.js';
235
- import { createSharedChatSession, getGeneralChatConfig, getPlanExecutionConfig, ProxyChatSession, compressTextUsingFlashLite, } from './ai.js';
22
+ import { marked } from 'marked';
23
+ import { markedTerminal } from 'marked-terminal';
24
+ import { debugLog } from '../utils/logger.js';
25
+ import { ensureProjectStorage, ensureIgnored, readCache, writeCache, invalidateCacheForDependents, } from '../utils/projectStorage.js';
26
+ import { createSharedChatSession, getGeneralChatConfig, getPlanExecutionConfig, compressTextUsingFlashLite, } from './ai.js';
236
27
  import { changeLogger } from './changeLogger.js';
237
28
  import { gatherContext, routeIntent } from './contextAgent.js';
238
- import { printLogo } from '../utils/logo.js';
239
29
  import { verifyChangedFiles } from './verificationService.js';
240
30
  import { buildContextInjection } from '../utils/contextPrompts.js';
241
- import { readPaste } from '../utils/paste.js';
242
- import { marked } from 'marked';
243
- import { markedTerminal } from 'marked-terminal';
31
+ // Submodule Imports
32
+ import { AsyncInputHandler } from './agent/inputHandler.js';
33
+ import { processResponse } from './agent/toolLoop.js';
34
+ import { handleSlashCommand } from './agent/slashCommands.js';
244
35
  marked.use(markedTerminal());
245
- // ─── Constants ───────────────────────────────────────────────────────
246
- /**
247
- * Mapping of tool identifiers to user-friendly terminal emojis.
248
- * Enhances the visual feedback during background tool execution turns.
249
- */
250
- const TOOL_ICONS = {
251
- read_file: '📖',
252
- write_file: '✏️',
253
- modify_file: '🔧',
254
- list_directory: '📂',
255
- run_command: '⚡',
256
- grep_search: '🔍',
257
- delete_file: '🗑️',
258
- rename_file: '🚚',
259
- find_dependencies: '🔗',
260
- run_analysis_script: '🔬',
261
- };
262
- /**
263
- * Human-readable translations for tool actions.
264
- * Used for constructing clear, active-verb descriptive log headers in the CLI.
265
- */
266
- const TOOL_LABELS = {
267
- read_file: 'Reading file',
268
- write_file: 'Writing file',
269
- modify_file: 'Modifying file',
270
- list_directory: 'Listing directory',
271
- run_command: 'Running command',
272
- grep_search: 'Searching code',
273
- delete_file: 'Deleting file',
274
- rename_file: 'Moving file',
275
- find_dependencies: 'Tracing dependencies',
276
- run_analysis_script: 'Analyzing code structure',
277
- };
278
- // ─── Helpers ─────────────────────────────────────────────────────────
279
- /**
280
- * Formats a tool execution call into a beautifully stylized console status line.
281
- * Extracts context-specific arguments to display relevant parameters in real-time.
282
- *
283
- * @param name - The identifier of the tool being called (e.g. 'read_file').
284
- * @param args - The dictionary of parameters supplied to the tool.
285
- * @returns A fully colorized and formatted ANSI console string.
286
- */
287
- function formatToolCall(name, args) {
288
- const icon = TOOL_ICONS[name] ?? '🔧';
289
- const label = TOOL_LABELS[name] ?? name;
290
- const formatPath = (path) => pc.cyan(path);
291
- const argsMap = {
292
- read_file: () => {
293
- if (args.startLine !== undefined || args.endLine !== undefined) {
294
- const start = args.startLine ?? 1;
295
- const end = args.endLine ?? 'end';
296
- return pc.dim(` (Lines ${start}-${end})`) + `: ${formatPath(String(args.filePath))}`;
297
- }
298
- if (Array.isArray(args.targetElements) && args.targetElements.length > 0) {
299
- return pc.dim(` (Elements: ${args.targetElements.join(', ')})`) + `: ${formatPath(String(args.filePath))}`;
300
- }
301
- return `: ${formatPath(String(args.filePath))}`;
302
- },
303
- write_file: () => `: ${formatPath(String(args.filePath))}`,
304
- modify_file: () => `: ${formatPath(String(args.filePath))}`,
305
- list_directory: () => `: ${formatPath(String(args.dirPath ?? '.'))}`,
306
- run_command: () => `: ${pc.yellow(String(args.command))}`,
307
- grep_search: () => `: ${pc.magenta(String(args.pattern))}`,
308
- delete_file: () => `: ${pc.red(String(args.filePath))}`,
309
- rename_file: () => `: ${formatPath(String(args.sourcePath))} -> ${formatPath(String(args.targetPath))}`,
310
- find_dependencies: () => `: ${formatPath(String(args.filePath))}${args.direction ? ` (${args.direction})` : ''}`,
311
- run_analysis_script: () => `: ${formatPath(String(args.targetFile ?? 'workspace'))}`,
312
- };
313
- const formatter = argsMap[name];
314
- const details = formatter ? formatter() : '';
315
- return `${icon} ${label}${details}`;
316
- }
317
- function isCommandDestructive(command) {
318
- const destructivePatterns = [
319
- // Unix deletion (catches rm -r, rm -rf, rm -fr, rm --recursive, rm --force, anywhere in args)
320
- /\brm\s+.*?(?:-[a-zA-Z]*[rfR]|--(?:recursive|force))\b/i,
321
- /\bmv\s+.*\/dev\/null\b/i, // Move to void
322
- // Windows deletion (catches rd /s, del /f /s)
323
- /\b(?:rd|rmdir)\s+.*\/[sq]\b/i,
324
- /\bdel\s+.*\/[fs]\b/i,
325
- // Remote script execution (catches curl/wget piped to any shell or runtime)
326
- /\b(?:curl|wget)\b.*?\|\s*(?:sh|bash|zsh|dash|python|node|ruby|perl)\b/i,
327
- // Git overwriting (catches hard resets, clean -f, force pushes, hard branch deletes)
328
- /\bgit\s+(?:reset|clean)\s+.*(?:--hard|-f|--force)\b/i,
329
- /\bgit\s+push\s+.*(?:-f|--force)\b/i,
330
- /\bgit\s+branch\s+.*-D\b/i,
331
- // DB & Cluster Drops (catches truncates, deletes, drop db/table, docker volume/system prunes, kubectl wipes)
332
- /\b(?:drop|truncate|delete)\s+(?:database|table|schema)\b/i,
333
- /\bdocker\s+system\s+prune\b/i,
334
- /\bdocker\s+volume\s+(?:rm|prune)\b/i,
335
- /\bkubectl\s+delete\s+(?:namespace|all|--all)\b/i,
336
- // Disk & System destruction (format, fdisk, dd, overwriting block devices, shutdown, fork bombs)
337
- /\b(?:mkfs|fdisk|parted|mkswap)\b/i,
338
- /\bformat\s+[A-Z]:/i,
339
- /\bdd\s+.*(?:if=|of=)\b/i,
340
- />\s*\/dev\/(?:sda|disk|hda|nvme)\b/i,
341
- /\b(?:shutdown|reboot|halt|poweroff)\b/i,
342
- /:\(\)\{\s*:\|:&\s*\};:/, // Fork bomb
343
- // User data/config erasure
344
- /\bcrontab\s+-r\b/i,
345
- /\bhistory\s+-c\b/i,
346
- // Massive permission shifts (chmod 777, recursive chowns)
347
- /\bchmod\s+(?:-[R\w]*\s+)?777\b/i,
348
- /\bchown\s+-[R\w]*\b/i,
349
- ];
350
- return destructivePatterns.some((pattern) => pattern.test(command));
351
- }
352
- function isCommandSafe(command) {
353
- // Do not auto-approve chained, redirected, or sudo commands
354
- if (/[;&|>]/.test(command) || /\bsudo\b/.test(command)) {
355
- return false;
356
- }
357
- const safePatterns = [
358
- /^\s*ls\b/i,
359
- /^\s*pwd\b/i,
360
- /^\s*whoami\b/i,
361
- /^\s*cat\b/i,
362
- /^\s*echo\b/i,
363
- /^\s*grep\b/i,
364
- /^\s*find\b/i,
365
- /^\s*npm\s+(install|i|ci|run\b)/i,
366
- /^\s*yarn\s+(install|add|build|lint|test|run\b)/i,
367
- /^\s*pnpm\s+(install|i|add|build|lint|test|run\b)/i,
368
- /^\s*bun\s+(install|i|add|run\b)/i,
369
- /^\s*cargo\s+(build|check|add|test|run\b)/i,
370
- /^\s*go\s+(mod|get|build|test|run\b)/i,
371
- /^\s*pip\s+(install|list|show)\b/i,
372
- /^\s*uv\s+(add|pip|sync|run\b)/i,
373
- /^\s*rustc\b/i,
374
- /^\s*git\s+(status|log|diff|show|branch)\b/i,
375
- /^\s*(node|python|ruby|java|go|rustc)\s+(--version|-v)\b/i,
376
- /^\s*tsc\b/i,
377
- /^\s*eslint\b/i,
378
- /^\s*prettier\b/i,
379
- ];
380
- return safePatterns.some((pattern) => pattern.test(command));
381
- }
382
- /**
383
- * Executes an interactive approval prompt via `@clack/prompts`.
384
- *
385
- * Supports pattern-based approvals and three user approval models:
386
- * 1. **Ask**: Prompts the user for every shell execution command (except implicitly safe ones).
387
- * 2. **Skip-Once**: Automatically grants permission to the current command, then resets.
388
- * 3. **Skip-All / Auto-Approve**: Grants permission to all future terminal commands.
389
- *
390
- * Destructive commands will ALWAYS prompt the user, regardless of mode.
391
- *
392
- * @param command - The terminal string requested for execution.
393
- * @returns A promise resolving to `true` if approved, or `false` if denied/cancelled.
394
- */
395
- async function requestCommandApproval(command) {
396
- const mode = getApprovalMode();
397
- const isDestructive = isCommandDestructive(command);
398
- const isSafe = isCommandSafe(command);
399
- // Force prompt for destructive commands
400
- if (isDestructive) {
401
- p.log.warn(`${pc.bgRed(pc.white(' WARNING '))} Destructive command detected. Explicit approval required.`);
402
- }
403
- else {
404
- if (mode === 'skip-all') {
405
- p.log.info(`${pc.dim('Auto-approved (skip-all):')} ${pc.yellow(command)}`);
406
- return true;
407
- }
408
- if (mode === 'skip-once') {
409
- p.log.info(`${pc.dim('Auto-approved (skip-once):')} ${pc.yellow(command)}`);
410
- consumeSkipOnce();
411
- return true;
412
- }
413
- if (isSafe) {
414
- p.log.info(`${pc.dim('Auto-approved (safe command):')} ${pc.yellow(command)}`);
415
- return true;
416
- }
417
- }
418
- // mode === 'ask' or isDestructive
419
- const result = await p.select({
420
- message: `Approve command: ${pc.yellow(command)}`,
421
- options: [
422
- { value: 'approve', label: 'Yes, run this command' },
423
- { value: 'skip-once', label: 'Yes, and skip approval for the next command too' },
424
- {
425
- value: 'skip-all',
426
- label: 'Yes, auto-approve all future commands (Note: this lasts until you restart the CLI)',
427
- },
428
- { value: 'deny', label: 'No, deny this command' },
429
- ],
430
- });
431
- if (p.isCancel(result) || result === 'deny') {
432
- return false;
433
- }
434
- if (result === 'skip-once') {
435
- setApprovalMode('skip-once');
436
- }
437
- else if (result === 'skip-all') {
438
- setApprovalMode('skip-all');
439
- }
440
- return true;
441
- }
442
- // ─── Agent Loop ──────────────────────────────────────────────────────
443
- /**
444
- * Processes a single model response that may contain tool calls.
445
- * Handles the full tool-call loop: execute → feed results → repeat
446
- * until the model produces a final text response.
447
- *
448
- * @param chat - The current active proxy chat session.
449
- * @param result - The current result returned from sending a message to the model.
450
- * @param workspaceRoot - The absolute/relative path to the workspace root.
451
- * @param inputHandler - The input handler to check for queued interrupts.
452
- * @param agentState - State tracker for whether the target agent configuration is CHAT or EXECUTE.
453
- * @param abortSignal - Signal to detect when the operation has been cancelled.
454
- * @returns A promise that resolves to the final text response.
455
- */
456
- /**
457
- * Coordinates and executes the recursive model-response tool-execution loop.
458
- *
459
- * ### Execution Mechanism:
460
- * This function processes the initial response from the generative model. If the model determines
461
- * it needs to execute tools (e.g. read_file, run_command, modify_file) to answer or fulfill the request:
462
- *
463
- * 1. **Analyze Tool Requirements**: Parses requested `functionCalls` from the LLM.
464
- * 2. **Check for User Abortion**: Periodically examines the `AbortSignal` to stop execution if requested.
465
- * 3. **Manage Safety Approvals**: For high-risk operations (like `run_command`), requests explicit confirmation from the user (or uses auto-approve configurations).
466
- * 4. **Execute Operations**: Runs requested tool actions via `executeTool` in parallel or series as requested.
467
- * 5. **Handle Interrupts**: Integrates background user inputs queued in `inputHandler` as new context prompts back into the active LLM context.
468
- * 6. **Submit Loop Frame**: Feeds execution outcomes back to the Gemini session and recursively repeats this sequence until the LLM produces a final text answer without further tool requests.
469
- *
470
- * ### Limits & Recovery Guardrails:
471
- * - **Turn-Limiting**: Capped at `MAX_TURNS` (25) to prevent infinite loops, API token drain, or excessive billing if the AI gets stuck in a repetitive loop.
472
- * - **Empty-Response Healing**: If the API returns an empty text response with no tools, the system initiates up to `MAX_EMPTY_RETRIES` (3) system-driven wake-up prompts to re-engage the model.
473
- *
474
- * @param chat - The current active proxy chat session.
475
- * @param result - The current result returned from sending a message to the model.
476
- * @param workspaceRoot - The absolute/relative path to the workspace root.
477
- * @param inputHandler - The input handler to check for queued interrupts.
478
- * @param agentState - State tracker for whether the target agent configuration is CHAT or EXECUTE.
479
- * @param abortSignal - Signal to detect when the operation has been cancelled.
480
- * @returns A promise that resolves to the final text response.
481
- */
482
- async function processResponse(chat, result, workspaceRoot, inputHandler, agentState, abortSignal) {
483
- let response = result.response;
484
- // Upper limit on autonomous sequential tool executions to prevent out-of-control loops
485
- let turnCount = 0;
486
- const MAX_TURNS = 50;
487
- // Recovery thresholds for handling unexpected empty API payloads
488
- let emptyRetryCount = 0;
489
- const MAX_EMPTY_RETRIES = 3;
490
- // Loop while the model keeps requesting tool calls
491
- while (true) {
492
- turnCount++;
493
- if (turnCount > MAX_TURNS) {
494
- p.log.error(`${pc.red('System Error:')} Agent exceeded maximum autonomous turns (${MAX_TURNS}). Force stopping to prevent infinite loop.`);
495
- try {
496
- p.log.info(pc.cyan(`Requesting final summary from AI based on gathered information...`));
497
- const originalTools = chat.tools || [];
498
- const originalInstruction = chat.systemInstruction || '';
499
- // Temporarily clear tools to force a pure text response
500
- chat.setAgentConfig(originalInstruction, []);
501
- const finalFollowUp = await chat.sendMessage(`[SYSTEM INTERRUPTION] You have exceeded the maximum number of allowed tool operations (${MAX_TURNS} turns). You must now provide a final answer to the user based ONLY on the information you have gathered so far. Do NOT attempt to call any more tools, just answer the user directly.`, undefined, abortSignal);
502
- // Restore tools
503
- chat.setAgentConfig(originalInstruction, originalTools);
504
- const finalOutput = finalFollowUp.response.text();
505
- if (finalOutput && finalOutput.trim()) {
506
- return finalOutput;
507
- }
508
- return '';
509
- }
510
- catch (e) {
511
- if (e.name === 'AbortError' || e.message?.includes('abort')) {
512
- p.log.warn(pc.yellow('Generation stopped by user.'));
513
- return '[Generation stopped by user]';
514
- }
515
- return '';
516
- }
517
- }
518
- const functionCalls = response.functionCalls();
519
- if (!functionCalls || functionCalls.length === 0) {
520
- // No more tool calls — return the final text response
521
- let finalOutput = response.text() ?? '';
522
- if (!finalOutput.trim()) {
523
- if (emptyRetryCount < MAX_EMPTY_RETRIES) {
524
- emptyRetryCount++;
525
- p.log.warn(pc.yellow(`AI returned an empty response. Attempting to recover (retry ${emptyRetryCount}/${MAX_EMPTY_RETRIES})...`));
526
- // System-driven injection to kickstart the LLM's conversation generation
527
- const wakeUpMessage = 'SYSTEM: You just returned a completely empty response with no tool calls. If you are stuck, please use your tools to explore the workspace, or explain what you are trying to do and ask the user for clarification. Do not return empty responses.';
528
- try {
529
- const retry = await chat.sendMessage(wakeUpMessage, undefined, abortSignal);
530
- response = retry.response;
531
- continue;
532
- }
533
- catch (e) {
534
- if (e.name === 'AbortError' || e.message?.includes('abort')) {
535
- p.log.warn(pc.yellow('Generation stopped by user during recovery.'));
536
- return '[Generation stopped by user]';
537
- }
538
- finalOutput = '[System Error: The AI returned an empty response and failed to recover.]';
539
- }
540
- }
541
- else {
542
- finalOutput =
543
- '[The AI repeatedly returned empty responses and could not recover. This usually means it hit an API filter/limit or is stuck. Try rewording your prompt.]';
544
- }
545
- }
546
- return finalOutput;
547
- }
548
- // Process each tool call requested by the model
549
- const toolResponses = [];
550
- for (const fc of functionCalls) {
551
- await inputHandler.waitForPrompt();
552
- if (abortSignal.aborted) {
553
- p.log.warn(pc.yellow('Execution aborted by user.'));
554
- return '[Generation stopped by user]';
555
- }
556
- const toolName = fc.name;
557
- const toolArgs = (fc.args ?? {});
558
- // Log the ongoing tool action to the console
559
- p.log.step(formatToolCall(toolName, toolArgs));
560
- // If executing a CLI/shell command, wait for approval
561
- if (toolName === 'run_command') {
562
- await inputHandler.waitForPrompt();
563
- inputHandler.stop(); // Temporarily release raw-mode during blocking interactive prompt
564
- const approved = await requestCommandApproval(toolArgs.command);
565
- inputHandler.start(); // Re-engage raw-mode for continuous background monitoring
566
- if (!approved) {
567
- toolResponses.push({
568
- functionResponse: {
569
- name: toolName,
570
- response: {
571
- output: '',
572
- error: 'Command was denied by the user. Do not retry this command. Ask the user how they would like to proceed.',
573
- },
574
- },
575
- });
576
- continue;
577
- }
578
- }
579
- // Execute the underlying filesystem, shell or search tool logic
580
- const toolResult = await executeTool(workspaceRoot, toolName, toolArgs, abortSignal);
581
- await inputHandler.waitForPrompt();
582
- if (toolResult.error) {
583
- debugLog(`Raw Tool Error for ${toolName}: ${toolResult.error}`);
584
- // Do not spam the user with expected chunking warnings
585
- if (!toolResult.error.includes('File is too large')) {
586
- // Truncate the error message for the terminal UI to prevent console clutter
587
- // (e.g. hiding the large file previews sent to the AI)
588
- const displayError = toolResult.error.split('\n')[0].substring(0, 100);
589
- p.log.warn(`${pc.red('Tool error:')} [${displayError}]`);
590
- }
591
- }
592
- toolResponses.push({
593
- functionResponse: {
594
- name: toolName,
595
- response: {
596
- output: toolResult.output,
597
- ...(toolResult.error ? { error: toolResult.error } : {}),
598
- },
599
- },
600
- });
601
- }
602
- // Check if the user entered any feedback or interrupt commands during the tool execution cycle
603
- await inputHandler.waitForPrompt();
604
- const queuedMsg = inputHandler.getAndClear();
605
- let additionalText = undefined;
606
- if (queuedMsg) {
607
- additionalText = `[USER INTERRUPTION] The user sent the following message during your execution:\n"${queuedMsg}"\n\nPlease incorporate this feedback into your ongoing work. Address the user's message, but DO NOT lose track of your original overall plan or focus. After addressing this interruption, continue with your broader objective.`;
608
- p.log.info(pc.cyan(`Sending queued message to AI...`));
609
- // Dynamically upgrade agent permissions/intent to EXECUTE mode if the interrupted instruction requires system modifications
610
- const newIntent = await routeIntent(queuedMsg);
611
- if (agentState.targetAgent === 'CHAT') {
612
- if (newIntent.targetAgent === 'EXECUTE' || newIntent.needsContext) {
613
- agentState.targetAgent = 'EXECUTE';
614
- const config = getPlanExecutionConfig();
615
- chat.setAgentConfig(config.systemInstruction, config.tools);
616
- p.log.info(pc.yellow(`Upgraded session intent to EXECUTE based on chained message.`));
617
- }
618
- }
619
- }
620
- // Feed tool results and potential interruption text back to the model
621
- let followUp;
622
- try {
623
- followUp = await chat.sendMessage(toolResponses, additionalText, abortSignal);
624
- }
625
- catch (e) {
626
- if (e.name === 'AbortError' || e.message?.includes('abort')) {
627
- p.log.warn(pc.yellow('Generation stopped by user.'));
628
- return '[Generation stopped by user]';
629
- }
630
- throw e;
631
- }
632
- await inputHandler.waitForPrompt();
633
- const grounding = followUp.response.groundingMetadata?.();
634
- if (grounding?.webSearchQueries && grounding.webSearchQueries.length > 0) {
635
- p.log.info(`${pc.blue('🌐')} ${pc.dim('Google Search Queries:')} ${grounding.webSearchQueries.map((q) => pc.cyan(`"${q}"`)).join(', ')}`);
636
- }
637
- response = followUp.response;
638
- }
639
- }
640
- /**
641
- * Starts the interactive agent chat loop.
642
- * Runs until the user types "exit", "quit", or presses Ctrl+C.
643
- *
644
- * @param workspaceRoot - The root directory of the workspace.
645
- * @param version - The active version of the CLI utility.
646
- */
36
+ // Export submodules for potential external uses if required
37
+ export { AsyncInputHandler } from './agent/inputHandler.js';
647
38
  /**
648
39
  * Starts and orchestrates the primary interactive command-line interface (REPL) loop.
649
40
  *
@@ -659,13 +50,7 @@ async function processResponse(chat, result, workspaceRoot, inputHandler, agentS
659
50
  * - Intercepts and filters OS clipboard paste buffers to allow inserting massive scripts cleanly.
660
51
  *
661
52
  * 2. **Slash Commands Processing**:
662
- * - `/paste`: Interactively captures large copy-pasted blocks using EOF tracking.
663
- * - `/clear`: Hard-clears active terminal histories and resets session state.
664
- * - `/models`: Dynamic runtime model hot-swapping (e.g., swapping between Flash and Pro variants).
665
- * - `/debug`: Toggles active runtime execution telemetry logging.
666
- * - `/auto-approve`: Grants blanket terminal script permissions to bypass prompt approval blocks.
667
- * - `/revert`: Pops the most recent change-set log and rolls back mutated files to original states.
668
- * - `/commit`: Performs git diff staging, executes model summaries to write micro-commits, and executes local git commits.
53
+ * - Delegated to `handleSlashCommand` within `src/services/agent/slashCommands.ts`.
669
54
  *
670
55
  * 3. **Workspace Context Gathering & Intention Routing**:
671
56
  * - Leverages the Context Agent (`gatherContext`) to perform exploratory scans of the active repository.
@@ -709,55 +94,19 @@ export async function startAgentLoop(workspaceRoot, version) {
709
94
  console.log(pc.dim('\nType your coding request below. Type "exit" or "quit" to leave.\n'));
710
95
  while (true) {
711
96
  inputHandler.stop();
712
- let lines = [];
713
- let isMultiLine = false;
714
- let canceledGlobal = false;
715
- // Input collection sub-loop (handles multiline line-by-line gathering)
716
- while (true) {
717
- const userInputRaw = await p.text({
718
- message: isMultiLine ? ' ' : pc.magenta('❯'),
719
- placeholder: isMultiLine ? '' : 'Use "\\" for new lines',
720
- });
721
- if (p.isCancel(userInputRaw)) {
722
- if (isMultiLine) {
723
- p.log.warn(pc.yellow('Multi-line input canceled.'));
724
- lines = [];
725
- isMultiLine = false;
726
- continue;
727
- }
728
- else {
729
- canceledGlobal = true;
730
- break;
731
- }
732
- }
733
- let line = (userInputRaw || '');
734
- // Require a space before the backslash to prevent Windows directory paths (e.g., C:\foo\) from triggering multiline
735
- if (line.trimEnd().endsWith(' \\')) {
736
- const cleanLine = line.trimEnd().slice(0, -2);
737
- lines.push(cleanLine);
738
- isMultiLine = true;
739
- // Clack's text prompt dims the submitted value, which can be completely invisible on some terminal themes.
740
- // We explicitly log the line here so the user can see what they are typing in multiline mode.
741
- p.log.step(pc.cyan(cleanLine));
742
- }
743
- else {
744
- lines.push(line);
745
- // Unconditionally log the user's message so it is always visible
746
- p.log.step(pc.cyan(line));
747
- break;
748
- }
749
- }
750
- if (canceledGlobal) {
97
+ // Input collection (delegated to a helper function to avoid nested loop warning)
98
+ const { userInput: rawInput, canceled } = await collectUserInput();
99
+ if (canceled) {
751
100
  p.outro(pc.green('Goodbye! Happy coding. 🚀'));
752
101
  return;
753
102
  }
754
- let userInput = lines.join('\n').trim();
103
+ let userInput = rawInput;
755
104
  if (!userInput) {
756
105
  continue;
757
106
  }
758
107
  // Capture standalone forward slash triggers to open the selection console
759
108
  if (userInput === '/') {
760
- const commandMenu = await p.select({
109
+ const commandMenu = await p['select']({
761
110
  message: 'Command Menu',
762
111
  options: [
763
112
  { value: '/models', label: '/models', hint: 'Change the active AI model' },
@@ -787,183 +136,24 @@ export async function startAgentLoop(workspaceRoot, version) {
787
136
  debugLog(`Executing slash command: ${userInput}`);
788
137
  }
789
138
  // ─── Slash Commands Handling ─────────────────────────────────────
790
- if (userInput.toLowerCase() === '/paste') {
791
- p.log.info(pc.cyan('Paste mode activated. Paste your text below, then press Ctrl+D on an empty line to submit. (Ctrl+C to cancel)'));
792
- try {
793
- isRawPasteMode = true;
794
- const content = await readPaste();
795
- isRawPasteMode = false;
796
- if (!content) {
797
- p.log.warn('Paste mode closed with no content.');
798
- continue;
799
- }
800
- userInput = content;
801
- p.log.step(pc.cyan(`Loaded ${content.length} characters from paste.`));
802
- }
803
- catch (err) {
804
- isRawPasteMode = false;
805
- p.log.error(`Paste failed: ${err instanceof Error ? err.message : String(err)}`);
806
- continue;
807
- }
808
- }
809
- else if (userInput.toLowerCase() === '/clear') {
810
- chat.clearHistory();
811
- process.stdout.write('\x1B[2J\x1B[3J\x1B[H'); // Hard clear screen and scrollback
812
- printLogo();
813
- p.intro(`${pc.bgCyan(pc.black(' Minovative Mind CLI '))} ${pc.dim('v' + version)}`);
814
- p.log.info(`${pc.dim('Workspace:')} ${pc.cyan(workspaceRoot)}`);
815
- p.log.info(`${pc.dim('Commands:')} Type ${pc.yellow('/')} to open the command menu and "${pc.yellow('stop')}" to stop the ai generation. Type ${pc.yellow('exit')} to leave.`);
816
- p.log.success('Chat history cleared.');
817
- console.log(pc.dim('\nType your coding request below. Type "exit" or "quit" to leave.\n'));
818
- continue;
819
- }
820
- if (userInput.toLowerCase() === '/models') {
821
- const currentModel = chat.getModel();
822
- const selectedModel = await p.select({
823
- message: `Select AI Model (Current: ${pc.cyan(currentModel)})`,
824
- initialValue: currentModel,
825
- options: [
826
- {
827
- value: 'gemini-3.1-pro-preview',
828
- label: 'Gemini 3.1 Pro',
829
- hint: 'The newest Pro model for complex logic',
830
- },
831
- {
832
- value: 'gemini-3.5-flash',
833
- label: 'Gemini 3.5 Flash',
834
- hint: 'Balanced performance',
835
- },
836
- {
837
- value: 'gemini-3.1-flash-lite',
838
- label: 'Gemini 3.1 Flash-Lite',
839
- hint: 'Ultra-fast and cost-effective',
840
- },
841
- ],
842
- });
843
- if (!p.isCancel(selectedModel)) {
844
- chat.setModel(selectedModel);
845
- p.log.success(`Model successfully switched to ${pc.cyan(selectedModel)}`);
846
- }
847
- continue;
848
- }
849
- if (userInput.toLowerCase() === '/debug') {
850
- const debugMode = toggleDebugMode();
851
- if (debugMode) {
852
- p.log.info('Debug mode enabled. Internal logs will now be shown.');
853
- }
854
- else {
855
- p.log.info('Debug mode disabled.');
856
- }
857
- continue;
858
- }
859
- if (userInput.toLowerCase() === '/auto-approve') {
860
- setApprovalMode('skip-all');
861
- p.log.success('Auto-approve enabled for all future commands in this session.');
862
- continue;
863
- }
864
- if (userInput.toLowerCase() === '/revert') {
865
- const history = changeLogger.getHistory();
866
- if (!history || history.length === 0) {
867
- p.log.warn('No changes to revert.');
868
- continue;
869
- }
870
- const lastChangeSet = history[history.length - 1];
871
- const truncate = (str, max) => (str.length > max ? str.substring(0, max - 3) + '...' : str);
872
- const revertMenu = await p.select({
873
- message: 'Revert Menu',
874
- options: [
875
- { value: 'revert_last', label: `Revert last change (${truncate(lastChangeSet.description, 50)})` },
876
- { value: 'view_history', label: 'View history' },
877
- { value: 'cancel', label: 'Cancel' },
878
- ],
879
- });
880
- if (p.isCancel(revertMenu) || revertMenu === 'cancel') {
139
+ if (userInput.startsWith('/')) {
140
+ const slashCtx = {
141
+ chat,
142
+ inputHandler,
143
+ workspaceRoot,
144
+ version,
145
+ isRawPasteMode,
146
+ };
147
+ const slashResult = await handleSlashCommand(userInput, slashCtx);
148
+ if (slashResult.isRawPasteMode !== undefined) {
149
+ isRawPasteMode = slashResult.isRawPasteMode;
150
+ }
151
+ if (slashResult.userInputOverride !== undefined) {
152
+ userInput = slashResult.userInputOverride;
153
+ }
154
+ else if (slashResult.shouldContinue) {
881
155
  continue;
882
156
  }
883
- let targetTimestamp = lastChangeSet.timestamp;
884
- if (revertMenu === 'view_history') {
885
- // Reverse history so newest is at the top
886
- const historyOptions = history
887
- .slice()
888
- .reverse()
889
- .map((cs, i) => ({
890
- value: cs.timestamp,
891
- label: `[${i === 0 ? 'Latest' : `-${i}`}] ${truncate(cs.description, 50)} (${new Date(cs.timestamp).toLocaleTimeString()})`,
892
- hint: `Reverts this and all ${i} changes after it`,
893
- }));
894
- const selectedHistory = await p.select({
895
- message: 'Select the point in history to revert back to:',
896
- options: [...historyOptions, { value: -1, label: 'Cancel' }],
897
- });
898
- if (p.isCancel(selectedHistory) || selectedHistory === -1) {
899
- continue;
900
- }
901
- targetTimestamp = selectedHistory;
902
- }
903
- const spinner = p.spinner();
904
- spinner.start('Reverting selected changes...');
905
- try {
906
- const changesToRevert = changeLogger.popUntil(targetTimestamp);
907
- // changesToRevert is ordered from newest to oldest. We must apply them sequentially.
908
- for (const changeSet of changesToRevert) {
909
- for (const change of changeSet.changes) {
910
- const absPath = path.resolve(workspaceRoot, change.filePath);
911
- if (change.action === 'create') {
912
- await fs.rm(absPath, { force: true });
913
- }
914
- else if (change.originalContent !== null) {
915
- await fs.writeFile(absPath, change.originalContent, 'utf-8');
916
- }
917
- }
918
- }
919
- spinner.stop('Reverted successfully.');
920
- p.log.success(`Reverted ${changesToRevert.length} changeset(s) successfully.`);
921
- }
922
- catch (err) {
923
- spinner.stop('Revert failed.');
924
- p.log.error(`Failed to revert: ${err instanceof Error ? err.message : String(err)}`);
925
- }
926
- continue;
927
- }
928
- if (userInput.toLowerCase() === '/commit') {
929
- const commitSpinner = p.spinner();
930
- commitSpinner.start('Staging changes and analyzing diff...');
931
- try {
932
- await execAsync('git add .', { cwd: workspaceRoot });
933
- const { stdout: diffOut } = await execAsync('git diff --cached', { cwd: workspaceRoot });
934
- if (!diffOut || diffOut.trim().length === 0) {
935
- commitSpinner.stop('No changes to commit.');
936
- p.log.warn('Git working tree is clean.');
937
- continue;
938
- }
939
- commitSpinner.message('Generating commit message...');
940
- const prompt = `Generate a concise, standard git commit message for the following diff. Only return the commit message text.
941
-
942
- <diff>
943
- ${diffOut}
944
- </diff>`;
945
- // Use flash-lite for extreme speed and low cost for this simple task
946
- const commitSystemPrompt = 'You are an expert developer. Output only the git commit message, no markdown formatting, no explanations. Follow Conventional Commits format (feat:, fix:, chore:, refactor:, etc.) for the first line, keeping it under 70 characters and using the imperative mood. Then, add a blank line followed by a more descriptive bulleted list explaining the "what" and "why" of the changes based on the diff.';
947
- const commitAgent = new ProxyChatSession('gemini-3.1-flash-lite', commitSystemPrompt, [], {});
948
- const commitResult = await commitAgent.sendMessage(prompt);
949
- const commitMsg = commitResult.response.text().trim();
950
- commitSpinner.message('Committing...');
951
- const tmpMsgPath = path.join(workspaceRoot, '.gemini-commit-msg.tmp');
952
- await fs.writeFile(tmpMsgPath, commitMsg, 'utf-8');
953
- try {
954
- await execAsync(`git commit -F .gemini-commit-msg.tmp`, { cwd: workspaceRoot });
955
- }
956
- finally {
957
- await fs.rm(tmpMsgPath, { force: true });
958
- }
959
- commitSpinner.stop('Committed successfully.');
960
- p.log.success(`Committed with message:\n\n${pc.dim(commitMsg)}`);
961
- }
962
- catch (err) {
963
- commitSpinner.stop('Commit failed.');
964
- p.log.error(`Failed to commit: ${err instanceof Error ? err.message : String(err)}`);
965
- }
966
- continue;
967
157
  }
968
158
  // ─── Main Execution Cycle ────────────────────────────────────────
969
159
  const turnStartTime = Date.now();
@@ -1018,43 +208,8 @@ ${diffOut}
1018
208
  let dynamicSystemInstruction = config.systemInstruction;
1019
209
  // Inject gathered workspace directories, dependency configurations, and matching search patterns
1020
210
  if (gatherRes.contextResult) {
1021
- const cachedContext = readCache(workspaceRoot, 'context_cache.json') || {};
1022
- let cacheUpdated = false;
1023
- // Compress each relevant file individually to allow granular cache hits
1024
- const compressedFiles = new Map();
1025
- for (const [filePath, content] of gatherRes.contextResult.relevantFiles.entries()) {
1026
- if (content.length < 2000) {
1027
- debugLog(`Bypassing cache and compression for ${filePath} (length ${content.length} < 2000)`);
1028
- compressedFiles.set(filePath, content);
1029
- continue;
1030
- }
1031
- const fileHash = crypto.createHash('sha256').update(filePath + content).digest('hex');
1032
- if (cachedContext[fileHash]) {
1033
- debugLog(`Context cache HIT for file ${filePath} (hash ${fileHash.substring(0, 8)})`);
1034
- compressedFiles.set(filePath, cachedContext[fileHash]);
1035
- }
1036
- else {
1037
- debugLog(`Context cache MISS for file ${filePath} (hash ${fileHash.substring(0, 8)})`);
1038
- const compressPrompt = `Summarize the following file contents concisely. Preserve all exports, functions, classes, variables, and architectural purpose. Keep it under 2000 characters if possible. File: ${filePath}`;
1039
- const summary = await compressTextUsingFlashLite(content, compressPrompt);
1040
- compressedFiles.set(filePath, summary);
1041
- cachedContext[fileHash] = summary;
1042
- cacheUpdated = true;
1043
- }
1044
- }
1045
- if (cacheUpdated) {
1046
- // Keep cache size manageable by restricting to recent 100 file entries
1047
- const keys = Object.keys(cachedContext);
1048
- if (keys.length > 100) {
1049
- const toDelete = keys.length - 100;
1050
- for (let i = 0; i < toDelete; i++) {
1051
- delete cachedContext[keys[i]];
1052
- }
1053
- }
1054
- writeCache(workspaceRoot, 'context_cache.json', cachedContext);
1055
- }
1056
- // Replace raw file contents with their compressed summaries
1057
- gatherRes.contextResult.relevantFiles = compressedFiles;
211
+ // Compress each relevant file individually using helper to avoid nested loop warning
212
+ gatherRes.contextResult.relevantFiles = await compressContextFiles(workspaceRoot, gatherRes.contextResult);
1058
213
  // Assemble the final context injection string
1059
214
  const contextInjection = buildContextInjection(gatherRes.contextResult);
1060
215
  debugLog(`Final compressed context injection size: ${contextInjection.length} chars`);
@@ -1086,76 +241,11 @@ ${diffOut}
1086
241
  p.log.info(`${pc.blue('🌐')} ${pc.dim('Google Search Queries:')} ${grounding.webSearchQueries.map((q) => pc.cyan(`"${q}"`)).join(', ')}`);
1087
242
  }
1088
243
  spinner.stop('');
1089
- let finalText = '';
1090
- const MAX_CORRECTIONS = 3;
1091
- let correctionAttempts = 0;
1092
244
  const calls = result.response.functionCalls();
1093
245
  debugLog(`Initial functionCalls: ${calls && calls.length > 0 ? JSON.stringify(calls) : 'None'}`);
1094
- let agentState = { targetAgent: effectiveTargetAgent };
1095
- let previousChangeCount = changeLogger.getCurrentChangeSet()?.changes.length || 0;
1096
- // Stage 2: Recursive Tool Loops and Automated Self-Correction
1097
- while (correctionAttempts <= MAX_CORRECTIONS) {
1098
- if (finalText === '[Generation stopped by user]')
1099
- break;
1100
- finalText = await processResponse(chat, result, workspaceRoot, inputHandler, agentState, ac.signal);
1101
- debugLog(`processResponse returned finalText (length ${finalText.length}): "${finalText.substring(0, 10)}..."`);
1102
- if (finalText === '[Generation stopped by user]') {
1103
- break;
1104
- }
1105
- if (finalText.startsWith('[The AI repeatedly returned empty responses')) {
1106
- break;
1107
- }
1108
- const currentChanges = changeLogger.getCurrentChangeSet()?.changes || [];
1109
- // If the correction cycle is active but no new changes were registered, the model gave up
1110
- if (correctionAttempts > 0 && currentChanges.length <= previousChangeCount) {
1111
- if (correctionAttempts >= MAX_CORRECTIONS) {
1112
- p.log.warn('Max self-correction attempts reached. Leaving remaining errors for manual review.');
1113
- break;
1114
- }
1115
- debugLog('AI failed to modify any files during the correction attempt. Retrying...');
1116
- const forcePrompt = `AUTOMATED SYSTEM CHECK: You did not modify any files. You MUST use your file modification tools to apply a fix for the previously mentioned errors. Do not just explain the issue.`;
1117
- spinner.start('Thinking (Correction)...');
1118
- result = await chat.sendMessage(forcePrompt);
1119
- spinner.stop('');
1120
- correctionAttempts++;
1121
- continue;
1122
- }
1123
- previousChangeCount = currentChanges.length;
1124
- const changedFiles = currentChanges
1125
- .filter((c) => c.action === 'create' || c.action === 'modify')
1126
- .map((c) => c.filePath);
1127
- if (changedFiles.length === 0)
1128
- break;
1129
- // Stage 3: Static code analysis / verification
1130
- p.log.step('Verifying modified files...');
1131
- const verificationResult = await verifyChangedFiles(workspaceRoot, changedFiles, ac.signal);
1132
- if (verificationResult === '[Verification Aborted]') {
1133
- p.log.warn(pc.yellow('Verification aborted by user.'));
1134
- break;
1135
- }
1136
- // Print non-blocking performance warnings to the terminal
1137
- if (verificationResult.warnings) {
1138
- p.log.warn('Performance Audit Warnings:\n' + verificationResult.warnings);
1139
- }
1140
- if (!verificationResult.errors) {
1141
- p.log.success('Verification passed.');
1142
- break;
1143
- }
1144
- correctionAttempts++;
1145
- if (correctionAttempts > MAX_CORRECTIONS) {
1146
- p.log.warn('Max self-correction attempts reached. Leaving remaining errors for manual review.');
1147
- break;
1148
- }
1149
- p.log.warn(`${pc.yellow('Verification failed. Auto-correcting errors')} (Attempt ${correctionAttempts}/${MAX_CORRECTIONS})...`);
1150
- const displayError = verificationResult.errors.split('\n').slice(0, 5).join('\n');
1151
- console.log(pc.dim(` ${displayError.replace(/\n/g, '\n ')}\n ...`));
1152
- debugLog(`Verification failed on attempt ${correctionAttempts}/${MAX_CORRECTIONS}. Errors:\n${verificationResult.errors}`);
1153
- // Compile compilation and syntax diagnostic warnings into an auto-correction prompt
1154
- const correctionPrompt = `AUTOMATED SYSTEM CHECK: Your previous changes resulted in the following errors:\n\n${verificationResult.errors}\n\nPlease analyze these errors and use your file modification tools to fix them.`;
1155
- spinner.start('Thinking (Correction)...');
1156
- result = await chat.sendMessage(correctionPrompt);
1157
- spinner.stop('');
1158
- }
246
+ // Stage 2: Recursive Tool Loops and Automated Self-Correction (delegated to a helper function to avoid nested loop warning)
247
+ const correctionRes = await executeSelfCorrectionLoop(chat, result, workspaceRoot, inputHandler, effectiveTargetAgent, ac.signal, spinner);
248
+ const finalText = correctionRes.finalText;
1159
249
  const postExecutionChanges = changeLogger.getCurrentChangeSet()?.changes || [];
1160
250
  const modifiedFiles = postExecutionChanges
1161
251
  .filter((c) => c.action === 'modify' || c.action === 'create' || c.action === 'delete')
@@ -1189,3 +279,159 @@ ${diffOut}
1189
279
  }
1190
280
  }
1191
281
  }
282
+ async function collectUserInput() {
283
+ let lines = [];
284
+ let isMultiLine = false;
285
+ while (true) {
286
+ const userInputRaw = await p.text({
287
+ message: isMultiLine ? ' ' : pc.magenta('❯'),
288
+ placeholder: isMultiLine ? '' : 'Use "\\" for new lines',
289
+ });
290
+ if (p.isCancel(userInputRaw)) {
291
+ if (isMultiLine) {
292
+ p.log.warn(pc.yellow('Multi-line input canceled.'));
293
+ lines = [];
294
+ isMultiLine = false;
295
+ continue;
296
+ }
297
+ else {
298
+ return { userInput: '', canceled: true };
299
+ }
300
+ }
301
+ let line = (userInputRaw || '');
302
+ // Require a space before the backslash to prevent Windows directory paths (e.g., C:\foo\) from triggering multiline
303
+ if (line.trimEnd().endsWith(' \\')) {
304
+ const cleanLine = line.trimEnd().slice(0, -2);
305
+ lines.push(cleanLine);
306
+ isMultiLine = true;
307
+ // Clack's text prompt dims the submitted value, which can be completely invisible on some terminal themes.
308
+ // We explicitly log the line here so the user can see what they are typing in multiline mode.
309
+ p.log.step(pc.cyan(cleanLine));
310
+ }
311
+ else {
312
+ lines.push(line);
313
+ // Unconditionally log the user's message so it is always visible
314
+ p.log.step(pc.cyan(line));
315
+ break;
316
+ }
317
+ }
318
+ return { userInput: lines.join('\n').trim(), canceled: false };
319
+ }
320
+ async function compressContextFiles(workspaceRoot, contextResult) {
321
+ const cachedContext = readCache(workspaceRoot, 'context_cache.json') || {};
322
+ let cacheUpdated = false;
323
+ const compressedFiles = new Map();
324
+ for (const [filePath, content] of contextResult.relevantFiles.entries()) {
325
+ if (content.length < 2000) {
326
+ debugLog(`Bypassing cache and compression for ${filePath} (length ${content.length} < 2000)`);
327
+ compressedFiles.set(filePath, content);
328
+ continue;
329
+ }
330
+ const fileHash = crypto
331
+ .createHash('sha256')
332
+ .update(filePath + content)
333
+ .digest('hex');
334
+ if (cachedContext[fileHash]) {
335
+ debugLog(`Context cache HIT for file ${filePath} (hash ${fileHash.substring(0, 8)})`);
336
+ compressedFiles.set(filePath, cachedContext[fileHash]);
337
+ }
338
+ else {
339
+ debugLog(`Context cache MISS for file ${filePath} (hash ${fileHash.substring(0, 8)})`);
340
+ const compressPrompt = `Summarize the following file contents concisely. Preserve all exports, functions, classes, variables, and architectural purpose. Keep it under 2000 characters if possible. File: ${filePath}`;
341
+ const summary = await compressTextUsingFlashLite(content, compressPrompt);
342
+ compressedFiles.set(filePath, summary);
343
+ cachedContext[fileHash] = summary;
344
+ cacheUpdated = true;
345
+ }
346
+ }
347
+ if (cacheUpdated) {
348
+ // Keep cache size manageable by restricting to recent 100 file entries
349
+ const keys = Object.keys(cachedContext);
350
+ if (keys.length > 100) {
351
+ const toDelete = keys.length - 100;
352
+ for (let i = 0; i < toDelete; i++) {
353
+ delete cachedContext[keys[i]];
354
+ }
355
+ }
356
+ writeCache(workspaceRoot, 'context_cache.json', cachedContext);
357
+ }
358
+ return compressedFiles;
359
+ }
360
+ async function executeSelfCorrectionLoop(chat, initialResult, workspaceRoot, inputHandler, effectiveTargetAgent, signal, spinner) {
361
+ let correctionAttempts = 0;
362
+ const MAX_CORRECTIONS = 5;
363
+ let result = initialResult;
364
+ let finalText = '';
365
+ let agentState = { targetAgent: effectiveTargetAgent };
366
+ let previousChangeCount = changeLogger.getCurrentChangeSet()?.changes.length || 0;
367
+ while (correctionAttempts <= MAX_CORRECTIONS) {
368
+ if (finalText === '[Generation stopped by user]')
369
+ break;
370
+ finalText = await processResponse(chat, result, workspaceRoot, inputHandler, agentState, signal);
371
+ debugLog(`processResponse returned finalText (length ${finalText.length}): "${finalText.substring(0, 10)}..."`);
372
+ if (finalText === '[Generation stopped by user]') {
373
+ break;
374
+ }
375
+ if (finalText.startsWith('[The AI repeatedly returned empty responses')) {
376
+ break;
377
+ }
378
+ const currentChanges = changeLogger.getCurrentChangeSet()?.changes || [];
379
+ // If the correction cycle is active but no new changes were registered, the model gave up
380
+ if (correctionAttempts > 0 && currentChanges.length <= previousChangeCount) {
381
+ if (correctionAttempts >= MAX_CORRECTIONS) {
382
+ p.log.warn('Max self-correction attempts reached. Leaving remaining errors for manual review.');
383
+ break;
384
+ }
385
+ debugLog('AI failed to modify any files during the correction attempt. Retrying...');
386
+ const forcePrompt = `AUTOMATED SYSTEM CHECK: You did not modify any files. You MUST use your file modification tools to apply a fix for the previously mentioned errors. Do not just explain the issue.`;
387
+ spinner.start('Thinking (Correction)...');
388
+ result = await chat.sendMessage(forcePrompt);
389
+ spinner.stop('');
390
+ correctionAttempts++;
391
+ continue;
392
+ }
393
+ previousChangeCount = currentChanges.length;
394
+ const changedFiles = currentChanges
395
+ .filter((c) => c.action === 'create' || c.action === 'modify')
396
+ .map((c) => c.filePath);
397
+ if (changedFiles.length === 0)
398
+ break;
399
+ // Stage 3: Static code analysis / verification
400
+ p.log.step('Verifying modified files...');
401
+ const verificationResult = await verifyChangedFiles(workspaceRoot, changedFiles, signal);
402
+ if (verificationResult === '[Verification Aborted]') {
403
+ p.log.warn(pc.yellow('Verification aborted by user.'));
404
+ break;
405
+ }
406
+ const hasErrors = !!verificationResult.errors;
407
+ const hasWarnings = !!verificationResult.warnings;
408
+ // Print performance warnings to the terminal
409
+ if (hasWarnings) {
410
+ p.log.warn('Performance Audit Warnings:\n' + verificationResult.warnings);
411
+ }
412
+ if (!hasErrors && !hasWarnings) {
413
+ p.log.success('Verification passed.');
414
+ break;
415
+ }
416
+ correctionAttempts++;
417
+ if (correctionAttempts > MAX_CORRECTIONS) {
418
+ p.log.warn('Max self-correction attempts reached. Leaving remaining issues for manual review.');
419
+ break;
420
+ }
421
+ const issueType = hasErrors && hasWarnings ? 'errors and warnings' : hasErrors ? 'errors' : 'performance warnings';
422
+ p.log.warn(`${pc.yellow(`Verification failed. Auto-correcting ${issueType}`)} (Attempt ${correctionAttempts}/${MAX_CORRECTIONS})...`);
423
+ const combinedIssues = [
424
+ hasErrors ? `Errors:\n${verificationResult.errors}` : '',
425
+ hasWarnings ? `Warnings:\n${verificationResult.warnings}` : ''
426
+ ].filter(Boolean).join('\n\n');
427
+ const displayError = combinedIssues.split('\n').slice(0, 5).join('\n');
428
+ console.log(pc.dim(` ${displayError.replace(/\n/g, '\n ')}\n ...`));
429
+ debugLog(`Verification failed on attempt ${correctionAttempts}/${MAX_CORRECTIONS}. Issues:\n${combinedIssues}`);
430
+ // Compile compilation and syntax diagnostic warnings into an auto-correction prompt
431
+ const correctionPrompt = `AUTOMATED SYSTEM CHECK: Your previous changes resulted in the following issues:\n\n${combinedIssues}\n\nPlease analyze these issues and use your file modification tools to fix them.`;
432
+ spinner.start('Thinking (Correction)...');
433
+ result = await chat.sendMessage(correctionPrompt);
434
+ spinner.stop('');
435
+ }
436
+ return { finalText };
437
+ }