sam-coder-cli
Version:
SAM-CODER: An animated command-line AI assistant with agency capabilities.
1,419 lines (1,278 loc) • 56 kB
JavaScript
#!/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