diff --git a/README.md b/README.md index b7712bf..603f23b 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Coderrr is an AI-powered coding agent that analyzes tasks, creates actionable pl - [Architecture](#architecture) - [Codebase Intelligence](#codebase-intelligence) - [Safety Features](#safety-features) +- [Project Customization](#project-customization) - [Supported Test Frameworks](#supported-test-frameworks) - [Contributing](#contributing) - [Documentation](#documentation) @@ -57,6 +58,7 @@ Coderrr is an AI-powered coding agent that analyzes tasks, creates actionable pl - **Task Analysis** - Breaks down complex requests into structured, actionable TODO items - **File Operations** - Create, update, patch, delete, and read files with automatic directory creation - **Command Execution** - Runs shell commands with mandatory permission prompts (GitHub Copilot-style) +- **Separate Terminal Execution** - Long-running commands run in their own terminal window, keeping Coderrr responsive - **Self-Healing** - Automatically retries failed steps with AI-generated fixes - **Auto Testing** - Automatically detects and runs tests after completing tasks - **Codebase Intelligence** - Scans and understands project structure for accurate file editing @@ -66,6 +68,9 @@ Coderrr is an AI-powered coding agent that analyzes tasks, creates actionable pl ### Advanced Features +- **Cross-Session Memory** - Remembers conversation context across sessions via `.coderrr/memory.json` +- **Skills.md Support** - Define persistent coding guidelines and patterns for your project +- **Coderrr.md Support** - Add task-specific instructions that guide AI behavior - **Codebase Scanner** - Automatic project awareness with 1-minute cache - **Multi-Framework Support** - Works with Node.js, Python, Go, Rust, Java projects - **Environment Configuration** - Flexible backend configuration via environment variables @@ -245,7 +250,7 @@ coderrr start --dir /path/to/project 3. **TODO Generation** - Tasks are broken down into actionable steps 4. **Execution** - The agent executes each step: - File operations (create, update, patch, delete) - - Command execution (with permission prompts) + - Command execution in separate terminal windows (with permission prompts) 5. **Testing** - Automatically runs tests if a test framework is detected 6. **Completion** - Shows summary and execution statistics --- @@ -332,6 +337,53 @@ This means when you ask to "edit the agent file", it knows you mean `src/agent.j --- +## Project Customization + +Coderrr supports project-specific customization through special files: + +### Skills.md - Persistent Guidelines + +Create a `Skills.md` file in your project root to define coding guidelines that apply to **all tasks**: + +```markdown +# Project Skills + +## Code Style +- Use TypeScript strict mode +- Prefer functional components in React +- Always add JSDoc comments to public functions + +## Architecture +- Follow clean architecture patterns +- Keep business logic in /src/domain +``` + +### Coderrr.md - Task-Specific Instructions + +Create a `Coderrr.md` file for task-specific guidance: + +```markdown +# Current Focus + +Working on authentication module. Priority: +1. Security first - validate all inputs +2. Use bcrypt for password hashing +3. JWT tokens with 1-hour expiry +``` + +### Cross-Session Memory + +Coderrr automatically saves conversation history in `.coderrr/memory.json`: + +- **Persists across sessions** - Resume where you left off +- **Project-specific** - Each project has its own memory +- **Auto-managed** - Keeps last 30 conversation turns +- **Clear anytime** - Delete `.coderrr/memory.json` to reset + +> **Tip:** Add `.coderrr/` to your `.gitignore` to keep conversation history private. + +--- + ## Supported Test Frameworks Coderrr automatically detects and runs tests for: diff --git a/backend/main.py b/backend/main.py index 11349ab..6135706 100644 --- a/backend/main.py +++ b/backend/main.py @@ -163,6 +163,54 @@ class HealthResponse(BaseModel): --- +## PROTECTED FILES - NEVER DELETE THESE: +The following files are configuration files and MUST NOT be deleted: +- Coderrr.md (user's custom instructions) +- Skills.md (user's skills configuration) +- .coderrr/ (configuration directory) + +If the user asks to "delete everything" or "clear the directory", SKIP these protected files. + +--- + +## PATCH_FILE RULES - CRITICAL: +The `patch_file` action replaces EXACT text matches. Follow these rules: + +1. **ALWAYS read the file first** before using patch_file +2. The `oldContent` MUST be an EXACT copy from the file (not a placeholder) +3. NEVER use placeholders like "" or regex patterns +4. If you haven't read the file, use `read_file` first, then patch in a follow-up + +### WRONG (will fail): +```json +{"action": "patch_file", "oldContent": "", "newContent": "..."} +``` + +### CORRECT: +```json +{"action": "read_file", "path": "index.html", "summary": "Read file to get exact content"} +``` +Then after seeing the content, use the EXACT text for oldContent. + +--- + +## CHOOSING BETWEEN UPDATE_FILE AND PATCH_FILE: + +- **update_file**: Replaces the ENTIRE file. Use when: + - Creating new content from scratch + - Major rewrites (>50% of file changing) + - File structure is completely changing + +- **patch_file**: Replaces only a PORTION. Use when: + - Adding a new section to existing file + - Modifying a specific function or component + - User says "add", "modify", "enhance", "update" a specific part + - PRESERVING existing code is important + +**CRITICAL**: When user asks to "add animations" or "enhance the hero section", use patch_file to PRESERVE existing styles and features. Do NOT use update_file which would erase everything else. + +--- + The JSON MUST follow this exact schema: { "explanation": "Your answer or explanation of what you will do", @@ -171,6 +219,8 @@ class HealthResponse(BaseModel): "action": "ACTION_TYPE", "path": "file/path if applicable", "content": "file content if creating/updating files", + "oldContent": "exact text to find (for patch_file only)", + "newContent": "replacement text (for patch_file only)", "command": "shell command if action is run_command", "summary": "Brief description of this step" } @@ -178,11 +228,11 @@ class HealthResponse(BaseModel): } Valid ACTION_TYPE values: -- "read_file": Read and examine a file (requires path, summary) - USE FOR QUESTIONS +- "read_file": Read and examine a file (requires path, summary) - USE FOR QUESTIONS OR BEFORE PATCHING - "create_file": Create a new file (requires path, content, summary) -- "update_file": Replace entire file content (requires path, content, summary) -- "patch_file": Modify part of a file (requires path, oldContent, newContent, summary) -- "delete_file": Delete a file (requires path, summary) +- "update_file": Replace ENTIRE file content (requires path, content, summary) - use sparingly! +- "patch_file": Modify PART of a file (requires path, oldContent, newContent, summary) - preferred for edits +- "delete_file": Delete a file (requires path, summary) - NEVER delete protected files - "run_command": Execute a shell command (requires command, summary) - "create_dir": Create a directory (requires path, summary) @@ -193,6 +243,9 @@ class HealthResponse(BaseModel): 4. Each plan item MUST have "action" and "summary" fields 5. For run_command, use PowerShell syntax on Windows 6. DO NOT create files when user is asking ABOUT existing files - read them instead! +7. NEVER delete Coderrr.md, Skills.md, or .coderrr directory +8. ALWAYS read a file before using patch_file on it +9. When enhancing/adding features, use patch_file to preserve existing code """ diff --git a/bin/coderrr.js b/bin/coderrr.js index 4d3ed3c..69fff06 100644 --- a/bin/coderrr.js +++ b/bin/coderrr.js @@ -16,6 +16,14 @@ const configManager = require('../src/configManager'); const { getProviderChoices, getModelChoices, getProvider, validateApiKey } = require('../src/providers'); const { tryExtractJSON } = require('../src/utils'); +const { displayInsights } = require('../src/insightsUI'); + +program + .command('insights') + .description('Display local usage statistics and task history') + .action(() => { + displayInsights(); + }); // Optional: Load .env from user's home directory (for advanced users who want custom backend) const homeConfigPath = path.join(os.homedir(), '.coderrr', '.env'); if (fs.existsSync(homeConfigPath)) { diff --git a/docs/insights-guide.md b/docs/insights-guide.md new file mode 100644 index 0000000..c459342 --- /dev/null +++ b/docs/insights-guide.md @@ -0,0 +1,14 @@ +# 📊 Coderrr Insights Guide + +Coderrr Insights is a local analytics module that tracks your AI-powered coding activity. It helps you visualize the impact of the agent on your workflow. + +## Features +- **Usage Statistics:** Track total tasks, file modifications, and self-healing successes. +- **Productivity Estimation:** See how much manual coding time you've potentially saved. +- **Local History:** View a snapshot of your last 50 coding sessions. + +## How to View Insights +To open your dashboard, simply run the following command in your terminal: + +```bash +coderrr insights \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index a048db7..56fe09f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coderrr-cli", - "version": "1.1.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coderrr-cli", - "version": "1.1.0", + "version": "1.1.1", "license": "MIT", "os": [ "darwin", @@ -68,7 +68,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1264,7 +1263,6 @@ "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1839,7 +1837,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", diff --git a/src/agent.js b/src/agent.js index 8651137..03c4d78 100644 --- a/src/agent.js +++ b/src/agent.js @@ -55,6 +55,49 @@ class Agent { // Load user provider configuration this.providerConfig = configManager.getConfig(); + + // Track running processes spawned in separate terminals + this.runningProcesses = []; + + // Register cleanup handler for when Coderrr exits + this.registerExitCleanup(); + } + + /** + * Register cleanup handler to terminate spawned processes on exit + */ + registerExitCleanup() { + const cleanup = async () => { + if (this.runningProcesses.length > 0) { + ui.info(`Cleaning up ${this.runningProcesses.length} running process(es)...`); + for (const proc of this.runningProcesses) { + if (proc && typeof proc.stop === 'function') { + try { + const isRunning = await proc.isRunning(); + if (isRunning) { + await proc.stop(); + } + if (proc.stopMonitoring) { + proc.stopMonitoring(); + } + } catch (e) { + // Ignore cleanup errors + } + } + } + } + }; + + // Handle various exit signals + process.on('exit', cleanup); + process.on('SIGINT', async () => { + await cleanup(); + process.exit(0); + }); + process.on('SIGTERM', async () => { + await cleanup(); + process.exit(0); + }); } /** @@ -372,6 +415,9 @@ For command execution on ${osType}, use appropriate command separators (${osType ui.section('Executing Plan'); + // Track completed steps for context in self-healing + const completedSteps = []; + // Execute each step for (let i = 0; i < plan.length; i++) { const step = plan[i]; @@ -386,18 +432,33 @@ For command execution on ${osType}, use appropriate command separators (${osType while (!stepSuccess && retryCount <= this.maxRetries) { try { if (step.action === 'run_command') { - // Execute command with permission - const result = await this.executor.execute(step.command, { + // Execute command in a separate terminal window + // This prevents long-running or infinite loop commands from blocking Coderrr + const result = await this.executor.executeInSeparateTerminal(step.command, { requirePermission: true, - cwd: this.workingDir + cwd: this.workingDir, + monitorOutput: true }); - stepResult = result.success - ? `Executed command: "${step.command}"` - : `Failed command: "${step.command}". Error: ${result.error || result.output}`; + if (result.cancelled) { + ui.warning('Command cancelled by user'); + stepSuccess = true; // Consider cancelled as completed + stepResult = `Cancelled command: "${step.command}"`; + } else if (result.success) { + stepResult = `Started command in separate terminal: "${step.command}"`; + stepSuccess = true; - if (!result.success && !result.cancelled) { - const errorMsg = result.error || result.output || 'Unknown error'; + // Store the process handle for potential cleanup later + if (!this.runningProcesses) { + this.runningProcesses = []; + } + this.runningProcesses.push(result); + + ui.info('Command is running in separate terminal. Coderrr remains responsive.'); + ui.info('The terminal window will show the command output.'); + } else { + const errorMsg = result.error || 'Unknown error'; + stepResult = `Failed to start command: "${step.command}". Error: ${errorMsg}`; // Check if this error is retryable (can be fixed by AI) if (!this.isRetryableError(errorMsg)) { @@ -411,7 +472,7 @@ For command execution on ${osType}, use appropriate command separators (${osType ui.warning(`Command failed (attempt ${retryCount + 1}/${this.maxRetries + 1})`); ui.info('Analyzing error and generating fix...'); - const fixedStep = await this.selfHeal(step, errorMsg, retryCount); + const fixedStep = await this.selfHeal(step, errorMsg, retryCount, completedSteps); if (fixedStep && this.validateFixedStep(fixedStep)) { Object.assign(step, fixedStep); @@ -425,15 +486,6 @@ For command execution on ${osType}, use appropriate command separators (${osType ui.error(`Command failed${this.autoRetry ? ` after ${this.maxRetries + 1} attempts` : ''}, stopping execution`); break; } - } else { - stepSuccess = true; - } - - if (result.cancelled) { - ui.warning('Command cancelled by user'); - stepSuccess = true; // Consider cancelled as completed - } else { - stepSuccess = true; } } else { // File operation @@ -450,6 +502,8 @@ For command execution on ${osType}, use appropriate command separators (${osType if (stepSuccess) { this.todoManager.complete(i); executionLog.push(`✓ Step ${i + 1}: ${stepResult}`); + // Track completed step for context in case later steps fail + completedSteps.push({ ...step, result: stepResult }); } } catch (error) { const errorMsg = error.message || 'Unknown error'; @@ -469,7 +523,7 @@ For command execution on ${osType}, use appropriate command separators (${osType ui.warning(`Step failed: ${errorMsg} (attempt ${retryCount + 1}/${this.maxRetries + 1})`); ui.info('Analyzing error and generating fix...'); - const fixedStep = await this.selfHeal(step, errorMsg, retryCount); + const fixedStep = await this.selfHeal(step, errorMsg, retryCount, completedSteps); if (fixedStep && this.validateFixedStep(fixedStep)) { Object.assign(step, fixedStep); @@ -566,9 +620,23 @@ For command execution on ${osType}, use appropriate command separators (${osType /** * Self-healing: Ask AI to fix a failed step + * @param {Object} failedStep - The step that failed + * @param {string} errorMessage - The error message + * @param {number} attemptNumber - Current attempt number + * @param {Array} completedSteps - Steps already successfully completed in this plan */ - async selfHeal(failedStep, errorMessage, attemptNumber) { + async selfHeal(failedStep, errorMessage, attemptNumber, completedSteps = []) { try { + // Build context about what has already been completed + let completedContext = ''; + if (completedSteps.length > 0) { + completedContext = `\nALREADY COMPLETED STEPS (do NOT repeat these or try to access deleted files): +${completedSteps.map((s, i) => ` ${i + 1}. ${s.action}: ${s.path || s.command || ''} - ${s.summary}`).join('\n')} + +IMPORTANT: The above actions have ALREADY been executed. Files that were deleted NO LONGER EXIST. +`; + } + // Use the same format as normal requests so it passes backend validation const healingPrompt = `The following step failed with an error. Please analyze the error and provide a fixed version of the step. @@ -580,12 +648,17 @@ Summary: ${failedStep.summary} ERROR: ${errorMessage} - +${completedContext} CONTEXT: - Working directory: ${this.workingDir} - Attempt number: ${attemptNumber + 1} - Available files: ${this.codebaseContext ? this.codebaseContext.files.map(f => f.path).slice(0, 10).join(', ') : 'Unknown'} +IMPORTANT REMINDERS: +- NEVER delete Coderrr.md, Skills.md, or .coderrr directory (these are protected) +- If a file was already deleted in a previous step, it no longer exists +- For patch_file, you MUST use the EXACT content from the file, not placeholders + Please provide ONLY a JSON object with the fixed step. Use the standard plan format: { "explanation": "Brief explanation of what went wrong and how you fixed it", diff --git a/src/executor.js b/src/executor.js index af8bb87..98a4f0a 100644 --- a/src/executor.js +++ b/src/executor.js @@ -3,9 +3,11 @@ * Refactored to work with new Agent architecture */ -const { spawn } = require('child_process'); +const { spawn, exec } = require('child_process'); const fs = require('fs').promises; +const fsSync = require('fs'); const path = require('path'); +const os = require('os'); const ui = require('./ui'); class CommandExecutor { @@ -22,7 +24,7 @@ class CommandExecutor { // Add this method to normalize commands based on OS normalizeCommand(command) { const isWindows = process.platform === 'win32'; - + if (isWindows) { // Replace && with ; for Windows PowerShell return command.replace(/&&/g, ';'); @@ -43,9 +45,9 @@ class CommandExecutor { // Normalize command based on OS const normalizedCommand = this.normalizeCommand(command); - + ui.displayCommand(normalizedCommand); - + // Ask for permission if required if (requirePermission) { const confirmed = await ui.confirm('Execute this command?', false); @@ -58,7 +60,7 @@ class CommandExecutor { // Execute command return new Promise((resolve) => { ui.info('Executing...'); - + const startTime = Date.now(); let stdout = ''; let stderr = ''; @@ -67,19 +69,19 @@ class CommandExecutor { shell, stdio: ['inherit', 'pipe', 'pipe'] }); - + child.stdout.on('data', (data) => { const text = data.toString(); stdout += text; process.stdout.write(text); }); - + child.stderr.on('data', (data) => { const text = data.toString(); stderr += text; process.stderr.write(text); }); - + child.on('close', (code) => { const duration = Date.now() - startTime; const result = { @@ -98,7 +100,7 @@ class CommandExecutor { } resolve(result); }); - + child.on('error', (error) => { ui.error(`Failed to execute command: ${error.message}`); resolve({ @@ -115,11 +117,11 @@ class CommandExecutor { */ async executeBatch(commands, options = {}) { const results = []; - + for (const command of commands) { const result = await this.execute(command, options); results.push(result); - + // Stop on first failure unless continueOnError is true if (!result.success && !options.continueOnError) { break; @@ -142,8 +144,551 @@ class CommandExecutor { clearHistory() { this.history = []; } + + /** + * Execute command in a separate terminal window (cross-platform) + * This allows Coderrr to continue while the command runs in a detached process + * + * @param {string} command - Command to execute + * @param {Object} options - Execution options + * @returns {Promise} Result with process info and monitoring capabilities + */ + async executeInSeparateTerminal(command, options = {}) { + const { + requirePermission = true, + cwd = process.cwd(), + timeout = 0, // 0 means no timeout + monitorOutput = true + } = options; + + // Normalize command based on OS + const normalizedCommand = this.normalizeCommand(command); + + ui.displayCommand(normalizedCommand); + ui.info('(Will run in separate terminal window)'); + + // Ask for permission if required + if (requirePermission) { + const confirmed = await ui.confirm('Execute this command in a separate terminal?', false); + if (!confirmed) { + ui.warning('Command execution cancelled by user'); + return { success: false, cancelled: true }; + } + } + + // Create unique output file for monitoring + const timestamp = Date.now(); + const outputFile = path.join(os.tmpdir(), `coderrr-output-${timestamp}.log`); + const pidFile = path.join(os.tmpdir(), `coderrr-pid-${timestamp}.txt`); + + // Initialize output file + fsSync.writeFileSync(outputFile, '', 'utf8'); + + const platform = process.platform; + let terminalProcess = null; + let childPid = null; + + try { + if (platform === 'win32') { + // Windows: Use start command with cmd.exe + const result = await this.spawnWindowsTerminal(normalizedCommand, cwd, outputFile, pidFile); + terminalProcess = result.process; + childPid = result.pid; + } else if (platform === 'darwin') { + // macOS: Use osascript to open Terminal.app + const result = await this.spawnMacTerminal(normalizedCommand, cwd, outputFile, pidFile); + terminalProcess = result.process; + childPid = result.pid; + } else { + // Linux: Try gnome-terminal, konsole, or xterm + const result = await this.spawnLinuxTerminal(normalizedCommand, cwd, outputFile, pidFile); + terminalProcess = result.process; + childPid = result.pid; + } + + ui.success('Command started in separate terminal window'); + + // Start monitoring output if requested + let outputWatcher = null; + let lastReadPosition = 0; + + if (monitorOutput) { + outputWatcher = this.startOutputMonitoring(outputFile, (newContent) => { + if (newContent.trim()) { + process.stdout.write(newContent); + } + }); + } + + // Create result object with control methods + const result = { + success: true, + pid: childPid, + outputFile, + pidFile, + command: normalizedCommand, + startTime: Date.now(), + + // Method to stop the spawned process + stop: async () => { + return this.terminateProcess(childPid, platform); + }, + + // Method to stop monitoring + stopMonitoring: () => { + if (outputWatcher) { + outputWatcher.close(); + outputWatcher = null; + } + }, + + // Method to get current output + getOutput: () => { + try { + return fsSync.readFileSync(outputFile, 'utf8'); + } catch (e) { + return ''; + } + }, + + // Method to check if process is still running + isRunning: async () => { + return this.isProcessRunning(childPid, platform); + }, + + // Method to wait for process completion with optional timeout + waitForCompletion: async (timeoutMs = 0) => { + return this.waitForProcess(childPid, platform, timeoutMs, outputFile); + } + }; + + // Store in history + this.history.push({ + command: normalizedCommand, + pid: childPid, + separateTerminal: true, + startTime: result.startTime + }); + + // If timeout is specified, set up auto-termination + if (timeout > 0) { + setTimeout(async () => { + const stillRunning = await result.isRunning(); + if (stillRunning) { + ui.warning(`Command timed out after ${timeout}ms, terminating...`); + await result.stop(); + } + }, timeout); + } + + return result; + + } catch (error) { + ui.error(`Failed to spawn separate terminal: ${error.message}`); + return { + success: false, + error: error.message, + command: normalizedCommand + }; + } + } + + /** + * Spawn terminal on Windows + */ + async spawnWindowsTerminal(command, cwd, outputFile, pidFile) { + return new Promise((resolve, reject) => { + // Create a PowerShell script that runs the command, shows output, AND logs to file + const psScript = path.join(os.tmpdir(), `coderrr-cmd-${Date.now()}.ps1`); + + // PowerShell script that: + // 1. Changes to working directory + // 2. Runs command with output visible AND teed to log file + // 3. Shows completion message + // 4. Waits for user to close + const psContent = ` +$ErrorActionPreference = "Continue" +Set-Location -Path "${cwd.replace(/\\/g, '\\\\')}" +Write-Host "=== Coderrr Task ===" -ForegroundColor Cyan +Write-Host "Working Directory: ${cwd.replace(/\\/g, '\\\\')}" -ForegroundColor Gray +Write-Host "Command: ${command.replace(/"/g, '\\"')}" -ForegroundColor Yellow +Write-Host "Started at: $(Get-Date)" -ForegroundColor Gray +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "" + +# Log start to file +"Command started at $(Get-Date)" | Out-File -FilePath "${outputFile.replace(/\\/g, '\\\\')}" -Encoding UTF8 + +try { + # Run command and tee output to both console and file + Invoke-Expression "${command.replace(/"/g, '\\"')}" 2>&1 | Tee-Object -FilePath "${outputFile.replace(/\\/g, '\\\\')}" -Append + $exitCode = $LASTEXITCODE +} catch { + Write-Host "Error: $_" -ForegroundColor Red + $_ | Out-File -FilePath "${outputFile.replace(/\\/g, '\\\\')}" -Append + $exitCode = 1 +} + +Write-Host "" +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "Command finished with exit code: $exitCode at $(Get-Date)" -ForegroundColor $(if($exitCode -eq 0){"Green"}else{"Red"}) +"Command finished with exit code $exitCode at $(Get-Date)" | Out-File -FilePath "${outputFile.replace(/\\/g, '\\\\')}" -Append + +Write-Host "" +Write-Host "Press any key to close this window..." -ForegroundColor Gray +$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") +`; + fsSync.writeFileSync(psScript, psContent, 'utf8'); + + // Use exec with start command - exec handles the shell properly + const startCmd = `start "Coderrr Task" powershell.exe -ExecutionPolicy Bypass -File "${psScript}"`; + + // Use exec instead of spawn to avoid quote escaping issues + exec(startCmd, { cwd, windowsHide: false }, (error) => { + // The exec callback fires immediately after start launches the window + // We don't wait for the PowerShell script to complete + if (error) { + // Only reject on actual errors, not on expected behavior + if (!error.message.includes('is not recognized')) { + // Start command itself failed + } + } + }); + + // Resolve immediately - don't wait for the terminal + // The command is now running independently + setTimeout(() => { + // Try to get the PowerShell process PID + exec('wmic process where "name=\'powershell.exe\'" get ProcessId,CommandLine /format:csv', (err, out) => { + let pid = null; + if (!err && out) { + // Look for our script in the output + const lines = out.split('\n'); + for (const line of lines) { + if (line.includes('coderrr-cmd-')) { + const match = line.match(/,(\d+)$/); + if (match) { + pid = parseInt(match[1]); + break; + } + } + } + // Fallback: get any recent powershell + if (!pid) { + const matches = out.match(/,(\d+)\r?\n/g); + if (matches && matches.length > 0) { + const lastMatch = matches[matches.length - 1]; + pid = parseInt(lastMatch.replace(/[,\r\n]/g, '')); + } + } + } + + if (pid) { + fsSync.writeFileSync(pidFile, pid.toString(), 'utf8'); + } + + resolve({ process: null, pid }); + }); + }, 300); // Short delay to let the window open + }); + } + + /** + * Spawn terminal on macOS + */ + async spawnMacTerminal(command, cwd, outputFile, pidFile) { + return new Promise((resolve, reject) => { + // Create a shell script that shows output AND logs to file + const shellScript = path.join(os.tmpdir(), `coderrr-cmd-${Date.now()}.sh`); + const scriptContent = `#!/bin/bash +cd "${cwd}" +echo "=== Coderrr Task ===" +echo "Working Directory: ${cwd}" +echo "Command: ${command}" +echo "Started at: $(date)" +echo "========================================" +echo "" + +# Log start to file +echo "Command started at $(date)" > "${outputFile}" + +# Run command with output to both terminal AND file using tee +${command} 2>&1 | tee -a "${outputFile}" +EXITCODE=\${PIPESTATUS[0]} + +echo "" +echo "========================================" +echo "Command finished with exit code $EXITCODE at $(date)" +echo "Command finished with exit code $EXITCODE at $(date)" >> "${outputFile}" + +echo "" +echo "Press Enter to close this terminal..." +read +`; + fsSync.writeFileSync(shellScript, scriptContent, { mode: 0o755 }); + + // Use osascript to open Terminal.app and run the script + const appleScript = ` +tell application "Terminal" + activate + do script "bash '${shellScript}'; echo $$ > '${pidFile}'" +end tell +`; + + exec(`osascript -e '${appleScript}'`, (error, stdout, stderr) => { + if (error) { + reject(error); + return; + } + + // Wait a bit for PID file to be created + setTimeout(() => { + let pid = null; + try { + if (fsSync.existsSync(pidFile)) { + pid = parseInt(fsSync.readFileSync(pidFile, 'utf8').trim()); + } + } catch (e) { + // Ignore + } + + resolve({ process: null, pid }); + }, 1000); + }); + }); + } + + /** + * Spawn terminal on Linux + */ + async spawnLinuxTerminal(command, cwd, outputFile, pidFile) { + return new Promise((resolve, reject) => { + // Create a shell script that shows output AND logs to file + const shellScript = path.join(os.tmpdir(), `coderrr-cmd-${Date.now()}.sh`); + const scriptContent = `#!/bin/bash +cd "${cwd}" +echo "=== Coderrr Task ===" +echo "Working Directory: ${cwd}" +echo "Command: ${command}" +echo "Started at: $(date)" +echo "========================================" +echo "" + +# Log start to file +echo "Command started at $(date)" > "${outputFile}" + +# Run command with output to both terminal AND file using tee +${command} 2>&1 | tee -a "${outputFile}" +EXITCODE=\${PIPESTATUS[0]} + +echo "" +echo "========================================" +echo "Command finished with exit code $EXITCODE at $(date)" +echo "Command finished with exit code $EXITCODE at $(date)" >> "${outputFile}" + +echo "" +echo "Press Enter to close this terminal..." +read +`; + fsSync.writeFileSync(shellScript, scriptContent, { mode: 0o755 }); + + // Try different terminal emulators in order of preference + const terminals = [ + { cmd: 'gnome-terminal', args: ['--', 'bash', '-c', `${shellScript}; echo $$ > ${pidFile}`] }, + { cmd: 'konsole', args: ['-e', 'bash', '-c', `${shellScript}; echo $$ > ${pidFile}`] }, + { cmd: 'xfce4-terminal', args: ['-e', `bash -c "${shellScript}; echo $$ > ${pidFile}"`] }, + { cmd: 'xterm', args: ['-e', `bash -c "${shellScript}; echo $$ > ${pidFile}"`] } + ]; + + const tryTerminal = async (index) => { + if (index >= terminals.length) { + reject(new Error('No suitable terminal emulator found. Tried: gnome-terminal, konsole, xfce4-terminal, xterm')); + return; + } + + const term = terminals[index]; + + // Check if terminal exists + exec(`which ${term.cmd}`, async (error) => { + if (error) { + // Terminal not found, try next + tryTerminal(index + 1); + return; + } + + // Terminal found, spawn it + const child = spawn(term.cmd, term.args, { + cwd, + detached: true, + stdio: 'ignore' + }); + + child.unref(); + + // Wait for PID file + setTimeout(() => { + let pid = child.pid; + try { + if (fsSync.existsSync(pidFile)) { + pid = parseInt(fsSync.readFileSync(pidFile, 'utf8').trim()); + } + } catch (e) { + // Use child.pid as fallback + } + + resolve({ process: child, pid }); + }, 1000); + }); + }; + + tryTerminal(0); + }); + } + + /** + * Start monitoring output file for changes + */ + startOutputMonitoring(outputFile, callback) { + let lastSize = 0; + + const checkForChanges = () => { + try { + const stats = fsSync.statSync(outputFile); + if (stats.size > lastSize) { + const fd = fsSync.openSync(outputFile, 'r'); + const buffer = Buffer.alloc(stats.size - lastSize); + fsSync.readSync(fd, buffer, 0, buffer.length, lastSize); + fsSync.closeSync(fd); + + const newContent = buffer.toString('utf8'); + callback(newContent); + lastSize = stats.size; + } + } catch (e) { + // File might not exist yet or be locked + } + }; + + // Check every 500ms + const intervalId = setInterval(checkForChanges, 500); + + return { + close: () => clearInterval(intervalId) + }; + } + + /** + * Terminate a process by PID + */ + async terminateProcess(pid, platform) { + if (!pid) return false; + + return new Promise((resolve) => { + try { + if (platform === 'win32') { + exec(`taskkill /PID ${pid} /T /F`, (error) => { + if (error) { + ui.warning(`Could not terminate process ${pid}: ${error.message}`); + resolve(false); + } else { + ui.success(`Terminated process ${pid}`); + resolve(true); + } + }); + } else { + exec(`kill -TERM ${pid}`, (error) => { + if (error) { + // Try SIGKILL as fallback + exec(`kill -KILL ${pid}`, (err2) => { + if (err2) { + ui.warning(`Could not terminate process ${pid}`); + resolve(false); + } else { + ui.success(`Terminated process ${pid}`); + resolve(true); + } + }); + } else { + ui.success(`Terminated process ${pid}`); + resolve(true); + } + }); + } + } catch (e) { + ui.warning(`Error terminating process: ${e.message}`); + resolve(false); + } + }); + } + + /** + * Check if a process is running + */ + async isProcessRunning(pid, platform) { + if (!pid) return false; + + return new Promise((resolve) => { + if (platform === 'win32') { + exec(`tasklist /FI "PID eq ${pid}" /NH`, (error, stdout) => { + resolve(!error && stdout.includes(pid.toString())); + }); + } else { + exec(`ps -p ${pid}`, (error) => { + resolve(!error); + }); + } + }); + } + + /** + * Wait for a process to complete + */ + async waitForProcess(pid, platform, timeoutMs, outputFile) { + const startTime = Date.now(); + + return new Promise((resolve) => { + const checkInterval = setInterval(async () => { + const running = await this.isProcessRunning(pid, platform); + + if (!running) { + clearInterval(checkInterval); + + // Read final output + let output = ''; + try { + output = fsSync.readFileSync(outputFile, 'utf8'); + } catch (e) { + // Ignore + } + + // Check if command succeeded (look for exit code in output) + const exitCodeMatch = output.match(/exit code (\d+)/i); + const exitCode = exitCodeMatch ? parseInt(exitCodeMatch[1]) : 0; + + resolve({ + success: exitCode === 0, + code: exitCode, + output, + duration: Date.now() - startTime + }); + return; + } + + // Check timeout + if (timeoutMs > 0 && (Date.now() - startTime) > timeoutMs) { + clearInterval(checkInterval); + resolve({ + success: false, + timedOut: true, + duration: Date.now() - startTime + }); + } + }, 1000); + }); + } } + // Legacy function for backward compatibility with old blessed TUI code async function safeWriteFile(filePath, content) { const dir = path.dirname(filePath); @@ -168,8 +713,8 @@ async function executePlan(plan, ctx) { const idx = i + 1; appendMessage('assistant', `Step ${idx}/${plan.length}: ${step.action} ${step.path || step.command || ''}`); status.setContent(`Executing step ${idx}/${plan.length} — ${step.action}`); - - let confirmNeeded = ['create_file','update_file','patch_file','delete_file','run_command'].includes(step.action); + + let confirmNeeded = ['create_file', 'update_file', 'patch_file', 'delete_file', 'run_command'].includes(step.action); let ok = true; if (confirmNeeded) { ok = await askYesNo(`Proceed with step ${idx}: ${step.action} ${step.path || step.command || ''}?`); @@ -186,7 +731,7 @@ async function executePlan(plan, ctx) { if (exists !== null) { appendMessage('assistant', `File ${step.path} already exists. Asking before overwrite.`); const ov = await askYesNo(`File ${step.path} exists. Overwrite?`); - if (!ov) { appendMessage('assistant','Skipped creation.'); continue; } + if (!ov) { appendMessage('assistant', 'Skipped creation.'); continue; } } await safeWriteFile(step.path, step.content || ''); appendMessage('assistant', `✅ Created/overwritten ${step.path}`); diff --git a/src/fileOps.js b/src/fileOps.js index af9223e..2d69b0b 100644 --- a/src/fileOps.js +++ b/src/fileOps.js @@ -137,6 +137,12 @@ class FileOperations { /** * Patch a file (partial update) + * + * This method supports flexible matching to handle differences between + * what the AI provides and what's actually in the file: + * - Line ending normalization (CRLF vs LF) + * - Leading/trailing whitespace tolerance + * - Fuzzy line-by-line matching as fallback */ async patchFile(filePath, oldContent, newContent) { try { @@ -150,12 +156,79 @@ class FileOperations { // Read current content const originalContent = fs.readFileSync(absolutePath, 'utf8'); - // Replace old content with new content - if (!originalContent.includes(oldContent)) { - throw new Error(`Pattern not found in file: ${filePath}`); + // Try multiple matching strategies + let patchedContent = null; + + // Strategy 1: Exact match + if (originalContent.includes(oldContent)) { + patchedContent = originalContent.replace(oldContent, newContent); + } + + // Strategy 2: Normalize line endings (CRLF -> LF) and try again + if (!patchedContent) { + const normalizedOriginal = originalContent.replace(/\r\n/g, '\n'); + const normalizedOld = oldContent.replace(/\r\n/g, '\n'); + const normalizedNew = newContent.replace(/\r\n/g, '\n'); + + if (normalizedOriginal.includes(normalizedOld)) { + // Found with normalized line endings - apply patch and restore original line endings + const hasWindowsLineEndings = originalContent.includes('\r\n'); + patchedContent = normalizedOriginal.replace(normalizedOld, normalizedNew); + if (hasWindowsLineEndings) { + patchedContent = patchedContent.replace(/\n/g, '\r\n'); + } + } } - const patchedContent = originalContent.replace(oldContent, newContent); + // Strategy 3: Trim whitespace from each line and match + if (!patchedContent) { + const trimLines = (str) => str.split('\n').map(line => line.trim()).join('\n'); + const trimmedOriginal = trimLines(originalContent.replace(/\r\n/g, '\n')); + const trimmedOld = trimLines(oldContent.replace(/\r\n/g, '\n')); + + if (trimmedOriginal.includes(trimmedOld)) { + // Found with trimmed lines - need to find actual position + // This is a fallback, so we'll do a line-by-line search + const originalLines = originalContent.replace(/\r\n/g, '\n').split('\n'); + const oldLines = oldContent.replace(/\r\n/g, '\n').split('\n').filter(l => l.trim()); + + // Find the starting line by matching first non-empty line + let startIdx = -1; + for (let i = 0; i < originalLines.length; i++) { + if (originalLines[i].trim() === oldLines[0].trim()) { + // Check if subsequent lines match + let matches = true; + for (let j = 0; j < oldLines.length && (i + j) < originalLines.length; j++) { + if (originalLines[i + j].trim() !== oldLines[j].trim()) { + matches = false; + break; + } + } + if (matches) { + startIdx = i; + break; + } + } + } + + if (startIdx >= 0) { + // Replace the lines + const newLines = newContent.replace(/\r\n/g, '\n').split('\n'); + const beforeLines = originalLines.slice(0, startIdx); + const afterLines = originalLines.slice(startIdx + oldLines.length); + const hasWindowsLineEndings = originalContent.includes('\r\n'); + const lineEnding = hasWindowsLineEndings ? '\r\n' : '\n'; + patchedContent = [...beforeLines, ...newLines, ...afterLines].join(lineEnding); + } + } + } + + // If no strategy worked, throw error with helpful message + if (!patchedContent) { + // Show what we were looking for to help debug + const preview = oldContent.substring(0, 100).replace(/\n/g, '\\n'); + throw new Error(`Pattern not found in file: ${filePath}\nLooking for: "${preview}${oldContent.length > 100 ? '...' : ''}"`); + } // Write back fs.writeFileSync(absolutePath, patchedContent, 'utf8'); diff --git a/src/insights.js b/src/insights.js new file mode 100644 index 0000000..5ade132 --- /dev/null +++ b/src/insights.js @@ -0,0 +1,50 @@ +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const INSIGHTS_PATH = path.join(os.homedir(), '.coderrr', 'insights.json'); + +class InsightsManager { + constructor() { + this.ensureStorage(); + } + + ensureStorage() { + const dir = path.dirname(INSIGHTS_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + if (!fs.existsSync(INSIGHTS_PATH)) { + fs.writeFileSync(INSIGHTS_PATH, JSON.stringify({ sessions: [], totals: { tasks: 0, filesChanged: 0, healings: 0 } })); + } + } + + recordSession(data) { + try { + const content = JSON.parse(fs.readFileSync(INSIGHTS_PATH, 'utf8')); + const session = { + timestamp: new Date().toISOString(), + task: data.task || 'Unknown Task', + success: data.success || false, + filesChanged: data.filesChanged || 0, + healings: data.healings || 0 + }; + + content.sessions.push(session); + content.totals.tasks += 1; + content.totals.filesChanged += session.filesChanged; + content.totals.healings += session.healings; + + // Keep last 50 sessions to save space + if (content.sessions.length > 50) content.sessions.shift(); + + fs.writeFileSync(INSIGHTS_PATH, JSON.stringify(content, null, 2)); + } catch (error) { + // Fail silently to not disturb the main process + } + } + + getStats() { + return JSON.parse(fs.readFileSync(INSIGHTS_PATH, 'utf8')); + } +} + +module.exports = new InsightsManager(); \ No newline at end of file diff --git a/src/insightsUI.js b/src/insightsUI.js new file mode 100644 index 0000000..24da9dd --- /dev/null +++ b/src/insightsUI.js @@ -0,0 +1,22 @@ +const chalk = require('chalk'); +const insights = require('./insights'); + +function displayInsights() { + const data = insights.getStats(); + console.log('\n' + chalk.cyan.bold('📊 CODERRR INSIGHTS DASHBOARD')); + console.log(chalk.gray('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')); + + console.log(`${chalk.white('Total Tasks Processed: ')} ${chalk.green.bold(data.totals.tasks)}`); + console.log(`${chalk.white('Files Modified: ')} ${chalk.yellow.bold(data.totals.filesChanged)}`); + console.log(`${chalk.white('Self-Healing Events: ')} ${chalk.magenta.bold(data.totals.healings)}`); + + console.log('\n' + chalk.cyan.bold('🕒 RECENT ACTIVITY')); + data.sessions.slice(-5).reverse().forEach(s => { + const status = s.success ? chalk.green('✔') : chalk.red('✘'); + const date = new Date(s.timestamp).toLocaleDateString(); + console.log(`${status} ${chalk.gray(`[${date}]`)} ${s.task.substring(0, 40)}...`); + }); + console.log(chalk.gray('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n')); +} + +module.exports = { displayInsights }; \ No newline at end of file diff --git a/src/utils/metrics.js b/src/utils/metrics.js new file mode 100644 index 0000000..bc43f38 --- /dev/null +++ b/src/utils/metrics.js @@ -0,0 +1,37 @@ +/** + * Metrics Utility + * * Provides helper functions to process session data and + * calculate productivity "wins" for the user. + */ + +/** + * Calculates estimated time saved based on the complexity of tasks + * @param {Array} sessions - Array of session objects from insights.json + * @returns {string} - Formatted string of time saved + */ +const calculateSavings = (sessions) => { + if (!sessions || sessions.length === 0) return "0 minutes"; + + // We estimate that each file operation or self-healing fix + // saves a developer roughly 10 minutes of manual work. + const ESTIMATED_MINS_SAVED_PER_ACTION = 10; + + const totalActions = sessions.reduce((acc, session) => { + const fileOps = session.filesChanged || 0; + const healings = session.healings || 0; + return acc + fileOps + healings; + }, 0); + + const totalMinutes = totalActions * ESTIMATED_MINS_SAVED_PER_ACTION; + + if (totalMinutes >= 60) { + const hours = (totalMinutes / 60).toFixed(1); + return `${hours} hours`; + } + + return `${totalMinutes} minutes`; +}; + +module.exports = { + calculateSavings +}; \ No newline at end of file diff --git a/test/insights.test.js b/test/insights.test.js new file mode 100644 index 0000000..af2c414 --- /dev/null +++ b/test/insights.test.js @@ -0,0 +1,11 @@ +const insights = require('../src/insights'); +const fs = require('fs'); + +describe('Insights Module', () => { + test('should record and retrieve a session', () => { + const initialTasks = insights.getStats().totals.tasks; + insights.recordSession({ task: 'Test Task', success: true, filesChanged: 2, healings: 1 }); + const updatedStats = insights.getStats(); + expect(updatedStats.totals.tasks).toBe(initialTasks + 1); + }); +}); \ No newline at end of file diff --git a/test/test-custom-prompt.js b/test/test-custom-prompt.js index 82e235d..70063f3 100644 --- a/test/test-custom-prompt.js +++ b/test/test-custom-prompt.js @@ -1,4 +1,4 @@ -const Agent = require('./src/agent'); +const Agent = require('../src/agent'); async function testCustomPrompt() { console.log('Testing custom prompt functionality...');