UNPKG

sam-coder-cli

Version:

SAM-CODER: An animated command-line AI assistant with agency capabilities.

1,419 lines (1,278 loc) 56 kB
#!/usr/bin/env node const ui = require('./ui.js'); const readline = require('readline'); const path = require('path'); const os = require('os'); const fs = require('fs').promises; const { exec } = require('child_process'); const util = require('util'); const execAsync = util.promisify(exec); // Import AGI Animation module const { runAGIAnimation } = require('./agi-animation.js'); // Configuration const CONFIG_PATH = path.join(os.homedir(), '.sam-coder-config.json'); let OPENROUTER_API_KEY; let MODEL = 'deepseek/deepseek-chat-v3-0324:free'; let API_BASE_URL = 'https://openrouter.ai/api/v1'; let SHOW_THOUGHTS = false; // Optional: reveal <think> content in console // Tool/Function definitions for the AI const tools = [ { type: 'function', function: { name: 'readFile', description: 'Read the contents of a file', parameters: { type: 'object', properties: { path: { type: 'string', description: 'Path to the file to read' } }, required: ['path'] } } }, { type: 'function', function: { name: 'writeFile', description: 'Write content to a file', parameters: { type: 'object', properties: { path: { type: 'string', description: 'Path to the file to write' }, content: { type: 'string', description: 'Content to write to the file' } }, required: ['path', 'content'] } } }, { type: 'function', function: { name: 'editFile', description: 'Edit specific parts of a file', parameters: { type: 'object', properties: { path: { type: 'string', description: 'Path to the file to edit' }, edits: { type: 'object', properties: { operations: { type: 'array', items: { type: 'object', properties: { type: { type: 'string', enum: ['replace', 'insert', 'delete'], description: 'Type of edit operation' }, startLine: { type: 'number', description: 'Starting line number (1-based)' }, endLine: { type: 'number', description: 'Ending line number (1-based, inclusive)' }, newText: { type: 'string', description: 'New text to insert or replace with' }, pattern: { type: 'string', description: 'Pattern to search for (for replace operations)' }, replacement: { type: 'string', description: 'Replacement text (for pattern-based replace)' }, flags: { type: 'string', description: 'Regex flags (e.g., "g" for global)' }, position: { type: 'string', enum: ['start', 'end'], description: 'Where to insert (only for insert operations)' }, line: { type: 'number', description: 'Line number to insert at (for line-based insert)' } }, required: ['type'], oneOf: [ { properties: { type: { const: 'replace' }, startLine: { type: 'number' }, endLine: { type: 'number' }, newText: { type: 'string' } }, required: ['startLine', 'endLine', 'newText'] }, { properties: { type: { const: 'replace' }, pattern: { type: 'string' }, replacement: { type: 'string' }, flags: { type: 'string' } }, required: ['pattern', 'replacement'] }, { properties: { type: { const: 'insert' }, position: { type: 'string', enum: ['start', 'end'] }, text: { type: 'string' } }, required: ['position', 'text'] }, { properties: { type: { const: 'insert' }, line: { type: 'number' }, text: { type: 'string' } }, required: ['line', 'text'] }, { properties: { type: { const: 'delete' }, startLine: { type: 'number' }, endLine: { type: 'number' } }, required: ['startLine', 'endLine'] } ] } } }, required: ['operations'] } }, required: ['path', 'edits'] } } }, { type: 'function', function: { name: 'runCommand', description: 'Execute a shell command', parameters: { type: 'object', properties: { command: { type: 'string', description: 'Command to execute' } }, required: ['command'] } } }, { type: 'function', function: { name: 'searchFiles', description: 'Search for files using a glob pattern', parameters: { type: 'object', properties: { pattern: { type: 'string', description: 'Glob pattern to search for' } }, required: ['pattern'] } } } ]; // System prompt for the AI Assistant when using tool calling const TOOL_CALLING_PROMPT = `You are a helpful AI assistant with agency capabilities. You can perform actions on the user's system using the provided tools. TOOLS AVAILABLE: 1. readFile - Read the contents of a file 2. writeFile - Write content to a file 3. editFile - Edit specific parts of a file 4. runCommand - Execute a shell command 5. searchFiles - Search for files using a glob pattern ENVIRONMENT: - OS: ${process.platform} - Current directory: ${process.cwd()} INSTRUCTIONS: - Use the provided tools to accomplish the user's request - Be concise but thorough in your responses - When executing commands or making changes, explain what you're doing - If you're unsure about a command or action, ask for clarification - Be careful with destructive operations - warn before making changes Always think step by step and explain your reasoning before taking actions that could affect the system.`; // System prompt for the AI Assistant when using legacy function calling (JSON actions) const FUNCTION_CALLING_PROMPT = `You are an autonomous AI agent with advanced problem-solving capabilities. You operate through strategic action sequences to accomplish complex tasks on the user's system. Think like an expert developer and system administrator combined. ## CORE IDENTITY & CAPABILITIES You are not just an assistant - you are an AGENT with: - **Strategic thinking**: Break complex problems into logical action sequences - **Adaptive intelligence**: Learn from action results and adjust your approach - **Domain expertise**: Apply best practices from software development, DevOps, and system administration - **Proactive behavior**: Anticipate needs and handle edge cases before they become problems ## AVAILABLE ACTIONS 1. **read** - Read file contents with intelligent parsing 2. **write** - Create files with proper formatting and structure 3. **edit** - Perform precise, context-aware file modifications 4. **command** - Execute shell commands with error handling 5. **search** - Find files using advanced pattern matching 6. **execute** - Run code with proper environment setup 7. **analyze** - Deep code analysis and architectural insights 8. **stop** - Complete task with comprehensive summary ## ACTION FORMAT Every action must be a JSON object in markdown code blocks: \`\`\`json { "type": "action_name", "data": { /* parameters */ }, "reasoning": "Strategic explanation of this action's purpose and expected outcome" } \`\`\` ## STRATEGIC THINKING FRAMEWORK Before taking actions, consider: 1. **Context Analysis**: What is the current state? What are the constraints? 2. **Goal Decomposition**: Break the objective into logical steps 3. **Risk Assessment**: What could go wrong? How to mitigate? 4. **Dependency Mapping**: What needs to happen before what? 5. **Success Criteria**: How will you know when you've succeeded? ## COMPREHENSIVE EXAMPLES ### Example 1: Investigating a Bug Report *User says: "My Node.js app crashes when I try to upload files"* \`\`\`json { "type": "search", "data": { "type": "files", "pattern": "package.json" }, "reasoning": "First, I need to understand the project structure and dependencies to identify potential upload-related packages and configurations" } \`\`\` Next action after finding package.json: \`\`\`json { "type": "read", "data": { "path": "./package.json" }, "reasoning": "Analyzing dependencies to identify file upload libraries (multer, formidable, etc.) and their versions for potential compatibility issues" } \`\`\` ### Example 2: Setting Up a Development Environment *User says: "Set up a React project with TypeScript and testing"* \`\`\`json { "type": "command", "data": { "command": "node --version && npm --version" }, "reasoning": "Verifying Node.js and npm versions to ensure compatibility with modern React and TypeScript tooling before proceeding with setup" } \`\`\` \`\`\`json { "type": "execute", "data": { "language": "bash", "code": "npx create-react-app my-app --template typescript && cd my-app && npm install --save-dev @testing-library/jest-dom @testing-library/react @testing-library/user-event" }, "reasoning": "Creating a TypeScript React project with comprehensive testing setup, including modern testing utilities for better developer experience" } \`\`\` ### Example 3: Performance Optimization Investigation *User says: "My web app is slow, help me find the bottlenecks"* \`\`\`json { "type": "search", "data": { "type": "files", "pattern": "*.{js,jsx,ts,tsx,json}" }, "reasoning": "Mapping the codebase structure to identify entry points, large files, and potential performance-critical components before diving into specific optimizations" } \`\`\` ### Example 4: Complex File Refactoring *User says: "Refactor this component to use hooks instead of class components"* \`\`\`json { "type": "read", "data": { "path": "./src/components/UserProfile.jsx" }, "reasoning": "Analyzing the existing class component structure, lifecycle methods, and state management to plan the hooks conversion strategy" } \`\`\` \`\`\`json { "type": "edit", "data": { "path": "./src/components/UserProfile.jsx", "edits": { "operations": [ { "type": "replace", "startLine": 1, "endLine": 3, "newText": "import React, { useState, useEffect, useCallback } from 'react';\nimport { debounce } from 'lodash';\n" } ] } }, "reasoning": "Converting class component imports to include necessary hooks, and adding performance optimization utilities like debounce for better UX" } \`\`\` ## ADVANCED BEHAVIORAL PATTERNS ### 🎯 **Proactive Problem Solving** - Always gather context before making changes - Anticipate edge cases and handle them preemptively - Suggest improvements beyond the immediate request ### 🔍 **Intelligent Investigation** - Use multiple information sources (files, commands, searches) - Cross-reference findings to build complete understanding - Document assumptions and validate them through actions ### ⚡ **Efficient Execution** - Batch related operations when possible - Use appropriate tools for each task (don't use command for what read can do better) - Minimize unnecessary file reads by maintaining context ### 🛡️ **Safety & Recovery** - Always backup before destructive operations - Validate inputs and outputs - Provide clear rollback instructions when things go wrong ## ERROR HANDLING & ADAPTATION When actions fail: 1. **Analyze the error**: What specifically went wrong? 2. **Identify root cause**: Is it permissions, missing dependencies, syntax, logic? 3. **Adapt strategy**: Try alternative approaches or tools 4. **Learn and improve**: Update your mental model for future actions Example error recovery: \`\`\`json { "type": "command", "data": { "command": "ls -la /etc/hosts" }, "reasoning": "Permission denied on direct file read - using ls to check file permissions and ownership before attempting alternative access methods" } \`\`\` ## COMMUNICATION EXCELLENCE Your reasoning should be: - **Strategic**: Explain the bigger picture, not just the immediate action - **Educational**: Help the user understand what you're doing and why - **Confident**: Show expertise while remaining humble about uncertainties - **Forward-thinking**: Mention what you'll do next or what to watch for ## ENVIRONMENT CONTEXT - **OS**: ${process.platform} - **Working Directory**: ${process.cwd()} - **Execution Model**: Sequential action loop until 'stop' action - **Scope**: Full system access with user permissions ## MISSION STATEMENT You are not just executing commands - you are solving problems intelligently. Every action should advance toward the goal while building a deeper understanding of the system and user needs. Be the AI agent that developers wish they had: knowledgeable, reliable, proactive, and genuinely helpful. **Begin by thoroughly understanding the user's request, then execute a strategic sequence of actions to achieve their goals efficiently and safely.**`; // Agent utilities const agentUtils = { async readFile(input) { try { const filePath = typeof input === 'string' ? input : input?.path; if (!filePath) throw new Error('readFile: missing path'); const content = await fs.readFile(filePath, 'utf-8'); return content; } catch (error) { throw new Error(`Failed to read file ${filePath}: ${error.message}`); } }, async writeFile(input, maybeContent) { let filePath; try { filePath = typeof input === 'string' ? input : input?.path; const content = typeof input === 'string' ? maybeContent : (input?.content ?? input?.contents ?? input?.data); if (!filePath) throw new Error('writeFile: missing path'); if (typeof content !== 'string') throw new Error('writeFile: missing content'); const dir = path.dirname(filePath); await fs.mkdir(dir, { recursive: true }); await fs.writeFile(filePath, content, 'utf-8'); return `Successfully wrote to ${filePath}`; } catch (error) { const ctx = filePath || (typeof input === 'object' ? input?.path : undefined) || 'unknown path'; throw new Error(`Failed to write to file ${ctx}: ${error.message}`); } }, async editFile(inputPathOrObj, maybeEdits) { try { const targetPath = typeof inputPathOrObj === 'string' ? inputPathOrObj : inputPathOrObj?.path; const edits = typeof inputPathOrObj === 'string' ? maybeEdits : inputPathOrObj?.edits; if (!targetPath) throw new Error('editFile: missing path'); if (!edits) throw new Error('editFile: missing edits'); // Read the current file content let content = await fs.readFile(targetPath, 'utf-8'); const lines = content.split('\n'); // Process each edit operation for (const op of edits.operations) { switch (op.type) { case 'replace': if (op.startLine !== undefined && op.endLine !== undefined) { // Line-based replacement if (op.startLine < 1 || op.endLine > lines.length) { throw new Error(`Line numbers out of range (1-${lines.length})`); } const before = lines.slice(0, op.startLine - 1); const after = lines.slice(op.endLine); const newLines = op.newText.split('\n'); lines.splice(0, lines.length, ...before, ...newLines, ...after); } else if (op.pattern) { // Pattern-based replacement const regex = new RegExp(op.pattern, op.flags || ''); content = content.replace(regex, op.replacement); // Update lines array for subsequent operations lines.length = 0; lines.push(...content.split('\n')); } break; case 'insert': if (op.position === 'start') { lines.unshift(...op.text.split('\n')); } else if (op.position === 'end') { lines.push(...op.text.split('\n')); } else if (op.line !== undefined) { if (op.line < 1 || op.line > lines.length + 1) { throw new Error(`Line number out of range (1-${lines.length + 1})`); } const insertLines = op.text.split('\n'); lines.splice(op.line - 1, 0, ...insertLines); } break; case 'delete': if (op.startLine < 1 || op.endLine > lines.length) { throw new Error(`Line numbers out of range (1-${lines.length})`); } lines.splice(op.startLine - 1, op.endLine - op.startLine + 1); break; default: throw new Error(`Unknown operation type: ${op.type}`); } } // Write the modified content back to the file await fs.writeFile(targetPath, lines.join('\n'), 'utf-8'); return `Successfully edited ${targetPath}`; } catch (error) { throw new Error(`Failed to edit file ${typeof inputPathOrObj === 'string' ? inputPathOrObj : inputPathOrObj?.path}: ${error.message}`); } }, async runCommand(input) { try { const isObj = typeof input === 'object' && input !== null; let command = !isObj ? input : (input.command ?? null); const cmd = isObj ? (input.cmd ?? input.program ?? null) : null; const args = isObj ? (input.args ?? input.params ?? null) : null; const script = isObj ? (input.script ?? null) : null; const shell = isObj ? (input.shell ?? (input.powershell ? 'powershell.exe' : (input.bash ? 'bash' : undefined))) : undefined; const cwdRaw = isObj ? input.cwd : undefined; const envRaw = isObj ? input.env : undefined; const timeout = isObj && typeof input.timeout === 'number' ? input.timeout : undefined; const quoteArg = (a) => { if (a == null) return ''; const s = String(a); return /\s|["']/g.test(s) ? '"' + s.replace(/"/g, '\\"') + '"' : s; }; // Build command from arrays or fields if not provided directly if (!command && cmd) { if (Array.isArray(cmd)) { command = cmd.map(quoteArg).join(' '); } else if (typeof cmd === 'string') { command = cmd; } } if (Array.isArray(command)) { command = command.map(quoteArg).join(' '); } if (command && Array.isArray(args) && args.length) { command = `${command} ${args.map(quoteArg).join(' ')}`; } if (script && !command) { // If only a script is provided, run it directly under the selected shell command = String(script); } if (!command || typeof command !== 'string' || command.trim().length === 0) { throw new Error('runCommand: missing command'); } // Resolve cwd let cwd = process.cwd(); if (typeof cwdRaw === 'string' && cwdRaw.trim().length > 0) { cwd = path.isAbsolute(cwdRaw) ? cwdRaw : path.join(process.cwd(), cwdRaw); } // Merge env const env = envRaw && typeof envRaw === 'object' ? { ...process.env, ...envRaw } : undefined; const { stdout, stderr } = await execAsync(command, { cwd, env, timeout, shell }); if (stderr) { console.error('Command stderr:', stderr); } return stdout || 'Command executed successfully (no output)'; } catch (error) { throw new Error(`Command failed: ${error.message}`); } }, async searchFiles(input) { try { const isString = typeof input === 'string'; let pattern = isString ? input : input?.pattern; let basePathRaw = isString ? null : (input?.path || null); const recursive = isString ? true : (input?.recursive !== false); // Handle wildcards embedded in the path (e.g., C:\\foo\\bar\\*) const hasWildcard = (p) => typeof p === 'string' && /[\*\?]/.test(p); if (!pattern && hasWildcard(basePathRaw)) { pattern = path.basename(basePathRaw); basePathRaw = path.dirname(basePathRaw); } const basePath = basePathRaw ? (path.isAbsolute(basePathRaw) ? basePathRaw : path.join(process.cwd(), basePathRaw)) : process.cwd(); const results = []; const matchByPattern = (name) => { if (!pattern) return true; // if no pattern, match all const escaped = pattern.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&'); const regex = new RegExp('^' + escaped.replace(/\*/g, '.*').replace(/\?/g, '.') + '$'); return regex.test(name); }; // If base path is a file, test it directly try { const stat = await fs.stat(basePath); if (stat.isFile()) { if (matchByPattern(path.basename(basePath))) { results.push(basePath); } return results.length > 0 ? `Found ${results.length} files:\n${results.join('\n')}` : 'No files found'; } } catch (e) { if (e && e.code === 'ENOENT') { return 'No files found'; } // Non-ENOENT errors are handled by traversal below } const search = async (dir) => { let entries; try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch (e) { if (e && e.code === 'ENOENT') return; // directory missing throw e; } for (const entry of entries) { const fullPath = path.join(dir, entry.name); try { if (entry.isDirectory()) { if (recursive) { await search(fullPath); } } else if (matchByPattern(entry.name)) { results.push(fullPath); } } catch (_) { continue; } } }; await search(basePath); return results.length > 0 ? `Found ${results.length} files:\n${results.join('\n')}` : 'No files found'; } catch (error) { throw new Error(`Search failed: ${error.message}`); } } }; // Extract JSON from markdown code blocks function extractJsonFromMarkdown(text) { if (!text || typeof text !== 'string') { return null; } // Try to find a markdown code block with JSON content (case insensitive) const codeBlockRegex = /```(?:json|JSON)\s*([\s\S]*?)\s*```/i; const match = text.match(codeBlockRegex); if (match && match[1]) { try { const jsonStr = match[1].trim(); if (!jsonStr) { return null; } return JSON.parse(jsonStr); } catch (error) { // ignore } } // If no code block found, look for JSON-like patterns in the text const jsonPatterns = [ // Look for objects that start with { and end with } /\{[\s\S]*?\}/g, // Look for arrays that start with [ and end with ] /\[[\s\S]*?\]/g ]; for (const pattern of jsonPatterns) { const matches = text.match(pattern); if (matches) { for (const match of matches) { try { const parsed = JSON.parse(match.trim()); // Validate that it looks like an action object if (parsed && typeof parsed === 'object' && parsed.type) { return parsed; } } catch (error) { // Continue to next match continue; } } } } // If still no valid JSON found, try to parse the entire text as JSON try { const trimmed = text.trim(); if (trimmed.startsWith('{') && trimmed.endsWith('}')) { return JSON.parse(trimmed); } } catch (error) { // Last resort failed } return null; } // Extract and strip <think>...</think> blocks from model output function splitThinking(text) { if (!text || typeof text !== 'string') { return { thought: '', content: text || '' }; } const thinkRegex = /<think>[\s\S]*?<\/think>/gi; let combinedThoughts = []; let match; // Collect all thoughts const singleThinkRegex = /<think>([\s\S]*?)<\/think>/i; let remaining = text; while ((match = remaining.match(singleThinkRegex))) { combinedThoughts.push((match[1] || '').trim()); remaining = remaining.replace(singleThinkRegex, ''); } const visible = remaining.replace(thinkRegex, '').trim(); return { thought: combinedThoughts.join('\n\n').trim(), content: visible }; } // Try to recover tool/function calls embedded in assistant text for thinking models function parseInlineToolCalls(text) { if (!text || typeof text !== 'string') return null; const candidates = []; // 1) JSON code blocks const codeBlockRegex = /```(?:json)?\s*([\s\S]*?)\s*```/gi; let m; while ((m = codeBlockRegex.exec(text)) !== null) { const block = (m[1] || '').trim(); if (block) candidates.push(block); } // 2) <tool_call>...</tool_call> const toolTagRegex = /<tool_call>([\s\S]*?)<\/tool_call>/gi; while ((m = toolTagRegex.exec(text)) !== null) { const inner = (m[1] || '').trim(); if (inner) candidates.push(inner); } // 2b) <function_call>...</function_call> const fnTagRegex = /<function_call>([\s\S]*?)<\/function_call>/gi; while ((m = fnTagRegex.exec(text)) !== null) { const inner = (m[1] || '').trim(); if (inner) candidates.push(inner); } // 3) General JSON-looking substrings as last resort const braceRegex = /\{[\s\S]*?\}/g; const braceMatches = text.match(braceRegex) || []; braceMatches.forEach(snippet => candidates.push(snippet)); const toolCalls = []; for (const candidate of candidates) { try { const obj = JSON.parse(candidate); // OpenAI-style single function_call if (obj && obj.function_call && obj.function_call.name) { const args = obj.function_call.arguments ?? {}; toolCalls.push({ id: `inline-${toolCalls.length + 1}`, type: 'function', function: { name: obj.function_call.name, arguments: typeof args === 'string' ? args : JSON.stringify(args) } }); continue; } // Anthropic-like tool_use if (obj && obj.tool_call && obj.tool_call.name) { const args = obj.tool_call.arguments ?? {}; toolCalls.push({ id: `inline-${toolCalls.length + 1}`, type: 'function', function: { name: obj.tool_call.name, arguments: typeof args === 'string' ? args : JSON.stringify(args) } }); continue; } // Array of tool_calls if (Array.isArray(obj?.tool_calls)) { obj.tool_calls.forEach((tc) => { if (tc?.function?.name) { const args = tc.function.arguments ?? {}; toolCalls.push({ id: tc.id || `inline-${toolCalls.length + 1}`, type: 'function', function: { name: tc.function.name, arguments: typeof args === 'string' ? args : JSON.stringify(args) } }); } }); if (toolCalls.length) continue; } // Direct function structure if (obj?.name && (obj.arguments !== undefined || obj.args !== undefined)) { const args = obj.arguments ?? obj.args ?? {}; toolCalls.push({ id: `inline-${toolCalls.length + 1}`, type: 'function', function: { name: obj.name, arguments: typeof args === 'string' ? args : JSON.stringify(args) } }); continue; } } catch (_) { // ignore parse failures } } return toolCalls.length ? toolCalls : null; } // Normalize single function_call to tool_calls array if present function normalizeToolCallsFromMessage(message) { if (!message || typeof message !== 'object') return message; if (!message.tool_calls && message.function_call && message.function_call.name) { const args = message.function_call.arguments ?? {}; message.tool_calls = [{ id: 'fc-1', type: 'function', function: { name: message.function_call.name, arguments: typeof args === 'string' ? args : JSON.stringify(args) } }]; } return message; } // Parse segmented format like <|start|>channel<|message|>...<|end|> function parseSegmentedTranscript(text) { if (!text || typeof text !== 'string') { return { content: text || '', thought: '', recoveredToolCalls: null, segmented: false }; } const blockRegex = /\<\|start\|>([^<|]+)\<\|message\|>([\s\S]*?)\<\|end\|>/gi; let match; let visibleParts = []; let thoughts = []; let commentaryParts = []; let segmentedFound = false; while ((match = blockRegex.exec(text)) !== null) { const rawRole = (match[1] || '').trim().toLowerCase(); const body = (match[2] || '').trim(); if (!rawRole) continue; segmentedFound = true; if (rawRole === 'analysis') { thoughts.push(body); } else if (rawRole === 'commentary') { commentaryParts.push(body); } else if (rawRole === 'final' || rawRole === 'assistant' || rawRole === 'user' || rawRole === 'system' || rawRole === 'developer') { // Prefer 'final' or 'assistant' as visible, but include others to preserve content order visibleParts.push(body); } else { // Unknown channel: treat as visible content visibleParts.push(body); } } // If no blocks matched, return original if (visibleParts.length === 0 && thoughts.length === 0 && commentaryParts.length === 0) { // Try channel-only segments: allow missing trailing stop tokens; stop at next token or end const chanRegex = /\<\|channel\|>\s*([a-zA-Z]+)\s*(?:to=([^\s<]+))?\s*(?:\<\|constrain\|>(\w+))?\s*\<\|message\|>([\s\S]*?)(?=(?:\<\|start\|>|\<\|channel\|>|\<\|end\|>|\<\|call\|>|\<\|return\|>|$))/gi; let anyChannel = false; let commsWithRecipients = []; while ((match = chanRegex.exec(text)) !== null) { anyChannel = true; segmentedFound = true; const channel = (match[1] || '').trim().toLowerCase(); const recipient = (match[2] || '').trim(); const constraint = (match[3] || '').trim().toLowerCase(); const body = (match[4] || '').trim(); if (channel === 'analysis') { thoughts.push(body); } else if (channel === 'commentary') { if (recipient) { commsWithRecipients.push({ recipient, constraint, body }); } else { // preamble visible to user per spec visibleParts.push(body); } } else if (channel === 'final') { visibleParts.push(body); } else { visibleParts.push(body); } } // Build recovered tool calls from commentary with recipients let recoveredToolCalls = null; if (commsWithRecipients.length) { recoveredToolCalls = []; for (const item of commsWithRecipients) { // recipient format like functions.get_weather let funcName = item.recipient; if (funcName.startsWith('functions.')) { funcName = funcName.slice('functions.'.length); } // parse args (robust: try code blocks, then first JSON object) let args = extractArgsJson(item.body); recoveredToolCalls.push({ id: `inline-${recoveredToolCalls.length + 1}`, type: 'function', function: { name: funcName, arguments: typeof args === 'string' ? args : JSON.stringify(args) } }); } } if (!anyChannel) { return { content: text, thought: '', recoveredToolCalls: null, segmented: false }; } return { content: visibleParts.join('\n\n').trim(), thought: thoughts.join('\n\n').trim(), recoveredToolCalls: recoveredToolCalls && recoveredToolCalls.length ? recoveredToolCalls : null, segmented: segmentedFound }; } // Look for a Reasoning: level outside blocks as a hint const reasoningMatch = text.match(/Reasoning:\s*(high|medium|low)/i); if (reasoningMatch) { thoughts.unshift(`Reasoning level: ${reasoningMatch[1]}`); } // Recover tool calls from commentary channels let recoveredToolCalls = null; if (commentaryParts.length) { for (const part of commentaryParts) { const found = parseInlineToolCalls(part); if (found && found.length) { recoveredToolCalls = (recoveredToolCalls || []).concat(found); } } } return { content: visibleParts.join('\n\n').trim(), thought: thoughts.join('\n\n').trim(), recoveredToolCalls: recoveredToolCalls && recoveredToolCalls.length ? recoveredToolCalls : null, segmented: segmentedFound }; } // Extract first JSON object/array from arbitrary text, including fenced code blocks function extractArgsJson(text) { if (!text || typeof text !== 'string') return {}; // Prefer fenced code block const fence = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/i); if (fence && fence[1]) { try { return JSON.parse(fence[1].trim()); } catch (_) {} } // Try straightforward parse const trimmed = text.trim(); if ((trimmed.startsWith('{') && trimmed.includes('}')) || (trimmed.startsWith('[') && trimmed.includes(']'))) { try { return JSON.parse(trimmed); } catch (_) {} } // Fallback: find first {...} minimally const braceRegex = /\{[\s\S]*?\}/g; const m = braceRegex.exec(text); if (m && m[0]) { try { return JSON.parse(m[0]); } catch (_) {} } // Fallback: find first [...] minimally const arrRegex = /\[[\s\S]*?\]/g; const a = arrRegex.exec(text); if (a && a[0]) { try { return JSON.parse(a[0]); } catch (_) {} } return {}; } // Call OpenRouter API with tool calling async function callOpenRouter(messages, currentModel, useJson = false) { const apiKey = OPENROUTER_API_KEY; const isCustomEndpoint = API_BASE_URL !== 'https://openrouter.ai/api/v1'; let body = { model: currentModel, messages: messages, }; // For standard OpenRouter calls that are not legacy, add tool parameters. if (!isCustomEndpoint && !useJson) { body.tools = tools; body.tool_choice = 'auto'; } // For custom endpoints (like vllm), ensure no tool-related parameters are sent. else if (isCustomEndpoint) { // The body is already clean for vLLM, containing only model and messages. } try { const response = await fetch(`${API_BASE_URL}/chat/completions`, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!response.ok) { if (response.status === 401) { throw new Error('AuthenticationError: Invalid API key. Please run /setup to reconfigure.'); } const error = await response.json(); throw new Error(`API error: ${error.error?.message || response.statusText}`); } return await response.json(); } catch (error) { console.error('API call failed:', error); ui.stopThinking(); throw new Error(`Failed to call OpenRouter API: ${error.message}`); } } // Process a query with tool calling async function processQueryWithTools(query, conversation = [], currentModel) { const userMessage = { role: 'user', content: query }; const messages = [...conversation, userMessage]; ui.startThinking(); try { const response = await callOpenRouter(messages, currentModel); const assistantMessage = response.choices[0].message; // Handle thinking tags and optionally display them if (assistantMessage && typeof assistantMessage.content === 'string') { // First handle segmented transcripts, then fallback to <think> const segmented = parseSegmentedTranscript(assistantMessage.content); let thought = segmented.thought; let content = segmented.content; if (!segmented.thought && !segmented.recoveredToolCalls) { const thinkSplit = splitThinking(assistantMessage.content); thought = thought || thinkSplit.thought; content = content || thinkSplit.content; } if (segmented.recoveredToolCalls && (!assistantMessage.tool_calls)) { assistantMessage.tool_calls = segmented.recoveredToolCalls; } if (thought && SHOW_THOUGHTS) { ui.showThought(thought); } assistantMessage.content = content; } normalizeToolCallsFromMessage(assistantMessage); messages.push(assistantMessage); // Try inline recovery for thinking models that embed tool calls inside content if (!assistantMessage.tool_calls && assistantMessage.content) { const recovered = parseInlineToolCalls(assistantMessage.content); if (recovered && recovered.length) { assistantMessage.tool_calls = recovered; } } if (assistantMessage.tool_calls) { const toolResults = await handleToolCalls(assistantMessage.tool_calls, messages); messages.push(...toolResults); ui.startThinking(); const finalResponseObj = await callOpenRouter(messages, currentModel); const finalAssistantMessage = finalResponseObj.choices[0].message; if (finalAssistantMessage && typeof finalAssistantMessage.content === 'string') { const segmented = parseSegmentedTranscript(finalAssistantMessage.content); let thought = segmented.thought; let content = segmented.content; if (!segmented.thought && !segmented.recoveredToolCalls) { const thinkSplit = splitThinking(finalAssistantMessage.content); thought = thought || thinkSplit.thought; content = content || thinkSplit.content; } if (thought && SHOW_THOUGHTS) { ui.showThought(thought); } finalAssistantMessage.content = content; } normalizeToolCallsFromMessage(finalAssistantMessage); messages.push(finalAssistantMessage); ui.stopThinking(); return { response: finalAssistantMessage.content, conversation: messages }; } else { // Fallback: if no tool_calls were returned, try to parse a JSON action from content (thinking models may embed later) const fallbackAction = extractJsonFromMarkdown(assistantMessage.content); if (fallbackAction && fallbackAction.type) { try { const result = await executeAction(fallbackAction); messages.push({ role: 'user', content: `Action result (${fallbackAction.type}): ${result}` }); ui.startThinking(); const finalResponseObj = await callOpenRouter(messages, currentModel); const finalAssistantMessage = finalResponseObj.choices[0].message; if (finalAssistantMessage && typeof finalAssistantMessage.content === 'string') { const segmented = parseSegmentedTranscript(finalAssistantMessage.content); let thought = segmented.thought; let content = segmented.content; if (!segmented.thought && !segmented.recoveredToolCalls) { const thinkSplit = splitThinking(finalAssistantMessage.content); thought = thought || thinkSplit.thought; content = content || thinkSplit.content; } if (thought && SHOW_THOUGHTS) { ui.showThought(thought); } finalAssistantMessage.content = content; } normalizeToolCallsFromMessage(finalAssistantMessage); messages.push(finalAssistantMessage); ui.stopThinking(); return { response: finalAssistantMessage.content, conversation: messages }; } catch (e) { ui.stopThinking(); // If fallback execution fails, just return original assistant content } } ui.stopThinking(); return { response: assistantMessage.content, conversation: messages }; } } catch (error) { ui.stopThinking(); ui.showError(`Error processing query: ${error.message}`); return { response: `Error: ${error.message}`, conversation: messages }; } } async function handleToolCalls(toolCalls, messages) { const results = []; for (const toolCall of toolCalls) { const functionName = toolCall.function.name; let args; try { args = JSON.parse(toolCall.function.arguments); } catch (error) { console.error('❌ Failed to parse tool arguments:', error); results.push({ tool_call_id: toolCall.id, role: 'tool', name: functionName, content: JSON.stringify({ error: `Invalid arguments format: ${error.message}` }) }); continue; } console.log(`🔧 Executing ${functionName} with args:`, args); try { if (!agentUtils[functionName]) { throw new Error(`Tool '${functionName}' not found`); } let result; if (Array.isArray(args)) { result = await agentUtils[functionName](...args); } else if (args && typeof args === 'object') { result = await agentUtils[functionName](args); } else if (args !== undefined) { result = await agentUtils[functionName](args); } else { result = await agentUtils[functionName](); } console.log('✅ Tool executed successfully'); const resultContent = typeof result === 'string' ? result : JSON.stringify(result); results.push({ tool_call_id: toolCall.id, role: 'tool', name: functionName, content: resultContent }); } catch (error) { console.error('❌ Tool execution failed:', error); results.push({ tool_call_id: toolCall.id, role: 'tool', name: functionName, content: JSON.stringify({ error: error.message, stack: process.env.DEBUG ? error.stack : undefined }) }); } } return results; } async function executeAction(action) { const { type, data } = action; switch (type) { case 'read': return await agentUtils.readFile(data.path); case 'write': return await agentUtils.writeFile(data.path, data.content); case 'edit': return await agentUtils.editFile(data.path, data.edits); case 'command': return await agentUtils.runCommand(data.command); case 'search': if (data.type === 'files') { return await agentUtils.searchFiles(data.pattern); } throw new Error('Text search is not implemented yet'); case 'execute': if (data.language === 'bash' || data.language === 'sh') { return await agentUtils.runCommand(data.code); } else if (data.language === 'node' || data.language === 'javascript' || data.language === 'js') { // For node.js code, escape quotes properly and handle multiline code const escapedCode = data.code.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); return await agentUtils.runCommand(`node -e "${escapedCode}"`); } else { throw new Error(`Unsupported execution language: ${data.language}`); } case 'browse': throw new Error('Web browsing is not implemented yet'); case 'analyze': return `Analysis requested for code: ${data.code}\nQuestion: ${data.question}`; case 'stop': return 'Stopping action execution'; default: throw new Error(`Unknown action type: ${type}`); } } async function processQuery(query, conversation = [], currentModel) { try { const userMessage = { role: 'user', content: query }; // Use existing conversation (which should already have system prompt from chat function) let messages = [...conversation, userMessage]; let actionCount = 0; const MAX_ACTIONS = 10; let finalResponse = ''; ui.startThinking(); while (actionCount < MAX_ACTIONS) { const responseObj = await callOpenRouter(messages, currentModel, true); const assistantMessage = responseObj.choices[0].message; if (assistantMessage && typeof assistantMessage.content === 'string') { const segmented = parseSegmentedTranscript(assistantMessage.content); let thought = segmented.thought; let content = segmented.content; if (!segmented.thought && !segmented.recoveredToolCalls) { const thinkSplit = splitThinking(assistantMessage.content); thought = thought || thinkSplit.thought; content = content || thinkSplit.content; } if (thought && SHOW_THOUGHTS) { ui.showThought(thought); } assistantMessage.content = content; } normalizeToolCallsFromMessage(assistantMessage); messages.push(assistantMessage); const actionData = extractJsonFromMarkdown(assistantMessage.content); // If no valid action found, treat as final response if (!actionData || !actionData.type) { finalResponse = assistantMessage.content; break; } // If stop action, break with the reasoning or content if (actionData.type === 'stop') { finalResponse = actionData.reasoning || assistantMessage.content; break; } actionCount++; console.log(`🔧 Executing action ${actionCount}: ${actionData.type}`); if (actionData.reasoning) { console.log(`📝 Reasoning: ${actionData.reasoning}`); } try { const result = await executeAction(actionData); console.log('✅ Action executed successfully'); // Add action result to conversation const resultMessage = { role: 'user', content: `Action result (${actionData.type}): ${result}` }; messages.push(resultMessage); } catch (error) { console.error('❌ Action execution failed:', error.message); // Add error result to conversation const errorMessage = { role: 'user', content: `Action failed (${actionData.type}): ${error.message}` }; messages.push(errorMessage); } } // If we hit max actions, get a final response if (actionCount >= MAX_ACTIONS && !finalResponse) { const finalMsg = { role: 'user', content: 'Please provide a final summary of what was accomplished.' }; messages.push(finalMsg); const finalResponseObj = await callOpenRouter(messages, currentModel, true); const finalAssistantMessage = finalResponseObj.choices[0].message; if (finalAssistantMessage && typeof finalAssistantMessage.content === 'string') { const { thought, content } = splitThinking(finalAssistantMessage.content); if (thought && SHOW_THOUGHTS) { ui.showThought(thought); } finalResponse = content; } else { finalResponse = finalResponseObj.choices[0].message.content; } messages.push(finalAssistantMessage); } ui.stopThinking(); return { response: finalResponse || 'Task completed.', conversation: messages }; } catch (error) { ui.stopThinking(); console.error('❌ Error during processing:', error); return { response: `An error occurred: ${error.message}`, conversation: conversation }; } } async function chat(rl, useToolCalling, initialModel) { let currentModel = initialModel; const conversation = []; // Initialize conversation with appropriate system prompt if (useToolCalling) { conversation.push({ role: 'system', content: TOOL_CALLING_PROMPT }); } else { conversation.push({ role: 'system', content: FUNCTION_CALLING_PROMPT }); } console.log('Type your message, or "exit" to quit.'); rl.setPrompt('> '); rl.prompt(); rl.on('line', async (input) => { if (input.toLowerCase().startsWith('/model')) { const newModel = input.split(' ')[1]; if (newModel) { currentModel = newModel; let config = await readConfig() || {}; config.MODEL = currentModel; await writeConfig(config); console.log(`Model changed to: ${currentModel}`); } else { console.log('Please specify a model. Usage: /model <model_name>'); } rl.prompt(); return; } if (input.toLowerCase().startsWith('/thoughts')) { const parts = input.trim().split(/\s+/); const arg = parts[1] ? parts[1].toLowerCase() : ''; if (arg !== 'on' && arg !== 'off') { const state = SHOW_THOUGHTS ? 'on' : 'off'; ui.showInfo(`Usage: /thoughts on|off (currently ${state})`); rl.prompt(); return; } const enable = arg === 'on'; SHOW_THOUGHTS = enable; let config = await readConfig