diff --git a/ORCHESTRATOR_INTEGRATION.md b/ORCHESTRATOR_INTEGRATION.md new file mode 100644 index 0000000..3e1ae28 --- /dev/null +++ b/ORCHESTRATOR_INTEGRATION.md @@ -0,0 +1,250 @@ +# plugin-code Orchestrator Integration Guidelines + +## Context + +`plugin-orchestrator` is a new agentic task orchestrator that: +- Runs background tasks with an agentic loop (plan → execute → evaluate → decide) +- Calls runtime actions (like CODE) to execute work +- Tracks progress and detects stalemate (stuck tasks) +- Provides batched notifications to users + +When orchestrator calls the CODE action, it currently only knows: +- ✅ Success or failure +- ✅ Which files were modified +- ❌ What's happening during execution +- ❌ How much progress was made +- ❌ What the agent is currently doing + +## Request: Add Status Provider + +### Why + +The orchestrator needs to evaluate progress after each iteration. Currently it can only see binary success/failure. With a status provider, the orchestrator could: + +1. **Better progress estimation** - Know if agent is 10% or 90% through the task +2. **Better stalemate detection** - See if agent is stuck vs. making progress +3. **Better user feedback** - Show "reading files..." vs. "writing changes..." +4. **Better planning** - Know what the agent tried so it can plan next steps + +### Proposed: `CODE_EXECUTION_STATUS` Provider + +```typescript +/** + * Provider that exposes current coding execution status. + * + * WHY: Orchestrator needs visibility into CODE action execution + * to make better progress estimates and detect stalemate. + */ +export const codeExecutionStatusProvider: Provider = { + name: 'CODE_EXECUTION_STATUS', + description: 'Current status of coding agent execution', + + get: async (runtime: IAgentRuntime, message: Memory, state?: State) => { + const coderService = runtime.getService('coder'); + if (!coderService) { + return { + text: 'Coder service not available', + values: { available: false }, + }; + } + + const status = coderService.getExecutionStatus(); + + return { + text: formatStatus(status), + values: { + // Is a coding task currently running? + isExecuting: status.isExecuting, + + // Which agent is being used? + agent: status.agent, // 'claude-code', 'cursor', 'native', etc. + + // Current phase + phase: status.phase, // 'idle', 'planning', 'reading', 'writing', 'verifying' + + // Files being worked on + currentFile: status.currentFile, + filesRead: status.filesRead, + filesWritten: status.filesWritten, + + // Progress estimate (0-100) + progress: status.progress, + + // Time elapsed + startedAt: status.startedAt, + elapsedMs: status.elapsedMs, + + // Last action taken + lastAction: status.lastAction, + }, + data: status, + }; + }, +}; +``` + +### What CoderService Needs to Track + +```typescript +interface ExecutionStatus { + // Execution state + isExecuting: boolean; + agent: string | null; + conversationId: string | null; + + // Phase tracking + phase: 'idle' | 'planning' | 'reading' | 'writing' | 'verifying'; + + // File tracking + currentFile: string | null; + filesRead: string[]; + filesWritten: string[]; + + // Progress + progress: number; // 0-100 estimate + + // Timing + startedAt: number | null; + elapsedMs: number; + + // History (last few actions for context) + lastAction: string | null; + recentActions: string[]; +} +``` + +### How Agents Update Status + +Each agent implementation should call status updates: + +```typescript +// In NativeCoderAgent.execute(): +async execute(params: ExecuteParams, runtime: IAgentRuntime): Promise { + const coderService = runtime.getService('coder'); + + // Start + coderService.updateStatus({ + isExecuting: true, + agent: 'native', + phase: 'planning', + progress: 10, + }); + + // Reading files + coderService.updateStatus({ + phase: 'reading', + currentFile: 'src/auth.ts', + progress: 30, + }); + + // Writing + coderService.updateStatus({ + phase: 'writing', + currentFile: 'src/auth.ts', + filesWritten: ['src/auth.ts'], + progress: 70, + }); + + // Done + coderService.updateStatus({ + isExecuting: false, + phase: 'idle', + progress: 100, + }); + + return result; +} +``` + +### For CLI Agents (claude, cursor, etc.) + +CLI agents can update status based on output parsing: + +```typescript +// In ClaudeCodeAgent.execute(): +async execute(params: ExecuteParams, runtime: IAgentRuntime): Promise { + const coderService = runtime.getService('coder'); + + coderService.updateStatus({ + isExecuting: true, + agent: 'claude-code', + phase: 'planning', + }); + + // Stream CLI output and parse for status updates + const process = spawn('claude', [...]); + + process.stdout.on('data', (data) => { + const output = data.toString(); + + // Parse for file operations + if (output.includes('Reading')) { + const file = parseFileName(output); + coderService.updateStatus({ + phase: 'reading', + currentFile: file, + filesRead: [...status.filesRead, file], + }); + } + + if (output.includes('Writing')) { + const file = parseFileName(output); + coderService.updateStatus({ + phase: 'writing', + currentFile: file, + filesWritten: [...status.filesWritten, file], + }); + } + }); + + // ... +} +``` + +## How Orchestrator Uses This + +```typescript +// In orchestrator's runOneIteration(): +async function runOneIteration(runtime: IAgentRuntime, task: Task) { + // ... execute CODE action ... + + // After execution, get status for better progress estimation + const statusProvider = runtime.providers.find(p => p.name === 'CODE_EXECUTION_STATUS'); + if (statusProvider) { + const status = await statusProvider.get(runtime, message, state); + + // Use files written as progress indicator + if (status.values.filesWritten?.length > 0) { + // Made tangible progress + progressBonus = 10; + } + + // Use phase for LLM context + lastAction = status.values.lastAction; + } +} +``` + +## Implementation Priority + +1. **High**: Add `ExecutionStatus` tracking to CoderService +2. **High**: Add `CODE_EXECUTION_STATUS` provider +3. **Medium**: Update NativeCoderAgent to report status +4. **Medium**: Update CLI agents to parse output and report status +5. **Low**: Add `recentActions` history for LLM context + +## Benefits + +| Without Status | With Status | +|---------------|-------------| +| "CODE succeeded" | "CODE succeeded: wrote 3 files in 12s using claude-code" | +| Progress: guess | Progress: based on files read/written | +| Stalemate: after 3 failures | Stalemate: detect stuck at "reading" phase | +| User sees: "Running..." | User sees: "Writing src/auth.ts..." | + +## Questions for Discussion + +1. Should status be per-conversation or global? +2. How much history to keep in `recentActions`? +3. Should progress be auto-calculated or agent-provided? +4. Should we emit events in addition to provider? diff --git a/README.md b/README.md index 8e5f1e6..545afe7 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,28 @@ # @elizaos/plugin-code -A coder tools plugin for elizaOS that provides filesystem, shell, and git capabilities in a restricted/sandboxed environment. +A unified code toolkit for elizaOS that provides filesystem, shell, git, and AI-powered coding capabilities. ## Features -- **File Operations**: Read, write, edit, list, and search files -- **Shell Execution**: Run shell commands in a sandboxed directory +### Core File Operations +- **Read, Write, Edit**: Full file manipulation +- **List, Search**: Directory browsing and content search +- **Shell Execution**: Run commands in a sandboxed directory - **Git Commands**: Execute git operations -- **Directory Navigation**: Change working directory (restricted) -- **Safety Controls**: Command filtering, path validation, forbidden command blocking +- **Safety Controls**: Path validation, command filtering, forbidden command blocking + +### AI Coding Agents +- **Native Agent**: Uses elizaOS LLM + file actions (always available) +- **Claude Code**: Anthropic's official CLI (recommended) +- **Cursor Agent**: Cursor IDE agent with stream-json output +- **Aider**: AI pair programming in terminal +- **Codex**: OpenAI's official CLI +- **OpenCode**: Multi-provider coding assistant + +### Learning & Stats +- **Lessons**: Track failures and learn from them +- **Stats**: Monitor agent performance over time +- **Dynamic Providers**: Multi-resolution context for LLM prompts ## Installation @@ -18,14 +32,30 @@ bun add @elizaos/plugin-code ## Configuration -Set the following environment variables: +### Core Settings | Variable | Description | Required | Default | |----------|-------------|----------|---------| | `CODER_ENABLED` | Enable/disable the plugin | No | `false` | | `CODER_ALLOWED_DIRECTORY` | Directory for operations | Yes | `process.cwd()` | | `CODER_TIMEOUT` | Command timeout (ms) | No | `30000` | -| `CODER_FORBIDDEN_COMMANDS` | Additional forbidden commands (comma-separated) | No | - | +| `CODER_FORBIDDEN_COMMANDS` | Additional forbidden commands | No | - | + +### AI Agent Settings + +| Variable | Description | Values | Default | +|----------|-------------|--------|---------| +| `CODE_MODE` | Agent selection mode | `auto`, `native`, `cli` | `auto` | +| `CODE_CLAUDE_MODEL` | Model for Claude Code | Model name | - | +| `CODE_CURSOR_MODEL` | Model for Cursor | Model name | - | +| `CODE_AIDER_MODEL` | Model for Aider | Provider/model | - | +| `CODE_CODEX_MODEL` | Model for Codex | Model name | - | +| `CODE_OPENCODE_MODEL` | Model for OpenCode | Model name | - | + +### CODE_MODE Values +- `auto` (default): Use CLI agent if available, otherwise native +- `native`: Always use elizaOS native coding (LLM + file actions) +- `cli`: Always use CLI agents (error if none available) ## Usage @@ -41,35 +71,160 @@ const agent = { ## Actions +### File Operations + | Action | Description | Similes | |--------|-------------|---------| -| `READ_FILE` | Read file contents | VIEW_FILE, OPEN_FILE, CAT_FILE, SHOW_FILE, GET_FILE | -| `WRITE_FILE` | Create or overwrite a file | CREATE_FILE, SAVE_FILE, OUTPUT_FILE | -| `EDIT_FILE` | Replace substring in file | REPLACE_IN_FILE, PATCH_FILE, MODIFY_FILE | -| `LIST_FILES` | List directory contents | LS, LIST_DIR, LIST_DIRECTORY, DIR | -| `SEARCH_FILES` | Search text in files | GREP, RG, FIND_IN_FILES, SEARCH | +| `READ_FILE` | Read file contents | VIEW_FILE, OPEN_FILE, CAT_FILE | +| `WRITE_FILE` | Create or overwrite a file | CREATE_FILE, SAVE_FILE | +| `EDIT_FILE` | Replace substring in file | REPLACE_IN_FILE, PATCH_FILE | +| `LIST_FILES` | List directory contents | LS, LIST_DIR | +| `SEARCH_FILES` | Search text in files | GREP, FIND_IN_FILES | | `CHANGE_DIRECTORY` | Change working directory | CD, CWD | -| `EXECUTE_SHELL` | Run shell command | SHELL, RUN_COMMAND, EXEC, TERMINAL | -| `GIT` | Run git command | GIT_COMMAND, GIT_RUN | +| `EXECUTE_SHELL` | Run shell command | SHELL, RUN_COMMAND, EXEC | +| `GIT` | Run git command | GIT_COMMAND | + +### AI Coding Actions + +| Action | Description | Availability | +|--------|-------------|--------------| +| `CODE` | Execute with best agent | Always | +| `CLAUDE_CODE` | Execute with Claude Code | If CLI detected | +| `CURSOR` | Execute with Cursor | If CLI detected | +| `AIDER` | Execute with Aider | If CLI detected | +| `CODEX` | Execute with Codex | If CLI detected | +| `OPENCODE` | Execute with OpenCode | If CLI detected | +| `DETECT_AGENTS` | List available agents | Always | + +### Learning Actions + +| Action | Description | +|--------|-------------| +| `VIEW_LESSONS` | Show failure history | +| `SHOW_STATS` | Show agent performance | + +## Providers + +### Core Providers + +| Provider | Description | +|----------|-------------| +| `CODER_STATUS` | Current directory, history, recent operations | +| `CODE_HELP` | Usage instructions for the agent to guide users | +| `CODE_SETTINGS` | Current configuration (mode, agents, directories) | + +### Execution Status (for Orchestrator) + +| Provider | Description | +|----------|-------------| +| `CODE_EXECUTION_STATUS_OVERVIEW` | One-line: `CODE_STATUS: writing \| agent=claude-code \| progress=45%` | +| `CODE_EXECUTION_STATUS` | Structured status with files read/written | +| `CODE_EXECUTION_STATUS_FULL` | Complete with action history (CSV) | + +**Why Execution Status?** The orchestrator needs visibility into what's happening during code execution: +- Progress based on actual file activity (not guessing) +- User sees "Writing src/auth.ts..." instead of "Running..." +- Smart decisions: retry on error, adjust timeout -## Provider +### Dynamic Multi-Resolution Providers -The `CODER_STATUS` provider supplies context about: -- Current working directory -- Allowed directory -- Recent command history -- Recent file operations +| Provider | Resolution | Description | +|----------|------------|-------------| +| `CODE_AGENTS_OVERVIEW` | Low | Agent count and names | +| `CODE_AGENTS` | Medium | CSV: name, available, recommended | +| `CODE_AGENTS_FULL` | High | Full agent details | +| `CODE_LESSONS_OVERVIEW` | Low | Lesson count | +| `CODE_LESSONS` | Medium | CSV of recent lessons | +| `CODE_LESSONS_FULL` | High | Full lesson details | +| `CODE_STATS_OVERVIEW` | Low | Best agent, success rate | +| `CODE_STATS` | Medium | CSV per agent | +| `CODE_STATS_FULL` | High | Detailed breakdown | + +All dynamic providers are marked `dynamic: true` and not auto-included in prompts. + +## How CODE_MODE Works + +``` +User: "fix the bug in utils.ts" + | + v + CODE action + | + v + Check CODE_MODE + | + +---+---+ + | | + auto native/cli + | | + v v +Prefer CLI → Native fallback + | + v +Execute with selected agent + | + v +Return result (success/failure) +``` + +## Storage + +Lessons and stats are stored on disk in the project directory: +- `.plugin-code/lessons.json` - Failure history +- `.plugin-code/stats.json` - Performance stats + +This enables persistence across restarts and sharing between sessions. ## Security The plugin implements several security measures: -1. **Path Validation**: All file operations are restricted to `CODER_ALLOWED_DIRECTORY` +1. **Path Validation**: All file operations restricted to `CODER_ALLOWED_DIRECTORY` 2. **Command Filtering**: Blocks shell control operators (`&&`, `||`, `;`, `$(`, backticks) 3. **Forbidden Commands**: Blocks dangerous commands like `rm -rf /`, `sudo rm`, etc. -4. **Timeout**: Commands are killed after `CODER_TIMEOUT` milliseconds +4. **Timeout**: Commands killed after `CODER_TIMEOUT` milliseconds 5. **Disabled by Default**: Must explicitly set `CODER_ENABLED=true` +## Architecture + +``` +plugin-code +├── Services +│ ├── CoderService # File ops, shell, working directory +│ ├── AgentRegistry # Manages all coding agents +│ ├── ExecutionTrackerService # Tracks execution status per conversation +│ ├── LessonsService # Tracks failures +│ └── StatsService # Tracks performance +├── Agents +│ ├── NativeCoderAgent # elizaOS LLM + file actions +│ ├── ClaudeCodeAgent # Claude Code CLI +│ ├── CursorAgent # Cursor Agent CLI +│ ├── AiderAgent # Aider CLI +│ ├── CodexAgent # Codex CLI +│ └── OpenCodeAgent # OpenCode CLI +├── Actions +│ ├── File ops # read, write, edit, etc. +│ ├── Agent actions # CODE, CLAUDE_CODE, etc. +│ └── PRR actions # VIEW_LESSONS, SHOW_STATS +└── Providers + ├── coderStatusProvider # Core status + ├── help.provider # Usage instructions + ├── settings.provider # Configuration + ├── executionStatus.provider # 3 resolutions (for orchestrator) + ├── agents.providers # 3 resolutions + ├── lessons.providers # 3 resolutions + └── stats.providers # 3 resolutions +``` + +## Integration with plugin-orchestrator + +This plugin works standalone, but also integrates seamlessly with `plugin-orchestrator`: + +- **Standalone**: User says "fix bug" → CODE action → done +- **With Orchestrator**: Orchestrator calls CODE action as part of complex task workflow + +The CODE action executes once and returns - retries, task lifecycle, and notifications are handled by the orchestrator. + ## License MIT diff --git a/WORKSPACES.md b/WORKSPACES.md new file mode 100644 index 0000000..8491496 --- /dev/null +++ b/WORKSPACES.md @@ -0,0 +1,388 @@ +# Workspace Management in plugin-code + +## Overview + +The workspace system provides a managed directory structure for working with any codebase - git repos, archives, or local projects. Similar to GitHub Codespaces or Replit, it handles cloning, extracting, and organizing projects in a secure, isolated manner. + +## Architecture + +``` +~/.eliza-workspaces/ +├── .metadata/ +│ ├── workspaces.json # Registry of all workspaces +│ └── .json # Per-workspace metadata (future) +├── facebook-react/ # Cloned from git +├── my-python-project/ # Extracted from zip +├── local-experiment/ # Created locally +└── nextjs-app-2025/ # Another project +``` + +## Key Features + +### 🌐 Multiple Source Types +- **Git Clone**: Clone any repository from GitHub, GitLab, Bitbucket, etc. +- **Archive Import**: Extract from .zip or .tar.gz URLs +- **Local Creation**: Create empty workspace for new projects +- **Future**: Symlink support for development mode + +### 🔒 Security Model +- Each conversation/session is locked to ONE active workspace at a time +- `CODER_ALLOWED_DIRECTORY` automatically set to active workspace path +- All file/shell operations restricted to current workspace +- Parent directory (`~/.eliza-workspaces/`) cannot be accessed by agents + +### 📊 Auto-Detection +Workspaces automatically detect: +- **Languages**: JavaScript, TypeScript, Python, Rust, Go +- **Frameworks**: React, Next.js, Vue, elizaOS +- **Package Managers**: bun, npm, yarn, pnpm, cargo, go, pip + +## Configuration + +### Environment Variables + +```bash +# Workspace root directory (optional - defaults shown) +ELIZA_WORKSPACES_ROOT=~/.eliza-workspaces + +# Strict mode - enforce workspace directory constraint +# When true, CODER_ALLOWED_DIRECTORY must be inside workspaces root +ELIZA_WORKSPACES_STRICT=false + +# Legacy config still works +CODER_ENABLED=true +CODER_ALLOWED_DIRECTORY=/path/to/project # Auto-set from active workspace +``` + +### Character File + +```json +{ + "plugins": ["@elizaos/plugin-code"], + "settings": { + "CODER_ENABLED": true, + "ELIZA_WORKSPACES_ROOT": "~/.eliza-workspaces" + } +} +``` + +## Usage + +### Creating Workspaces + +**From Git Repository:** +``` +"Clone https://github.com/facebook/react" +"Clone the Next.js repo from GitHub" +"Import https://github.com/user/repo branch develop" +``` + +**From Archive:** +``` +"Import project from https://example.com/project.zip" +"Extract this archive: https://example.com/app.tar.gz" +``` + +**Local Workspace:** +``` +"Create local workspace named my-project" +"Create a new workspace called test-app" +``` + +### Managing Workspaces + +**List All Workspaces:** +``` +"Show me all workspaces" +"List workspaces" +``` + +**Switch Workspace:** +``` +"Switch to workspace react" +"Use the my-project workspace" +``` + +**Delete Workspace:** +``` +"Delete workspace old-project yes delete" +"Remove workspace test yes delete" +``` + +⚠️ **Note**: Deletion requires explicit "yes delete" confirmation + +### Working with Files + +Once a workspace is active, all existing file operations work within that workspace: + +``` +"Read the package.json file" +"List files in src/" +"Edit app.js and change..." +"Run npm install" +``` + +## Integration with CoderService + +The `CoderService` automatically integrates with `WorkspaceService`: + +```typescript +// CoderService checks for active workspace first +getAllowedDirectory(conversationId) { + const workspaceService = runtime.getService('workspace'); + const activeWorkspace = workspaceService.getActiveWorkspace(conversationId); + + if (activeWorkspace) { + return activeWorkspace.path; // Use workspace path + } + + return this.coderConfig.allowedDirectory; // Fallback to config +} +``` + +## Actions Reference + +### CREATE_WORKSPACE +Creates a new workspace from various sources. + +**Triggers:** `CLONE_REPO`, `IMPORT_PROJECT`, `NEW_WORKSPACE`, `SETUP_PROJECT` + +**Examples:** +- `"Clone https://github.com/facebook/react"` +- `"Create workspace from https://example.com/project.zip"` +- `"Create local workspace named my-project"` + +### LIST_WORKSPACES +Lists all available workspaces with details. + +**Triggers:** `SHOW_WORKSPACES`, `WORKSPACES`, `LIST_PROJECTS` + +**Shows:** +- Workspace name and path +- Source (git URL, archive URL, or local) +- Framework and language detection +- Last accessed time +- Active status + +### SWITCH_WORKSPACE +Switches to a different workspace. + +**Triggers:** `USE_WORKSPACE`, `CHANGE_WORKSPACE`, `ACTIVATE_WORKSPACE` + +**Example:** `"Switch to workspace react"` + +### DELETE_WORKSPACE +Permanently deletes a workspace. + +**Triggers:** `REMOVE_WORKSPACE`, `DESTROY_WORKSPACE` + +**Example:** `"Delete workspace old-project yes delete"` + +⚠️ **Requires confirmation**: Must include "yes delete" in the request + +## Providers + +### WORKSPACE_STATUS +Provides information about the current workspace state. + +**Includes:** +- Active workspace name and path +- Source information (git/archive/local) +- Detected framework and languages +- Package manager +- List of other available workspaces + +**Used by:** Agent prompts to understand current working context + +## API Reference + +### WorkspaceService + +```typescript +// Create workspace from git +const workspace = await workspaceService.createFromGit(repoUrl, { + branch: 'main', // optional + name: 'my-repo' // optional +}); + +// Create from archive +const workspace = await workspaceService.createFromArchive(archiveUrl, { + name: 'my-project' // optional +}); + +// Create local workspace +const workspace = await workspaceService.createLocal('project-name'); + +// Get active workspace +const workspace = workspaceService.getActiveWorkspace(conversationId); + +// Set active workspace +await workspaceService.setActiveWorkspace(conversationId, workspaceId); + +// List all workspaces +const workspaces = workspaceService.listWorkspaces(); + +// Delete workspace +await workspaceService.deleteWorkspace(workspaceId); +``` + +## Workspace Data Structure + +```typescript +interface Workspace { + id: string; // Unique UUID + name: string; // Display name + path: string; // Absolute filesystem path + source: { + type: 'git' | 'zip' | 'local' | 'symlink'; + url?: string; // Original source URL + branch?: string; // Git branch (if git) + commit?: string; // Git commit hash (if git) + }; + created: Date; + lastAccessed: Date; + active: boolean; // Currently active? + metadata: { + language?: string[]; // Detected languages + framework?: string; // Detected framework + packageManager?: string; // Detected package manager + }; +} +``` + +## Migration from Existing Setup + +If you're currently using `CODER_ALLOWED_DIRECTORY`: + +**Option 1: Keep Existing Behavior** +```bash +# Don't set ELIZA_WORKSPACES_ROOT - continues using CODER_ALLOWED_DIRECTORY +CODER_ENABLED=true +CODER_ALLOWED_DIRECTORY=/path/to/my/project +``` + +**Option 2: Migrate to Workspaces** +```bash +# Set workspaces root +ELIZA_WORKSPACES_ROOT=~/.eliza-workspaces + +# Create workspace from existing project (symlink coming soon) +# For now: copy or clone project into ~/.eliza-workspaces/ +cp -r /path/to/my/project ~/.eliza-workspaces/my-project +``` + +Then in chat: `"Switch to workspace my-project"` + +## Future Enhancements + +- **Symlink Support**: Link to existing directories without copying +- **Workspace Templates**: Pre-configured project templates +- **Collaborative Workspaces**: Share workspace state across agents +- **Automatic Cleanup**: Archive old/unused workspaces +- **Git Integration**: Auto-update, branch management, PR creation +- **Resource Limits**: Disk space quotas per workspace +- **Workspace Snapshots**: Save/restore workspace state + +## Examples + +### Complete Workflow + +``` +User: "Clone the React repository" +Agent: 🔄 Cloning repository from https://github.com/facebook/react... +Agent: ✅ Workspace created and activated! + **react** + 📂 Path: `~/.eliza-workspaces/react` + 🔗 Source: git (https://github.com/facebook/react) + 🎯 Framework: React + 📝 Languages: javascript, typescript + 📦 Package Manager: yarn + +User: "List all files in src/" +Agent: [Lists React source files...] + +User: "Run yarn install" +Agent: [Executes yarn install in workspace...] + +User: "Switch to my other project" +Agent: [Shows list of workspaces...] + +User: "Create a new workspace called test-app" +Agent: 📁 Creating local workspace "test-app"... +Agent: ✅ Workspace created! + +User: "Create index.js with hello world" +Agent: [Creates file in test-app workspace...] +``` + +## Troubleshooting + +### Workspace Not Found +``` +Error: Workspace "xyz" not found +``` +**Solution**: Run `"List workspaces"` to see available workspaces + +### Git Clone Failed +``` +Error: Failed to clone repository +``` +**Common causes:** +- Invalid URL +- Private repository without authentication +- Network issues + +**Solution**: Ensure URL is public or configure Git credentials + +### Out of Disk Space +``` +Error: ENOSPC: no space left on device +``` +**Solution**: Delete unused workspaces: +``` +"Delete workspace old-project yes delete" +``` + +### Path Access Denied +``` +Error: Cannot access path outside allowed directory +``` +**Explanation**: Security feature - all operations restricted to active workspace + +**Solution**: Ensure you're working within the current workspace or switch workspaces + +## Security Considerations + +1. **Directory Isolation**: Agents cannot access files outside active workspace +2. **Parent Directory Protection**: `~/.eliza-workspaces/` root is not accessible +3. **Command Restrictions**: All `CODER_FORBIDDEN_COMMANDS` still apply +4. **Git Authentication**: Credentials should be configured at OS level, not in agent +5. **Archive Sources**: Only download from trusted URLs + +## Best Practices + +1. **Naming**: Use descriptive, lowercase names with hyphens +2. **Organization**: Group related workspaces by project/client +3. **Cleanup**: Regularly delete unused workspaces +4. **Git Repos**: Prefer cloning over uploading archives for better version control +5. **Large Files**: Avoid workspaces with >1GB of files (performance) + +## Comparison to Other Tools + +| Feature | Eliza Workspaces | GitHub Codespaces | Replit | VS Code Dev Containers | +|---------|------------------|-------------------|--------|------------------------| +| **Chat Interface** | ✅ | ❌ | ❌ | ❌ | +| **Git Clone** | ✅ | ✅ | ✅ | ✅ | +| **Archive Import** | ✅ | ❌ | ✅ | ❌ | +| **Local Creation** | ✅ | ❌ | ✅ | ✅ | +| **Auto-Detection** | ✅ | ✅ | ✅ | ✅ | +| **Multi-Workspace** | ✅ | ✅ | ✅ | ❌ | +| **Offline Mode** | ✅ | ❌ | ❌ | ✅ | +| **AI Coding Agents** | ✅ | ❌ | ❌ | ❌ | + +--- + +**Need Help?** Ask your agent: +- `"How do workspaces work?"` +- `"Show me workspace commands"` +- `"What workspace am I in?"` diff --git a/package.json b/package.json index 78a1aa0..f98cd66 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@elizaos/plugin-code", "version": "1.6.6-alpha.5", - "description": "Coder tools plugin for elizaos (filesystem + shell + git)", + "description": "Coder tools plugin for elizaOS (filesystem + shell + git)", "type": "module", "main": "dist/index.js", "module": "dist/index.js", @@ -35,7 +35,8 @@ "author": "elizaOS", "license": "MIT", "dependencies": { - "@elizaos/core": "workspace:*" + "@elizaos/core": "workspace:*", + "uuid": "^11.1.0" }, "devDependencies": { "@elizaos/config": "workspace:*", @@ -59,7 +60,7 @@ "access": "public" }, "agentConfig": { - "pluginType": "elizaos:plugin:1.0.0", + "pluginType": "elizaOS:plugin:1.0.0", "pluginParameters": { "CODER_ENABLED": { "type": "boolean", @@ -86,6 +87,13 @@ "description": "Comma-separated list of additional forbidden commands", "required": false, "sensitive": false + }, + "ELIZA_WORKSPACES_ROOT": { + "type": "string", + "description": "Root directory for managed workspaces (defaults to ~/.eliza-workspaces)", + "required": false, + "default": "~/.eliza-workspaces", + "sensitive": false } } } diff --git a/src/actions/agents/aider.ts b/src/actions/agents/aider.ts new file mode 100644 index 0000000..7ecf574 --- /dev/null +++ b/src/actions/agents/aider.ts @@ -0,0 +1,95 @@ +/** + * AIDER Action - Execute coding tasks with Aider CLI + * + * Use when user explicitly requests Aider: + * - "use aider to fix this" + * - "with aider, add tests" + */ + +import type { Action, IAgentRuntime, Memory, State, HandlerCallback } from '@elizaos/core'; +import { logger } from '@elizaos/core'; +import { AgentRegistry } from '../../services/agentRegistry.service.ts'; +import type { CoderService } from '../../services/coderService.ts'; +import type { ExecuteParams } from '../../interfaces/ICodingAgent.ts'; + +export const aiderAction: Action = { + name: 'AIDER', + description: 'Execute coding task with Aider CLI. Use when user says "use aider".', + similes: ['use aider', 'aider', 'with aider', 'using aider'], + + validate: async (runtime: IAgentRuntime): Promise => { + const registry = AgentRegistry.getInstance(); + const reg = registry.get('aider'); + return reg?.isAvailable ?? false; + }, + + handler: async ( + runtime: IAgentRuntime, + message: Memory, + _state: State | undefined, + _options: Record | undefined, + callback: HandlerCallback + ) => { + const registry = AgentRegistry.getInstance(); + const coderService = runtime.getService('coder'); + + if (!coderService) { + await callback({ text: 'CoderService not available.' }); + return { success: false, error: 'CoderService not available' }; + } + + const reg = registry.get('aider'); + if (!reg?.isAvailable) { + await callback({ + text: 'Aider CLI not available. Install with: pip install aider-chat', + }); + return { success: false, error: 'Aider not available' }; + } + + const conversationId = message.roomId; + const projectPath = coderService.getCurrentDirectory(conversationId); + const prompt = + typeof message.content === 'string' ? message.content : message.content?.text || ''; + + if (!prompt.trim()) { + await callback({ text: 'Please provide a coding task description.' }); + return { success: false, error: 'Empty prompt' }; + } + + await callback({ text: 'Using Aider to execute coding task...' }); + + const params: ExecuteParams = { + prompt, + projectPath, + conversationId, + agentType: 'aider', + }; + + try { + const result = await reg.agent.execute(params, runtime); + + if (result.success) { + const filesMsg = result.modifiedFiles?.length + ? `Modified: ${result.modifiedFiles.join(', ')}` + : 'Task completed'; + + await callback({ text: `Done! ${filesMsg}` }); + + return { + success: true, + text: 'Task completed with Aider', + values: { agent: 'aider', modifiedFiles: result.modifiedFiles || [] }, + data: { actionName: 'AIDER', result }, + }; + } else { + await callback({ text: `Failed: ${result.error}` }); + return { success: false, error: result.error }; + } + } catch (error: unknown) { + const err = error as Error; + logger.error('[AIDER] Error:', err); + await callback({ text: `Error: ${err.message}` }); + return { success: false, error: err.message }; + } + }, +}; diff --git a/src/actions/agents/claudeCode.ts b/src/actions/agents/claudeCode.ts new file mode 100644 index 0000000..4ace2f0 --- /dev/null +++ b/src/actions/agents/claudeCode.ts @@ -0,0 +1,97 @@ +/** + * CLAUDE_CODE Action - Execute coding tasks with Claude Code CLI + * + * Use when user explicitly requests Claude Code: + * - "use claude to fix this" + * - "with claude code, add tests" + * - "claude code: implement login" + */ + +import type { Action, IAgentRuntime, Memory, State, HandlerCallback } from '@elizaos/core'; +import { logger } from '@elizaos/core'; +import { AgentRegistry } from '../../services/agentRegistry.service.ts'; +import type { CoderService } from '../../services/coderService.ts'; +import type { ExecuteParams } from '../../interfaces/ICodingAgent.ts'; + +export const claudeCodeAction: Action = { + name: 'CLAUDE_CODE', + description: + 'Execute coding task with Claude Code CLI. Use when user says "use claude" or "with claude code".', + similes: ['use claude', 'claude code', 'with claude', 'using claude'], + + validate: async (runtime: IAgentRuntime): Promise => { + const registry = AgentRegistry.getInstance(); + const reg = registry.get('claude-code'); + return reg?.isAvailable ?? false; + }, + + handler: async ( + runtime: IAgentRuntime, + message: Memory, + _state: State | undefined, + _options: Record | undefined, + callback: HandlerCallback + ) => { + const registry = AgentRegistry.getInstance(); + const coderService = runtime.getService('coder'); + + if (!coderService) { + await callback({ text: 'CoderService not available.' }); + return { success: false, error: 'CoderService not available' }; + } + + const reg = registry.get('claude-code'); + if (!reg?.isAvailable) { + await callback({ + text: 'Claude Code CLI not available. Install from: https://claude.com/claude-code', + }); + return { success: false, error: 'Claude Code not available' }; + } + + const conversationId = message.roomId; + const projectPath = coderService.getCurrentDirectory(conversationId); + const prompt = + typeof message.content === 'string' ? message.content : message.content?.text || ''; + + if (!prompt.trim()) { + await callback({ text: 'Please provide a coding task description.' }); + return { success: false, error: 'Empty prompt' }; + } + + await callback({ text: 'Using Claude Code to execute coding task...' }); + + const params: ExecuteParams = { + prompt, + projectPath, + conversationId, + agentType: 'claude-code', + }; + + try { + const result = await reg.agent.execute(params, runtime); + + if (result.success) { + const filesMsg = result.modifiedFiles?.length + ? `Modified: ${result.modifiedFiles.join(', ')}` + : 'Task completed'; + + await callback({ text: `Done! ${filesMsg}` }); + + return { + success: true, + text: 'Task completed with Claude Code', + values: { agent: 'claude-code', modifiedFiles: result.modifiedFiles || [] }, + data: { actionName: 'CLAUDE_CODE', result }, + }; + } else { + await callback({ text: `Failed: ${result.error}` }); + return { success: false, error: result.error }; + } + } catch (error: unknown) { + const err = error as Error; + logger.error('[CLAUDE_CODE] Error:', err); + await callback({ text: `Error: ${err.message}` }); + return { success: false, error: err.message }; + } + }, +}; diff --git a/src/actions/agents/code.ts b/src/actions/agents/code.ts new file mode 100644 index 0000000..2fb38d9 --- /dev/null +++ b/src/actions/agents/code.ts @@ -0,0 +1,244 @@ +/** + * CODE Action - Execute coding tasks using the best available AI agent + * + * WHY THIS ACTION: + * - Universal entry point for AI-assisted coding + * - Respects CODE_MODE setting (auto, native, cli) + * - Automatically selects the best available agent + * - Works standalone or with plugin-orchestrator + * - Reports execution status via ExecutionTrackerService for observability + * + * CODE_MODE VALUES: + * - 'auto' (default): Use CLI if available, otherwise native + * - 'native': Always use elizaOS native coding (LLM + file actions) + * - 'cli': Always use CLI agents (error if none available) + * + * EXAMPLES: + * - "fix the bug in utils.ts" -> Uses best available agent + * - "implement the login feature" -> Uses best available agent + * - "using cursor, add tests" -> Would use CURSOR action instead + */ + +import type { Action, IAgentRuntime, Memory, State, HandlerCallback } from '@elizaos/core'; +import { logger } from '@elizaos/core'; +import { AgentRegistry } from '../../services/agentRegistry.service.ts'; +import type { CoderService } from '../../services/coderService.ts'; +import type { ExecutionTrackerService } from '../../services/executionTracker.service.ts'; +import type { ExecuteParams } from '../../interfaces/ICodingAgent.ts'; + +export const codeAction: Action = { + name: 'CODE', + description: + 'Execute a coding task using the best available AI coding agent. Supports native (elizaOS LLM) or CLI tools (Claude Code, Cursor, Aider, Codex, OpenCode). Use for writing, fixing, or modifying code.', + similes: [ + 'write code', + 'fix code', + 'implement', + 'code this', + 'make changes', + 'update the code', + 'modify', + 'refactor', + 'add feature', + 'fix bug', + 'create file', + 'edit file', + ], + + validate: async (runtime: IAgentRuntime): Promise => { + // Requires CoderService to be available + const coderService = runtime.getService('coder'); + if (!coderService) { + logger.warn('[CODE] CoderService not available'); + return false; + } + + // Check that we have at least one agent available + const registry = AgentRegistry.getInstance(); + const status = registry.getStatus(); + + if (status.available === 0) { + logger.warn('[CODE] No coding agents available'); + return false; + } + + return true; + }, + + handler: async ( + runtime: IAgentRuntime, + message: Memory, + state: State | undefined, + options: Record | undefined, + callback: HandlerCallback + ) => { + const registry = AgentRegistry.getInstance(); + const coderService = runtime.getService('coder'); + const tracker = runtime.getService('execution-tracker'); + + if (!coderService) { + await callback({ text: 'CoderService not available. Cannot execute coding tasks.' }); + return { success: false, error: 'CoderService not available' }; + } + + // Get CODE_MODE setting + const mode = (runtime.getSetting('CODE_MODE') as string) || 'auto'; + const conversationId = message.roomId; + const projectPath = coderService.getCurrentDirectory(conversationId); + + logger.info(`[CODE] Mode: ${mode}, Project: ${projectPath}`); + + // Pick agent based on mode + let agentName: string | undefined; + let agent = registry.getRecommended(); + + if (mode === 'native') { + // Force native agent + const nativeReg = registry.get('native'); + if (nativeReg?.isAvailable) { + agentName = 'native'; + agent = { name: 'native', registration: nativeReg }; + } else { + await callback({ text: 'Native coding agent not available.' }); + return { success: false, error: 'Native agent not available' }; + } + } else if (mode === 'cli') { + // Force CLI agent (not native) + const cliAgents = registry.getCLIAgents(); + if (cliAgents.length === 0) { + await callback({ + text: 'No CLI coding agents available. Install claude, cursor, aider, codex, or opencode. Or set CODE_MODE=auto to use native.', + }); + return { success: false, error: 'No CLI agents available' }; + } + // Use recommended (which prefers CLI anyway) + if (agent && agent.name === 'native') { + // getRecommended returned native, but we need CLI + agent = null; + for (const cliAgent of cliAgents) { + if (cliAgent.isRecommended) { + agentName = cliAgent.agent.getName(); + agent = { name: agentName, registration: cliAgent }; + break; + } + } + if (!agent && cliAgents.length > 0) { + agentName = cliAgents[0].agent.getName(); + agent = { name: agentName, registration: cliAgents[0] }; + } + } + } + // For 'auto' mode, getRecommended already handles priority (CLI > native) + + if (!agent) { + await callback({ text: 'No coding agents available.' }); + return { success: false, error: 'No agents available' }; + } + + agentName = agent.name; + const agentInstance = agent.registration.agent; + + // Extract prompt from message + const prompt = + typeof message.content === 'string' ? message.content : message.content?.text || ''; + + if (!prompt.trim()) { + await callback({ text: 'Please provide a coding task description.' }); + return { success: false, error: 'Empty prompt' }; + } + + // Notify user which agent we're using + const agentDisplay = agent.registration.displayName || agentName; + await callback({ text: `Using ${agentDisplay} to execute coding task...` }); + + // Start tracking execution status + tracker?.startExecution(conversationId, agentName); + tracker?.updatePhase(conversationId, 'planning', `Planning with ${agentDisplay}`); + + // Build execution parameters + const params: ExecuteParams = { + prompt, + projectPath, + conversationId, + agentType: agentName, + }; + + // Execute (once, no retries - that's orchestrator's job) + try { + tracker?.updatePhase(conversationId, 'writing', 'Executing coding task...'); + const result = await agentInstance.execute(params, runtime); + + // Record files from result + if (result.modifiedFiles) { + for (const file of result.modifiedFiles) { + tracker?.recordFileWrite(conversationId, file, true); + } + } + + if (result.success) { + const filesMsg = result.modifiedFiles?.length + ? `Modified: ${result.modifiedFiles.join(', ')}` + : 'No files reported as modified'; + + // Complete tracking + const elapsed = tracker?.completeExecution(conversationId, true); + const elapsedSec = elapsed ? Math.round(elapsed.elapsedMs / 1000) : 0; + const summary = `wrote ${result.modifiedFiles?.length || 0} files in ${elapsedSec}s using ${agentName}`; + + await callback({ + text: `Done! ${filesMsg}`, + }); + + return { + success: true, + text: `Task completed: ${summary}`, + values: { + agent: agentName, + modifiedFiles: result.modifiedFiles || [], + elapsedMs: elapsed?.elapsedMs || 0, + }, + data: { + actionName: 'CODE', + agent: agentName, + result, + executionSummary: summary, + }, + }; + } else { + tracker?.recordError(conversationId, result.error || 'Unknown error'); + tracker?.completeExecution(conversationId, false, result.error); + + await callback({ + text: `Failed: ${result.error || 'Unknown error'}`, + }); + + return { + success: false, + error: result.error, + text: `Task failed with ${agentName}: ${result.error}`, + data: { + actionName: 'CODE', + agent: agentName, + result, + }, + }; + } + } catch (error: unknown) { + const err = error as Error; + logger.error(`[CODE] Execution error with ${agentName}:`, err); + + tracker?.recordError(conversationId, err.message || 'Unknown error'); + tracker?.completeExecution(conversationId, false, err.message); + + await callback({ + text: `Error: ${err.message || 'Unknown error'}`, + }); + + return { + success: false, + error: err.message, + text: `Task error with ${agentName}: ${err.message}`, + }; + } + }, +}; diff --git a/src/actions/agents/codex.ts b/src/actions/agents/codex.ts new file mode 100644 index 0000000..10cf6c1 --- /dev/null +++ b/src/actions/agents/codex.ts @@ -0,0 +1,95 @@ +/** + * CODEX Action - Execute coding tasks with OpenAI Codex CLI + * + * Use when user explicitly requests Codex: + * - "use codex to fix this" + * - "with codex, add tests" + */ + +import type { Action, IAgentRuntime, Memory, State, HandlerCallback } from '@elizaos/core'; +import { logger } from '@elizaos/core'; +import { AgentRegistry } from '../../services/agentRegistry.service.ts'; +import type { CoderService } from '../../services/coderService.ts'; +import type { ExecuteParams } from '../../interfaces/ICodingAgent.ts'; + +export const codexAction: Action = { + name: 'CODEX', + description: 'Execute coding task with OpenAI Codex CLI. Use when user says "use codex".', + similes: ['use codex', 'codex', 'with codex', 'using codex', 'openai codex'], + + validate: async (runtime: IAgentRuntime): Promise => { + const registry = AgentRegistry.getInstance(); + const reg = registry.get('codex'); + return reg?.isAvailable ?? false; + }, + + handler: async ( + runtime: IAgentRuntime, + message: Memory, + _state: State | undefined, + _options: Record | undefined, + callback: HandlerCallback + ) => { + const registry = AgentRegistry.getInstance(); + const coderService = runtime.getService('coder'); + + if (!coderService) { + await callback({ text: 'CoderService not available.' }); + return { success: false, error: 'CoderService not available' }; + } + + const reg = registry.get('codex'); + if (!reg?.isAvailable) { + await callback({ + text: 'Codex CLI not available. Install OpenAI Codex CLI.', + }); + return { success: false, error: 'Codex not available' }; + } + + const conversationId = message.roomId; + const projectPath = coderService.getCurrentDirectory(conversationId); + const prompt = + typeof message.content === 'string' ? message.content : message.content?.text || ''; + + if (!prompt.trim()) { + await callback({ text: 'Please provide a coding task description.' }); + return { success: false, error: 'Empty prompt' }; + } + + await callback({ text: 'Using Codex to execute coding task...' }); + + const params: ExecuteParams = { + prompt, + projectPath, + conversationId, + agentType: 'codex', + }; + + try { + const result = await reg.agent.execute(params, runtime); + + if (result.success) { + const filesMsg = result.modifiedFiles?.length + ? `Modified: ${result.modifiedFiles.join(', ')}` + : 'Task completed'; + + await callback({ text: `Done! ${filesMsg}` }); + + return { + success: true, + text: 'Task completed with Codex', + values: { agent: 'codex', modifiedFiles: result.modifiedFiles || [] }, + data: { actionName: 'CODEX', result }, + }; + } else { + await callback({ text: `Failed: ${result.error}` }); + return { success: false, error: result.error }; + } + } catch (error: unknown) { + const err = error as Error; + logger.error('[CODEX] Error:', err); + await callback({ text: `Error: ${err.message}` }); + return { success: false, error: err.message }; + } + }, +}; diff --git a/src/actions/agents/cursor.ts b/src/actions/agents/cursor.ts new file mode 100644 index 0000000..9e99177 --- /dev/null +++ b/src/actions/agents/cursor.ts @@ -0,0 +1,95 @@ +/** + * CURSOR Action - Execute coding tasks with Cursor Agent CLI + * + * Use when user explicitly requests Cursor: + * - "use cursor to fix this" + * - "with cursor, add tests" + */ + +import type { Action, IAgentRuntime, Memory, State, HandlerCallback } from '@elizaos/core'; +import { logger } from '@elizaos/core'; +import { AgentRegistry } from '../../services/agentRegistry.service.ts'; +import type { CoderService } from '../../services/coderService.ts'; +import type { ExecuteParams } from '../../interfaces/ICodingAgent.ts'; + +export const cursorAction: Action = { + name: 'CURSOR', + description: 'Execute coding task with Cursor Agent CLI. Use when user says "use cursor".', + similes: ['use cursor', 'cursor agent', 'with cursor', 'using cursor'], + + validate: async (runtime: IAgentRuntime): Promise => { + const registry = AgentRegistry.getInstance(); + const reg = registry.get('cursor'); + return reg?.isAvailable ?? false; + }, + + handler: async ( + runtime: IAgentRuntime, + message: Memory, + _state: State | undefined, + _options: Record | undefined, + callback: HandlerCallback + ) => { + const registry = AgentRegistry.getInstance(); + const coderService = runtime.getService('coder'); + + if (!coderService) { + await callback({ text: 'CoderService not available.' }); + return { success: false, error: 'CoderService not available' }; + } + + const reg = registry.get('cursor'); + if (!reg?.isAvailable) { + await callback({ + text: 'Cursor Agent CLI not available. Install Cursor and run: cursor-agent login', + }); + return { success: false, error: 'Cursor not available' }; + } + + const conversationId = message.roomId; + const projectPath = coderService.getCurrentDirectory(conversationId); + const prompt = + typeof message.content === 'string' ? message.content : message.content?.text || ''; + + if (!prompt.trim()) { + await callback({ text: 'Please provide a coding task description.' }); + return { success: false, error: 'Empty prompt' }; + } + + await callback({ text: 'Using Cursor Agent to execute coding task...' }); + + const params: ExecuteParams = { + prompt, + projectPath, + conversationId, + agentType: 'cursor', + }; + + try { + const result = await reg.agent.execute(params, runtime); + + if (result.success) { + const filesMsg = result.modifiedFiles?.length + ? `Modified: ${result.modifiedFiles.join(', ')}` + : 'Task completed'; + + await callback({ text: `Done! ${filesMsg}` }); + + return { + success: true, + text: 'Task completed with Cursor', + values: { agent: 'cursor', modifiedFiles: result.modifiedFiles || [] }, + data: { actionName: 'CURSOR', result }, + }; + } else { + await callback({ text: `Failed: ${result.error}` }); + return { success: false, error: result.error }; + } + } catch (error: unknown) { + const err = error as Error; + logger.error('[CURSOR] Error:', err); + await callback({ text: `Error: ${err.message}` }); + return { success: false, error: err.message }; + } + }, +}; diff --git a/src/actions/agents/detect.ts b/src/actions/agents/detect.ts new file mode 100644 index 0000000..35ac396 --- /dev/null +++ b/src/actions/agents/detect.ts @@ -0,0 +1,85 @@ +/** + * DETECT_AGENTS Action - List available AI coding agents + * + * Shows which coding tools are installed and available: + * - Native (always available) + * - Claude Code, Cursor, Aider, Codex, OpenCode (if CLI detected) + */ + +import type { Action, IAgentRuntime, Memory, State, HandlerCallback } from '@elizaos/core'; +import { AgentRegistry } from '../../services/agentRegistry.service.ts'; + +export const detectAgentsAction: Action = { + name: 'DETECT_AGENTS', + description: + 'List available AI coding agents. Shows which coding tools are installed and ready to use.', + similes: [ + 'list agents', + 'show agents', + 'available agents', + 'which agents', + 'coding tools', + 'detect tools', + 'what tools', + ], + + validate: async (_runtime: IAgentRuntime): Promise => { + // Always valid - registry is always available + return true; + }, + + handler: async ( + runtime: IAgentRuntime, + _message: Memory, + _state: State | undefined, + _options: Record | undefined, + callback: HandlerCallback + ) => { + const registry = AgentRegistry.getInstance(); + const status = registry.getStatus(); + + // Get current mode setting + const mode = (runtime.getSetting('CODE_MODE') as string) || 'auto'; + + // Build response + const lines: string[] = []; + lines.push('**Available AI Coding Agents**\n'); + lines.push(`Code Mode: \`${mode}\``); + lines.push(`Total: ${status.total} registered, ${status.available} available\n`); + + if (status.recommended) { + lines.push(`Recommended: ${status.recommended}\n`); + } + + // Get formatted list + lines.push('```'); + lines.push(registry.getFormattedList()); + lines.push('```'); + + // Add mode explanation + lines.push('\n**Mode Options:**'); + lines.push('- `auto`: Use CLI if available, otherwise native'); + lines.push('- `native`: Always use elizaOS native coding'); + lines.push('- `cli`: Always use CLI agents'); + lines.push('\nSet via CODE_MODE environment variable.'); + + const responseText = lines.join('\n'); + + await callback({ text: responseText }); + + return { + success: true, + text: responseText, + values: { + total: status.total, + available: status.available, + recommended: status.recommended, + mode, + }, + data: { + actionName: 'DETECT_AGENTS', + status, + }, + }; + }, +}; diff --git a/src/actions/agents/index.ts b/src/actions/agents/index.ts new file mode 100644 index 0000000..b5b1af0 --- /dev/null +++ b/src/actions/agents/index.ts @@ -0,0 +1,11 @@ +/** + * Agent Actions - Export all AI coding agent actions + */ + +export { codeAction } from './code.ts'; +export { claudeCodeAction } from './claudeCode.ts'; +export { cursorAction } from './cursor.ts'; +export { aiderAction } from './aider.ts'; +export { codexAction } from './codex.ts'; +export { opencodeAction } from './opencode.ts'; +export { detectAgentsAction } from './detect.ts'; diff --git a/src/actions/agents/opencode.ts b/src/actions/agents/opencode.ts new file mode 100644 index 0000000..0ad9ee0 --- /dev/null +++ b/src/actions/agents/opencode.ts @@ -0,0 +1,95 @@ +/** + * OPENCODE Action - Execute coding tasks with OpenCode CLI + * + * Use when user explicitly requests OpenCode: + * - "use opencode to fix this" + * - "with opencode, add tests" + */ + +import type { Action, IAgentRuntime, Memory, State, HandlerCallback } from '@elizaos/core'; +import { logger } from '@elizaos/core'; +import { AgentRegistry } from '../../services/agentRegistry.service.ts'; +import type { CoderService } from '../../services/coderService.ts'; +import type { ExecuteParams } from '../../interfaces/ICodingAgent.ts'; + +export const opencodeAction: Action = { + name: 'OPENCODE', + description: 'Execute coding task with OpenCode CLI. Use when user says "use opencode".', + similes: ['use opencode', 'opencode', 'with opencode', 'using opencode'], + + validate: async (runtime: IAgentRuntime): Promise => { + const registry = AgentRegistry.getInstance(); + const reg = registry.get('opencode'); + return reg?.isAvailable ?? false; + }, + + handler: async ( + runtime: IAgentRuntime, + message: Memory, + _state: State | undefined, + _options: Record | undefined, + callback: HandlerCallback + ) => { + const registry = AgentRegistry.getInstance(); + const coderService = runtime.getService('coder'); + + if (!coderService) { + await callback({ text: 'CoderService not available.' }); + return { success: false, error: 'CoderService not available' }; + } + + const reg = registry.get('opencode'); + if (!reg?.isAvailable) { + await callback({ + text: 'OpenCode CLI not available.', + }); + return { success: false, error: 'OpenCode not available' }; + } + + const conversationId = message.roomId; + const projectPath = coderService.getCurrentDirectory(conversationId); + const prompt = + typeof message.content === 'string' ? message.content : message.content?.text || ''; + + if (!prompt.trim()) { + await callback({ text: 'Please provide a coding task description.' }); + return { success: false, error: 'Empty prompt' }; + } + + await callback({ text: 'Using OpenCode to execute coding task...' }); + + const params: ExecuteParams = { + prompt, + projectPath, + conversationId, + agentType: 'opencode', + }; + + try { + const result = await reg.agent.execute(params, runtime); + + if (result.success) { + const filesMsg = result.modifiedFiles?.length + ? `Modified: ${result.modifiedFiles.join(', ')}` + : 'Task completed'; + + await callback({ text: `Done! ${filesMsg}` }); + + return { + success: true, + text: 'Task completed with OpenCode', + values: { agent: 'opencode', modifiedFiles: result.modifiedFiles || [] }, + data: { actionName: 'OPENCODE', result }, + }; + } else { + await callback({ text: `Failed: ${result.error}` }); + return { success: false, error: result.error }; + } + } catch (error: unknown) { + const err = error as Error; + logger.error('[OPENCODE] Error:', err); + await callback({ text: `Error: ${err.message}` }); + return { success: false, error: err.message }; + } + }, +}; diff --git a/src/actions/editFile.ts b/src/actions/editFile.ts index c2f16a3..9298ddc 100644 --- a/src/actions/editFile.ts +++ b/src/actions/editFile.ts @@ -13,9 +13,7 @@ function getInputs( options: HandlerOptions | undefined, message: Memory ): { filepath: string; oldStr: string; newStr: string } { - const opt = options as - | { filepath?: string; old_str?: string; new_str?: string } - | undefined; + const opt = options as { filepath?: string; old_str?: string; new_str?: string } | undefined; const filepath = opt?.filepath?.trim() ?? ''; const oldStr = opt?.old_str ?? ''; const newStr = opt?.new_str ?? ''; diff --git a/src/actions/git.ts b/src/actions/git.ts index 5e5987f..5d9fe66 100644 --- a/src/actions/git.ts +++ b/src/actions/git.ts @@ -1,3 +1,21 @@ +/** + * Simple GIT action for plugin-code. + * + * IMPORTANT: This is a FALLBACK action for when plugin-git is NOT loaded. + * If plugin-git is available, prefer its atomic actions: + * - GIT_CLONE, GIT_COMMIT, GIT_PUSH, GIT_PULL, GIT_CHECKOUT, GIT_MERGE, etc. + * + * WHY KEEP THIS ACTION? + * - plugin-code should work standalone without plugin-git + * - Some quick git operations don't need the full plugin-git + * - Backward compatibility with existing projects + * + * WHEN TO USE plugin-git INSTEAD: + * - Need authentication (tokens, SSH) + * - Need managed working copies + * - Need security guards for sensitive directories + * - Want atomic, well-defined actions + */ import type { Action, ActionResult, @@ -7,6 +25,7 @@ import type { Memory, State, } from '@elizaos/core'; +import { logger } from '@elizaos/core'; import type { CoderService } from '../services/coderService.ts'; function getArgs(options: HandlerOptions | undefined): string { @@ -17,7 +36,8 @@ function getArgs(options: HandlerOptions | undefined): string { export const git: Action = { name: 'GIT', similes: ['GIT_COMMAND', 'GIT_RUN'], - description: 'Run a git command (restricted).', + description: + 'Run a git command (restricted). For full git support with auth, use plugin-git actions instead.', validate: async (runtime: IAgentRuntime): Promise => runtime.getService('coder') !== null, handler: async ( @@ -41,6 +61,18 @@ export const git: Action = { return { success: false, text: msg }; } + // Check if plugin-git is available and suggest using it for certain operations + const gitService = runtime.getService('git'); + if (gitService) { + const firstArg = args.split(/\s+/)[0]; + const pluginGitCommands = ['clone', 'commit', 'push', 'pull', 'checkout', 'merge', 'fetch']; + if (pluginGitCommands.includes(firstArg)) { + logger.debug( + `[GIT] plugin-git available - consider using GIT_${firstArg.toUpperCase()} action for better auth/security` + ); + } + } + const conv = message.roomId ?? message.agentId ?? runtime.agentId; const result = await svc.git(args, conv); const out = result.success ? result.stdout : result.stderr; diff --git a/src/actions/index.ts b/src/actions/index.ts index cd9a28d..1fa7559 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -1,3 +1,4 @@ +// Existing file operation actions export { changeDirectory } from './changeDirectory.ts'; export { editFile } from './editFile.ts'; export { executeShell } from './executeShell.ts'; @@ -6,3 +7,17 @@ export { listFiles } from './listFiles.ts'; export { readFile } from './readFile.ts'; export { searchFiles } from './searchFiles.ts'; export { writeFile } from './writeFile.ts'; + +// New agent actions +export { + codeAction, + claudeCodeAction, + cursorAction, + aiderAction, + codexAction, + opencodeAction, + detectAgentsAction, +} from './agents/index.ts'; + +// New PRR actions +export { viewLessonsAction, showStatsAction } from './prr/index.ts'; diff --git a/src/actions/prr/index.ts b/src/actions/prr/index.ts new file mode 100644 index 0000000..48ee428 --- /dev/null +++ b/src/actions/prr/index.ts @@ -0,0 +1,6 @@ +/** + * PRR Actions - Export lessons and stats actions + */ + +export { viewLessonsAction } from './viewLessons.ts'; +export { showStatsAction } from './showStats.ts'; diff --git a/src/actions/prr/showStats.ts b/src/actions/prr/showStats.ts new file mode 100644 index 0000000..c87e4c2 --- /dev/null +++ b/src/actions/prr/showStats.ts @@ -0,0 +1,113 @@ +/** + * SHOW_STATS Action - Display agent performance statistics + * + * Shows how well each coding agent is performing: + * - Success/failure rates + * - Total attempts + * - Best performing agent + */ + +import type { Action, IAgentRuntime, Memory, State, HandlerCallback } from '@elizaos/core'; +import type { StatsService } from '../../services/stats.service.ts'; + +export const showStatsAction: Action = { + name: 'SHOW_STATS', + description: 'Display performance statistics for AI coding agents.', + similes: [ + 'show stats', + 'agent stats', + 'performance', + 'success rate', + 'how well', + 'agent performance', + ], + + validate: async (runtime: IAgentRuntime): Promise => { + const statsService = runtime.getService('code-stats'); + return statsService !== undefined; + }, + + handler: async ( + runtime: IAgentRuntime, + _message: Memory, + _state: State | undefined, + _options: Record | undefined, + callback: HandlerCallback + ) => { + const statsService = runtime.getService('code-stats'); + + if (!statsService) { + await callback({ text: 'Stats tracking service not available.' }); + return { success: false, error: 'StatsService not available' }; + } + + // Get overview and all stats + const overview = statsService.getOverview(); + const allStats = statsService.getAllStats(); + + // Build response + const lines: string[] = []; + lines.push('**Agent Performance Statistics**\n'); + + // Overall stats + lines.push('**Overall:**'); + lines.push(`- Total Attempts: ${overview.totalAttempts}`); + lines.push(`- Successes: ${overview.totalSuccesses}`); + lines.push(`- Failures: ${overview.totalFailures}`); + + if (overview.totalAttempts > 0) { + const overallRate = ((overview.totalSuccesses / overview.totalAttempts) * 100).toFixed(1); + lines.push(`- Overall Success Rate: ${overallRate}%`); + } + + if (overview.bestAgent) { + const bestRate = (overview.bestSuccessRate * 100).toFixed(1); + lines.push(`\n**Best Agent:** ${overview.bestAgent} (${bestRate}% success rate)`); + } + + lines.push(''); + + // Per-agent stats + if (allStats.length > 0) { + lines.push('**Per Agent:**\n'); + lines.push('```'); + lines.push('Agent | Attempts | Success | Failure | Rate'); + lines.push('----------------|----------|---------|---------|------'); + + for (const stat of allStats.sort((a, b) => b.attempts - a.attempts)) { + const rate = + stat.attempts > 0 ? ((stat.successes / stat.attempts) * 100).toFixed(0) + '%' : 'N/A'; + const name = stat.agent.padEnd(15); + const attempts = String(stat.attempts).padStart(8); + const successes = String(stat.successes).padStart(7); + const failures = String(stat.failures).padStart(7); + const rateStr = rate.padStart(5); + + lines.push(`${name} | ${attempts} | ${successes} | ${failures} | ${rateStr}`); + } + + lines.push('```'); + } else { + lines.push('No stats recorded yet. Use the CODE action to generate stats.'); + } + + const responseText = lines.join('\n'); + + await callback({ text: responseText }); + + return { + success: true, + text: responseText, + values: { + totalAttempts: overview.totalAttempts, + bestAgent: overview.bestAgent, + bestSuccessRate: overview.bestSuccessRate, + }, + data: { + actionName: 'SHOW_STATS', + overview, + allStats, + }, + }; + }, +}; diff --git a/src/actions/prr/viewLessons.ts b/src/actions/prr/viewLessons.ts new file mode 100644 index 0000000..5dd95f3 --- /dev/null +++ b/src/actions/prr/viewLessons.ts @@ -0,0 +1,120 @@ +/** + * VIEW_LESSONS Action - Show failure history and lessons learned + * + * Shows past coding failures to help understand what went wrong: + * - Recent failures with context + * - Failure counts by agent + * - Search through failure history + */ + +import type { Action, IAgentRuntime, Memory, State, HandlerCallback } from '@elizaos/core'; +import type { LessonsService } from '../../services/lessons.service.ts'; + +export const viewLessonsAction: Action = { + name: 'VIEW_LESSONS', + description: 'Show failure history and lessons learned from past coding attempts.', + similes: [ + 'show lessons', + 'view lessons', + 'what failed', + 'failure history', + 'past errors', + 'lessons learned', + ], + + validate: async (runtime: IAgentRuntime): Promise => { + const lessonsService = runtime.getService('code-lessons'); + return lessonsService !== undefined; + }, + + handler: async ( + runtime: IAgentRuntime, + message: Memory, + _state: State | undefined, + _options: Record | undefined, + callback: HandlerCallback + ) => { + const lessonsService = runtime.getService('code-lessons'); + + if (!lessonsService) { + await callback({ text: 'Lessons tracking service not available.' }); + return { success: false, error: 'LessonsService not available' }; + } + + // Check for search keyword in message + const messageText = + typeof message.content === 'string' ? message.content : message.content?.text || ''; + + const searchMatch = messageText.match(/(?:search|find|for)\s+["']?([^"']+)["']?/i); + const searchKeyword = searchMatch?.[1]?.trim(); + + // Get overview + const overview = lessonsService.getOverview(); + + // Build response + const lines: string[] = []; + lines.push('**Lessons Learned from Coding Failures**\n'); + lines.push(`Total: ${overview.total} lessons`); + lines.push(`Recent (last hour): ${overview.recentCount}\n`); + + // By agent breakdown + if (Object.keys(overview.byAgent).length > 0) { + lines.push('**By Agent:**'); + for (const [agent, count] of Object.entries(overview.byAgent)) { + lines.push(`- ${agent}: ${count} failures`); + } + lines.push(''); + } + + // Get lessons (search or recent) + let lessons; + if (searchKeyword) { + lessons = lessonsService.search(searchKeyword); + lines.push(`**Search Results for "${searchKeyword}":** ${lessons.length} found\n`); + } else { + lessons = lessonsService.getRecent(5); + lines.push('**Recent Lessons:**\n'); + } + + if (lessons.length === 0) { + lines.push('No lessons found.'); + } else { + for (const lesson of lessons) { + const date = new Date(lesson.timestamp).toLocaleString(); + const promptPreview = + lesson.prompt.substring(0, 50) + (lesson.prompt.length > 50 ? '...' : ''); + const errorPreview = + lesson.error.substring(0, 80) + (lesson.error.length > 80 ? '...' : ''); + + lines.push(`**${lesson.id}** (${lesson.agent})`); + lines.push(`- Time: ${date}`); + lines.push(`- Prompt: ${promptPreview}`); + lines.push(`- Error: ${errorPreview}`); + if (lesson.files?.length) { + lines.push(`- Files: ${lesson.files.join(', ')}`); + } + lines.push(''); + } + } + + const responseText = lines.join('\n'); + + await callback({ text: responseText }); + + return { + success: true, + text: responseText, + values: { + total: overview.total, + byAgent: overview.byAgent, + searchKeyword: searchKeyword || null, + resultCount: lessons.length, + }, + data: { + actionName: 'VIEW_LESSONS', + overview, + lessons, + }, + }; + }, +}; diff --git a/src/index.ts b/src/index.ts index 35073e7..72073b2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,40 @@ +/** + * plugin-code - Unified Code Toolkit for elizaOS + * + * A self-sufficient code toolkit that provides: + * - File operations (read, write, edit, list, search) + * - Shell execution + * - Git operations (basic - use plugin-git for full support) + * - AI coding with multiple agents (native + CLI tools) + * - Learning from failures (lessons) + * - Performance tracking (stats) + * + * PROGRESSIVE ENHANCEMENT: + * - Works standalone with CODER_ALLOWED_DIRECTORY setting + * - If plugin-workspace is loaded, uses active workspace path + * - If plugin-git is loaded, delegates git operations + * + * AGENTS: + * - Native: Uses elizaOS LLM + file actions (always available) + * - Claude Code, Cursor, Aider, Codex, OpenCode: CLI tools (if installed) + * + * CONFIGURATION: + * - CODE_MODE: 'auto' | 'native' | 'cli' (default: auto) + * - CODER_ENABLED: true/false + * - CODER_ALLOWED_DIRECTORY: Project root path (fallback if no workspace) + */ + import type { Plugin } from '@elizaos/core'; +import { logger } from '@elizaos/core'; + +// Core services +import { CoderService } from './services/coderService.ts'; +import { LessonsService } from './services/lessons.service.ts'; +import { StatsService } from './services/stats.service.ts'; +import { ExecutionTrackerService } from './services/executionTracker.service.ts'; +import { AgentRegistry, registerAgentWithDetection } from './services/agentRegistry.service.ts'; + +// File operations import { changeDirectory, editFile, @@ -9,14 +45,61 @@ import { searchFiles, writeFile, } from './actions/index.ts'; -import { coderStatusProvider } from './providers/index.ts'; -import { CoderService } from './services/coderService.ts'; + +// Agent implementations +import { + NativeCoderAgent, + ClaudeCodeAgent, + CursorAgent, + AiderAgent, + CodexAgent, + OpenCodeAgent, +} from './services/agents/index.ts'; + +// Agent actions +import { + codeAction, + claudeCodeAction, + cursorAction, + aiderAction, + codexAction, + opencodeAction, + detectAgentsAction, +} from './actions/agents/index.ts'; + +// PRR actions +import { viewLessonsAction, showStatsAction } from './actions/prr/index.ts'; + +// Providers +import { coderStatusProvider } from './providers/coderStatusProvider.ts'; +import { + codeAgentsOverviewProvider, + codeAgentsProvider, + codeAgentsFullProvider, + codeLessonsOverviewProvider, + codeLessonsProvider, + codeLessonsFullProvider, + codeStatsOverviewProvider, + codeStatsProvider, + codeStatsFullProvider, + codeExecutionStatusOverviewProvider, + codeExecutionStatusProvider, + codeExecutionStatusFullProvider, + codeHelpProvider, + codeSettingsProvider, +} from './providers/index.ts'; export const coderPlugin: Plugin = { - name: 'eliza-coder', - description: 'Coder tools: filesystem, shell, and git (restricted)', - services: [CoderService], + name: 'plugin-code', + description: + 'Code toolkit: file ops, AI coding agents (native + CLI), lessons, stats. Use with plugin-workspace for workspace management.', + + // NOTE: WorkspaceService moved to plugin-workspace + // CoderService will check for plugin-workspace at runtime + services: [CoderService, LessonsService, StatsService, ExecutionTrackerService], + actions: [ + // File operations readFile, listFiles, searchFiles, @@ -25,14 +108,130 @@ export const coderPlugin: Plugin = { changeDirectory, executeShell, git, + + // Agent actions + codeAction, + claudeCodeAction, + cursorAction, + aiderAction, + codexAction, + opencodeAction, + detectAgentsAction, + + // PRR actions + viewLessonsAction, + showStatsAction, ], - providers: [coderStatusProvider], + + providers: [ + // Coder status + coderStatusProvider, + + // Agent providers (dynamic) + codeAgentsOverviewProvider, + codeAgentsProvider, + codeAgentsFullProvider, + + // Lessons providers (dynamic) + codeLessonsOverviewProvider, + codeLessonsProvider, + codeLessonsFullProvider, + + // Stats providers (dynamic) + codeStatsOverviewProvider, + codeStatsProvider, + codeStatsFullProvider, + + // Execution status providers (dynamic) - for orchestrator visibility + codeExecutionStatusOverviewProvider, + codeExecutionStatusProvider, + codeExecutionStatusFullProvider, + + // Help and settings + codeHelpProvider, + codeSettingsProvider, + ], + + init: async (_config, runtime) => { + logger.info('[plugin-code] Initializing code toolkit...'); + + // Check for plugin-workspace (progressive enhancement) + const workspaceService = runtime.getService('workspace'); + if (workspaceService) { + logger.info('[plugin-code] plugin-workspace detected - will use active workspace for file ops'); + } else { + logger.info('[plugin-code] plugin-workspace not loaded - using CODER_ALLOWED_DIRECTORY setting'); + } + + // Register agents + const registry = AgentRegistry.getInstance(); + + // Native agent is ALWAYS available + registry.register('native', { + agent: new NativeCoderAgent(), + displayName: 'Native (elizaOS)', + cliCommand: 'native', + isStable: true, + isRecommended: false, // CLI agents preferred when available + isAvailable: true, + description: 'Uses elizaOS LLM and file actions directly - no external tools needed', + }); + + // CLI agents - detect availability + await registerAgentWithDetection('claude-code', new ClaudeCodeAgent(), 'claude', { + displayName: 'Claude Code', + isRecommended: true, + isStable: true, + description: "Anthropic's official Claude Code CLI", + aliases: ['claude', 'claude-code', 'anthropic'], + }); + + await registerAgentWithDetection('cursor', new CursorAgent(), 'cursor-agent', { + displayName: 'Cursor Agent', + isRecommended: false, + isStable: true, + description: 'Cursor IDE agent with stream-json output', + aliases: ['cursor', 'cursor-agent'], + }); + + await registerAgentWithDetection('aider', new AiderAgent(), 'aider', { + displayName: 'Aider', + isRecommended: false, + isStable: true, + description: 'AI pair programming in your terminal', + aliases: ['aider'], + }); + + await registerAgentWithDetection('codex', new CodexAgent(), 'codex', { + displayName: 'OpenAI Codex', + isRecommended: false, + isStable: true, + description: "OpenAI's official Codex CLI", + aliases: ['codex', 'openai-codex'], + }); + + await registerAgentWithDetection('opencode', new OpenCodeAgent(), 'opencode', { + displayName: 'OpenCode', + isRecommended: false, + isStable: true, + description: 'Multi-provider AI coding assistant', + aliases: ['opencode'], + }); + + // Log status + const status = registry.getStatus(); + logger.info( + `[plugin-code] Registered ${status.total} agents, ${status.available} available, recommended: ${status.recommended || 'native'}` + ); + }, }; export default coderPlugin; +// Re-export all components for external use export * from './actions/index.ts'; -export { coderStatusProvider } from './providers/coderStatusProvider.ts'; -export { CoderService } from './services/coderService.ts'; +export * from './providers/index.ts'; +export * from './services/index.ts'; +export * from './interfaces/index.ts'; export * from './types/index.ts'; export * from './utils/index.ts'; diff --git a/src/interfaces/ICodingAgent.ts b/src/interfaces/ICodingAgent.ts index 63e3e1a..ddc2e46 100644 --- a/src/interfaces/ICodingAgent.ts +++ b/src/interfaces/ICodingAgent.ts @@ -108,7 +108,7 @@ export interface ICodingAgent { * Execute a coding task. * * @param params - Execution parameters - * @param runtime - ElizaOS runtime for settings and services + * @param runtime - elizaOS runtime for settings and services * @returns Result with success status and modified files */ execute(params: ExecuteParams, runtime: IAgentRuntime): Promise; @@ -125,8 +125,12 @@ export interface ICodingAgent { * * @param error - Error message to fix * @param originalParams - The original execution parameters - * @param runtime - ElizaOS runtime + * @param runtime - elizaOS runtime * @returns Result of the fix attempt */ - fixError(error: string, originalParams: ExecuteParams, runtime: IAgentRuntime): Promise; + fixError( + error: string, + originalParams: ExecuteParams, + runtime: IAgentRuntime + ): Promise; } diff --git a/src/providers/agents.providers.ts b/src/providers/agents.providers.ts new file mode 100644 index 0000000..db62fe6 --- /dev/null +++ b/src/providers/agents.providers.ts @@ -0,0 +1,94 @@ +/** + * Agent Providers - Multi-resolution context about available coding agents + * + * WHY THREE RESOLUTIONS: + * - OVERVIEW: Quick check, ~50 tokens - "Do I have coding tools?" + * - DEFAULT: Medium detail, ~200 tokens - CSV for decisions + * - FULL: Deep inspection, ~500 tokens - Complete agent details + * + * All marked dynamic: true so they're not auto-included in every prompt. + */ + +import type { Provider, IAgentRuntime, Memory, State } from '@elizaos/core'; +import { AgentRegistry } from '../services/agentRegistry.service.ts'; + +/** + * OVERVIEW: Quick count and names of available agents. + */ +export const codeAgentsOverviewProvider: Provider = { + name: 'CODE_AGENTS_OVERVIEW', + description: 'Available coding agents: count and names. Quick check what tools are available.', + dynamic: true, + + get: async ( + _runtime: IAgentRuntime, + _message: Memory, + _state?: State + ): Promise<{ text: string }> => { + const registry = AgentRegistry.getInstance(); + const available = registry.getAvailable(); + const status = registry.getStatus(); + + const agentNames = available.map((a) => a.displayName).join(', '); + const recommended = status.recommended ? ` (recommended: ${status.recommended})` : ''; + + return { + text: `Coding agents: ${status.available} available${recommended}\nAgents: ${agentNames}`, + }; + }, +}; + +/** + * DEFAULT: CSV format with availability and recommendation status. + */ +export const codeAgentsProvider: Provider = { + name: 'CODE_AGENTS', + description: 'Coding agents CSV: name, available, recommended. For deciding which agent to use.', + dynamic: true, + + get: async ( + _runtime: IAgentRuntime, + _message: Memory, + _state?: State + ): Promise<{ text: string }> => { + const registry = AgentRegistry.getInstance(); + const all = registry.getAll(); + + const csv = ['name,available,stable,recommended']; + for (const [name, reg] of Array.from(all.entries())) { + csv.push(`${name},${reg.isAvailable},${reg.isStable},${reg.isRecommended}`); + } + + return { text: csv.join('\n') }; + }, +}; + +/** + * FULL: Complete details for each agent. + */ +export const codeAgentsFullProvider: Provider = { + name: 'CODE_AGENTS_FULL', + description: 'Full coding agent details: capabilities, CLI commands, descriptions.', + dynamic: true, + + get: async ( + _runtime: IAgentRuntime, + _message: Memory, + _state?: State + ): Promise<{ text: string }> => { + const registry = AgentRegistry.getInstance(); + const sections: string[] = []; + + for (const [name, reg] of Array.from(registry.getAll().entries())) { + sections.push(`## ${reg.displayName} (${name}) +- CLI Command: ${reg.cliCommand} +- Available: ${reg.isAvailable ? 'Yes' : 'No'} +- Stable: ${reg.isStable ? 'Yes' : 'No'} +- Recommended: ${reg.isRecommended ? 'Yes' : 'No'} +- Description: ${reg.description || 'No description'} +- Aliases: ${reg.aliases?.join(', ') || 'None'}`); + } + + return { text: sections.join('\n\n') }; + }, +}; diff --git a/src/providers/executionStatus.provider.ts b/src/providers/executionStatus.provider.ts new file mode 100644 index 0000000..43ff718 --- /dev/null +++ b/src/providers/executionStatus.provider.ts @@ -0,0 +1,228 @@ +/** + * CODE_EXECUTION_STATUS provider + * + * Exposes current code execution status to the LLM/orchestrator. + * + * WHY THIS EXISTS: + * - Orchestrator needs visibility: "What's the agent doing right now?" + * - Enables meaningful progress: "Writing src/auth.ts..." instead of "Running..." + * - Supports smart decisions: retry on error, adjust timeout, show file counts + * + * OUTPUT FORMATS: + * - OVERVIEW: One-line summary for quick context + * - Default: Structured status with key metrics + * - FULL: Complete status including action history + */ + +import type { IAgentRuntime, Memory, Provider, ProviderResult, State } from '@elizaos/core'; +import type { ExecutionTrackerService } from '../services/executionTracker.service.ts'; +import type { ExecutionStatus, ExecutionAction } from '../types/execution.ts'; + +/** + * Format action history as CSV + */ +function formatHistoryAsCsv(actions: ExecutionAction[]): string { + if (actions.length === 0) return 'No actions recorded'; + + const header = 'type,description,file,success,timestamp'; + const rows = actions.map((a) => { + const file = a.file || ''; + const time = new Date(a.timestamp).toISOString(); + return `${a.type},"${a.description}",${file},${a.success},${time}`; + }); + + return [header, ...rows].join('\n'); +} + +/** + * Format status for display + */ +function formatStatus(status: ExecutionStatus, resolution: 'overview' | 'default' | 'full'): string { + if (!status.isExecuting && status.phase === 'idle') { + if (resolution === 'overview') { + return 'CODE_STATUS: idle'; + } + return 'No code execution in progress.'; + } + + const elapsed = Math.round(status.elapsedMs / 1000); + + if (resolution === 'overview') { + // One-line summary + const parts = [ + `CODE_STATUS: ${status.phase}`, + `agent=${status.agent}`, + `progress=${status.progress}%`, + `elapsed=${elapsed}s`, + ]; + if (status.filesWritten.length > 0) { + parts.push(`wrote=${status.filesWritten.length}`); + } + return parts.join(' | '); + } + + // Default or full resolution + const lines: string[] = [ + `**Code Execution Status**`, + `Agent: ${status.agent}`, + `Phase: ${status.phase}`, + `Progress: ${status.progress}%`, + `Elapsed: ${elapsed}s`, + ]; + + if (status.currentFile) { + lines.push(`Current File: ${status.currentFile}`); + } + + lines.push(`Files Read: ${status.filesRead.length}`); + lines.push(`Files Written: ${status.filesWritten.length}`); + lines.push(`Last Action: ${status.lastAction}`); + + if (status.error) { + lines.push(`Error: ${status.error}`); + } + + if (resolution === 'full') { + // Include file lists and history + if (status.filesRead.length > 0) { + lines.push(''); + lines.push('**Files Read:**'); + status.filesRead.forEach((f) => lines.push(` - ${f}`)); + } + + if (status.filesWritten.length > 0) { + lines.push(''); + lines.push('**Files Written:**'); + status.filesWritten.forEach((f) => lines.push(` - ${f}`)); + } + + if (status.actionHistory.length > 0) { + lines.push(''); + lines.push('**Action History (CSV):**'); + lines.push('```'); + lines.push(formatHistoryAsCsv(status.actionHistory)); + lines.push('```'); + } + } + + return lines.join('\n'); +} + +/** + * OVERVIEW resolution - one-line summary + */ +export const codeExecutionStatusOverviewProvider: Provider = { + name: 'CODE_EXECUTION_STATUS_OVERVIEW', + description: 'One-line summary of current code execution status', + dynamic: true, + + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const tracker = runtime.getService('execution-tracker'); + if (!tracker) { + return { text: 'CODE_STATUS: tracker unavailable', values: { available: false } }; + } + + const conversationId = message.roomId ?? runtime.agentId; + const status = tracker.getStatus(conversationId); + + return { + text: formatStatus(status, 'overview'), + values: { + isExecuting: status.isExecuting, + agent: status.agent, + phase: status.phase, + progress: status.progress, + }, + }; + }, +}; + +/** + * Default resolution - structured status + */ +export const codeExecutionStatusProvider: Provider = { + name: 'CODE_EXECUTION_STATUS', + description: 'Current code execution status with key metrics', + dynamic: true, + + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const tracker = runtime.getService('execution-tracker'); + if (!tracker) { + return { + text: 'Code execution tracker not available.', + values: { available: false }, + }; + } + + const conversationId = message.roomId ?? runtime.agentId; + const status = tracker.getStatus(conversationId); + + return { + text: formatStatus(status, 'default'), + values: { + isExecuting: status.isExecuting, + agent: status.agent, + phase: status.phase, + currentFile: status.currentFile, + filesRead: status.filesRead, + filesWritten: status.filesWritten, + progress: status.progress, + elapsedMs: status.elapsedMs, + lastAction: status.lastAction, + }, + data: { status }, + }; + }, +}; + +/** + * FULL resolution - complete status with history + */ +export const codeExecutionStatusFullProvider: Provider = { + name: 'CODE_EXECUTION_STATUS_FULL', + description: 'Complete code execution status including action history', + dynamic: true, + + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const tracker = runtime.getService('execution-tracker'); + if (!tracker) { + return { + text: 'Code execution tracker not available.', + values: { available: false }, + }; + } + + const conversationId = message.roomId ?? runtime.agentId; + const status = tracker.getStatus(conversationId); + + return { + text: formatStatus(status, 'full'), + values: { + isExecuting: status.isExecuting, + agent: status.agent, + phase: status.phase, + currentFile: status.currentFile, + filesRead: status.filesRead, + filesWritten: status.filesWritten, + progress: status.progress, + elapsedMs: status.elapsedMs, + lastAction: status.lastAction, + actionHistory: status.actionHistory, + error: status.error, + }, + data: { status }, + }; + }, +}; diff --git a/src/providers/help.provider.ts b/src/providers/help.provider.ts new file mode 100644 index 0000000..dfdd43c --- /dev/null +++ b/src/providers/help.provider.ts @@ -0,0 +1,101 @@ +/** + * CODE_HELP Provider + * + * Provides instructions on how to use plugin-code. + * Helps the agent guide users through coding operations. + */ + +import type { Provider, IAgentRuntime, Memory, State, ProviderResult } from '@elizaos/core'; + +const CODE_HELP = ` +## Code Toolkit (plugin-code) + +A unified toolkit for AI-assisted coding with multiple backends. + +### AI Coding (CODE Action) + +The main action - delegates to the best available AI coding agent: + +**Basic Usage:** +- "Fix the bug in utils.ts" +- "Implement user authentication" +- "Add tests for the UserService" +- "Refactor the database layer" + +**Agent Selection:** +- CODE_MODE=auto (default): Uses CLI agent if available, otherwise native +- CODE_MODE=native: Always uses elizaOS LLM + file actions +- CODE_MODE=cli: Requires external CLI tool (claude, cursor, aider, etc.) + +### Specific Agents + +Use a specific agent directly: + +- "Using Claude Code, fix the authentication bug" +- "With Cursor, implement the new feature" +- "Use Aider to add the API endpoint" +- "Run Codex to optimize the algorithm" +- "Use OpenCode to review this file" + +### File Operations + +**Reading:** +- "Read src/utils.ts" +- "Show me the package.json" +- "What's in the config file?" + +**Writing:** +- "Create a new file called helper.ts with..." +- "Write a README.md explaining..." + +**Editing:** +- "Edit utils.ts and add error handling" +- "Update the config to include..." + +**Searching:** +- "Search for TODO comments" +- "Find all uses of fetchData" +- "List all TypeScript files" + +### Shell Commands + +- "Run npm test" +- "Execute npm install lodash" +- "Run the build script" + +### Learning from Failures (Lessons) + +- "Show my lessons" - View patterns learned from failures +- "What lessons have we learned?" - See improvement suggestions + +### Stats + +- "Show coding stats" - View success rates and metrics +- "How are my agents performing?" - See agent comparison + +### Tips + +- Works best with plugin-workspace for project context +- If no workspace is active, set CODER_ALLOWED_DIRECTORY +- CLI agents (claude, cursor, etc.) must be installed separately +- Native agent works without any external tools +`.trim(); + +export const codeHelpProvider: Provider = { + name: 'CODE_HELP', + description: 'Instructions for using AI coding features', + + get: async ( + _runtime: IAgentRuntime, + _message: Memory, + _state?: State + ): Promise => { + return { + text: CODE_HELP, + values: { + pluginName: 'plugin-code', + hasHelp: true, + }, + }; + }, +}; diff --git a/src/providers/index.ts b/src/providers/index.ts index d1e8c70..1319478 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1 +1,38 @@ +/** + * Provider exports for plugin-code + */ + +// Existing provider export { coderStatusProvider } from './coderStatusProvider.ts'; + +// Help and settings providers +export { codeHelpProvider } from './help.provider.ts'; +export { codeSettingsProvider } from './settings.provider.ts'; + +// Agent providers (3 resolutions) +export { + codeAgentsOverviewProvider, + codeAgentsProvider, + codeAgentsFullProvider, +} from './agents.providers.ts'; + +// Lessons providers (3 resolutions) +export { + codeLessonsOverviewProvider, + codeLessonsProvider, + codeLessonsFullProvider, +} from './lessons.providers.ts'; + +// Stats providers (3 resolutions) +export { + codeStatsOverviewProvider, + codeStatsProvider, + codeStatsFullProvider, +} from './stats.providers.ts'; + +// Execution status providers (3 resolutions) +export { + codeExecutionStatusOverviewProvider, + codeExecutionStatusProvider, + codeExecutionStatusFullProvider, +} from './executionStatus.provider.ts'; diff --git a/src/providers/lessons.providers.ts b/src/providers/lessons.providers.ts new file mode 100644 index 0000000..0d1bcc8 --- /dev/null +++ b/src/providers/lessons.providers.ts @@ -0,0 +1,110 @@ +/** + * Lessons Providers - Multi-resolution context about failure history + * + * WHY THREE RESOLUTIONS: + * - OVERVIEW: Quick count - "Any failures?" + * - DEFAULT: CSV of recent lessons - for LLM to consider + * - FULL: Detailed breakdown - for deep analysis + * + * All marked dynamic: true so they're not auto-included in every prompt. + */ + +import type { Provider, IAgentRuntime, Memory, State } from '@elizaos/core'; +import type { LessonsService } from '../services/lessons.service.ts'; + +/** + * OVERVIEW: Quick count of lessons. + */ +export const codeLessonsOverviewProvider: Provider = { + name: 'CODE_LESSONS_OVERVIEW', + description: 'Lessons learned count. Quick check if there are past failures.', + dynamic: true, + + get: async ( + runtime: IAgentRuntime, + _message: Memory, + _state?: State + ): Promise<{ text: string }> => { + const lessonsService = runtime.getService('code-lessons'); + if (!lessonsService) { + return { text: 'Lessons tracking: not available' }; + } + + const overview = lessonsService.getOverview(); + return { + text: `Lessons: ${overview.total} recorded, ${overview.recentCount} in last hour`, + }; + }, +}; + +/** + * DEFAULT: CSV of recent lessons. + */ +export const codeLessonsProvider: Provider = { + name: 'CODE_LESSONS', + description: 'Recent lessons CSV: id, timestamp, agent, error. For learning from failures.', + dynamic: true, + + get: async ( + runtime: IAgentRuntime, + _message: Memory, + _state?: State + ): Promise<{ text: string }> => { + const lessonsService = runtime.getService('code-lessons'); + if (!lessonsService) { + return { text: 'id,timestamp,agent,error\n(lessons not available)' }; + } + + return { text: lessonsService.toCSV() }; + }, +}; + +/** + * FULL: Detailed lesson breakdown. + */ +export const codeLessonsFullProvider: Provider = { + name: 'CODE_LESSONS_FULL', + description: 'Full lesson details: prompts, errors, files involved.', + dynamic: true, + + get: async ( + runtime: IAgentRuntime, + _message: Memory, + _state?: State + ): Promise<{ text: string }> => { + const lessonsService = runtime.getService('code-lessons'); + if (!lessonsService) { + return { text: '# Lessons\n(not available)' }; + } + + const overview = lessonsService.getOverview(); + const recent = lessonsService.getRecent(10); + + const sections: string[] = []; + sections.push('# Coding Lessons'); + sections.push(`Total: ${overview.total}`); + sections.push(`Recent (1h): ${overview.recentCount}`); + + // By agent breakdown + sections.push('\n## By Agent'); + for (const [agent, count] of Object.entries(overview.byAgent)) { + sections.push(`- ${agent}: ${count}`); + } + + // Recent lessons + sections.push('\n## Recent Lessons'); + for (const lesson of recent) { + const date = new Date(lesson.timestamp).toISOString(); + sections.push(`\n### ${lesson.id}`); + sections.push(`- Agent: ${lesson.agent}`); + sections.push(`- Time: ${date}`); + sections.push(`- Prompt: ${lesson.prompt}`); + sections.push(`- Error: ${lesson.error}`); + if (lesson.files?.length) { + sections.push(`- Files: ${lesson.files.join(', ')}`); + } + } + + return { text: sections.join('\n') }; + }, +}; diff --git a/src/providers/settings.provider.ts b/src/providers/settings.provider.ts new file mode 100644 index 0000000..543c212 --- /dev/null +++ b/src/providers/settings.provider.ts @@ -0,0 +1,96 @@ +/** + * CODE_SETTINGS Provider + * + * Exposes current code plugin settings (non-sensitive only). + * Helps users understand their current configuration. + */ + +import type { Provider, IAgentRuntime, Memory, State, ProviderResult } from '@elizaos/core'; +import { AgentRegistry } from '../services/agentRegistry.service.ts'; +import type { CoderService } from '../services/coderService.ts'; + +export const codeSettingsProvider: Provider = { + name: 'CODE_SETTINGS', + description: 'Current code plugin configuration and settings', + + get: async ( + runtime: IAgentRuntime, + message: Memory, + _state?: State + ): Promise => { + const coderService = runtime.getService('coder'); + const registry = AgentRegistry.getInstance(); + + // Get settings (non-sensitive) + const codeMode = (runtime.getSetting('CODE_MODE') as string) || 'auto'; + const coderEnabled = runtime.getSetting('CODER_ENABLED') !== 'false'; + const allowedDirectory = runtime.getSetting('CODER_ALLOWED_DIRECTORY') || 'not set'; + const timeoutMs = parseInt(runtime.getSetting('CODER_TIMEOUT_MS') as string) || 300000; + + // Get agent status + const registryStatus = registry.getStatus(); + const availableAgents = registry.getAvailable().map((a) => ({ + name: a.agent.getName(), + displayName: a.displayName, + isRecommended: a.isRecommended, + })); + + // Get current directory from service + const conversationId = message.roomId ?? runtime.agentId; + const currentDirectory = coderService?.getCurrentDirectory(conversationId) || allowedDirectory; + + // Check if workspace plugin is providing the directory + const workspaceService = runtime.getService('workspace'); + const usingWorkspace = !!workspaceService; + + const settings = { + codeMode, + coderEnabled, + allowedDirectory, + currentDirectory, + usingWorkspace, + timeoutMs, + timeoutFormatted: `${Math.round(timeoutMs / 1000)}s`, + agents: { + total: registryStatus.total, + available: registryStatus.available, + recommended: registryStatus.recommended || 'native', + list: availableAgents, + }, + }; + + const lines = [ + '## Code Plugin Settings', + '', + `**Enabled:** ${settings.coderEnabled ? 'Yes' : 'No'}`, + `**Mode:** ${settings.codeMode}`, + `**Timeout:** ${settings.timeoutFormatted}`, + '', + '**Directory:**', + `- Current: \`${settings.currentDirectory}\``, + `- Using Workspace: ${settings.usingWorkspace ? 'Yes' : 'No'}`, + '', + '**AI Agents:**', + `- Total Registered: ${settings.agents.total}`, + `- Available: ${settings.agents.available}`, + `- Recommended: ${settings.agents.recommended}`, + '', + '**Available Agents:**', + ]; + + for (const agent of settings.agents.list) { + const rec = agent.isRecommended ? ' ⭐' : ''; + lines.push(`- ${agent.displayName} (${agent.name})${rec}`); + } + + if (settings.agents.list.length === 0) { + lines.push('- None available'); + } + + return { + text: lines.join('\n'), + values: settings, + data: { settings }, + }; + }, +}; diff --git a/src/providers/stats.providers.ts b/src/providers/stats.providers.ts new file mode 100644 index 0000000..997ba03 --- /dev/null +++ b/src/providers/stats.providers.ts @@ -0,0 +1,130 @@ +/** + * Stats Providers - Multi-resolution context about agent performance + * + * WHY THREE RESOLUTIONS: + * - OVERVIEW: Best agent and success rate - quick recommendation + * - DEFAULT: CSV per agent - for comparison + * - FULL: Detailed breakdown - for analysis + * + * All marked dynamic: true so they're not auto-included in every prompt. + */ + +import type { Provider, IAgentRuntime, Memory, State } from '@elizaos/core'; +import type { StatsService } from '../services/stats.service.ts'; + +/** + * OVERVIEW: Best agent and overall success rate. + */ +export const codeStatsOverviewProvider: Provider = { + name: 'CODE_STATS_OVERVIEW', + description: 'Best agent and success rate. Quick performance check.', + dynamic: true, + + get: async ( + runtime: IAgentRuntime, + _message: Memory, + _state?: State + ): Promise<{ text: string }> => { + const statsService = runtime.getService('code-stats'); + if (!statsService) { + return { text: 'Stats: not available' }; + } + + const overview = statsService.getOverview(); + + if (overview.totalAttempts === 0) { + return { text: 'Stats: No attempts recorded yet' }; + } + + const overallRate = ((overview.totalSuccesses / overview.totalAttempts) * 100).toFixed(0); + const bestInfo = overview.bestAgent + ? `Best: ${overview.bestAgent} (${(overview.bestSuccessRate * 100).toFixed(0)}%)` + : 'No clear best agent yet'; + + return { + text: `Stats: ${overview.totalAttempts} attempts, ${overallRate}% success. ${bestInfo}`, + }; + }, +}; + +/** + * DEFAULT: CSV per agent. + */ +export const codeStatsProvider: Provider = { + name: 'CODE_STATS', + description: 'Agent stats CSV: attempts, successes, failures, rate. For comparison.', + dynamic: true, + + get: async ( + runtime: IAgentRuntime, + _message: Memory, + _state?: State + ): Promise<{ text: string }> => { + const statsService = runtime.getService('code-stats'); + if (!statsService) { + return { text: 'agent,attempts,successes,failures,success_rate\n(stats not available)' }; + } + + return { text: statsService.toCSV() }; + }, +}; + +/** + * FULL: Detailed stats breakdown. + */ +export const codeStatsFullProvider: Provider = { + name: 'CODE_STATS_FULL', + description: 'Full agent performance details: timing, trends, recommendations.', + dynamic: true, + + get: async ( + runtime: IAgentRuntime, + _message: Memory, + _state?: State + ): Promise<{ text: string }> => { + const statsService = runtime.getService('code-stats'); + if (!statsService) { + return { text: '# Stats\n(not available)' }; + } + + const overview = statsService.getOverview(); + const allStats = statsService.getAllStats(); + + const sections: string[] = []; + sections.push('# Agent Performance Statistics'); + sections.push(`\nTotal Attempts: ${overview.totalAttempts}`); + sections.push(`Total Successes: ${overview.totalSuccesses}`); + sections.push(`Total Failures: ${overview.totalFailures}`); + + if (overview.totalAttempts > 0) { + const overallRate = ((overview.totalSuccesses / overview.totalAttempts) * 100).toFixed(1); + sections.push(`Overall Success Rate: ${overallRate}%`); + } + + if (overview.bestAgent) { + sections.push(`\nRecommended Agent: ${overview.bestAgent}`); + sections.push(`Best Success Rate: ${(overview.bestSuccessRate * 100).toFixed(1)}%`); + } + + sections.push('\n## Per Agent Details'); + for (const stat of allStats.sort((a, b) => b.attempts - a.attempts)) { + const rate = stat.attempts > 0 ? ((stat.successes / stat.attempts) * 100).toFixed(1) : 'N/A'; + + sections.push(`\n### ${stat.agent}`); + sections.push(`- Attempts: ${stat.attempts}`); + sections.push(`- Successes: ${stat.successes}`); + sections.push(`- Failures: ${stat.failures}`); + sections.push(`- Success Rate: ${rate}%`); + + if (stat.avgDurationMs) { + sections.push(`- Avg Duration: ${(stat.avgDurationMs / 1000).toFixed(1)}s`); + } + + if (stat.lastUsed) { + sections.push(`- Last Used: ${new Date(stat.lastUsed).toISOString()}`); + } + } + + return { text: sections.join('\n') }; + }, +}; diff --git a/src/services/agentRegistry.service.ts b/src/services/agentRegistry.service.ts new file mode 100644 index 0000000..9b421c5 --- /dev/null +++ b/src/services/agentRegistry.service.ts @@ -0,0 +1,389 @@ +/** + * AgentRegistry - Central registry for AI coding agent backends + * + * WHY THIS EXISTS: + * plugin-code supports multiple AI coding agents (Native, Claude Code, Cursor, etc.). + * Rather than hardcoding agent detection and selection logic throughout the codebase, + * we use a centralized registry pattern. This provides: + * + * 1. SINGLE SOURCE OF TRUTH - All agent availability info in one place + * 2. DYNAMIC DETECTION - Agents self-register during plugin init if their CLI exists + * 3. EXTENSIBILITY - Adding new agents only requires calling registerAgentWithDetection() + * 4. CONSISTENT API - Actions query registry instead of duplicating detection logic + * + * WHY SINGLETON: + * Agent CLI detection should happen once at startup, not on every task. + * The singleton ensures consistent state across all consumers (actions, services). + * + * WHY PRIORITY SYSTEM (recommended > stable > available): + * - "recommended" = verified, tested, production-ready (e.g., Claude Code) + * - "stable" = works but may have limitations + * - "available" = CLI detected but may be experimental + * This fallback chain ensures users always get the best available agent. + * + * @see AGENT SELECTION FLOW: + * 1. User input parsed for explicit agent ("using cursor") + * 2. If none, check CODE_MODE setting (auto, native, cli) + * 3. If 'auto', use registry.getRecommended() + */ + +import { logger } from '@elizaos/core'; +import type { ICodingAgent } from '../interfaces/ICodingAgent.ts'; +import { commandExists } from './agents/utils.ts'; + +export interface AgentRegistration { + /** Agent instance */ + agent: ICodingAgent; + /** Display name for users */ + displayName: string; + /** CLI command name (or 'native' for native agent) */ + cliCommand: string; + /** Whether this agent is production-ready */ + isStable: boolean; + /** Whether this agent is recommended */ + isRecommended: boolean; + /** Optional description */ + description?: string; + /** Whether the CLI was detected and is available */ + isAvailable: boolean; + /** Alternative names/aliases for detection in user input */ + aliases?: string[]; +} + +export class AgentRegistry { + private static instance: AgentRegistry; + private agents: Map = new Map(); + + private constructor() { } + + /** + * Get singleton instance + */ + static getInstance(): AgentRegistry { + if (!AgentRegistry.instance) { + AgentRegistry.instance = new AgentRegistry(); + } + return AgentRegistry.instance; + } + + /** + * Reset singleton instance (for testing) + */ + static resetInstance(): void { + AgentRegistry.instance = new AgentRegistry(); + } + + /** + * Register an agent backend + */ + register(agentName: string, registration: AgentRegistration): void { + if (this.agents.has(agentName)) { + logger.warn(`[AgentRegistry] Agent '${agentName}' is already registered. Overwriting.`); + } + + this.agents.set(agentName, registration); + + const status = registration.isAvailable ? '✅' : '⚠️'; + const recommended = registration.isRecommended ? ' (recommended)' : ''; + const stable = registration.isStable ? '' : ' [experimental]'; + + logger.info( + `[AgentRegistry] ${status} Registered agent: ${agentName}${recommended}${stable} - CLI: ${registration.cliCommand}` + ); + } + + /** + * Unregister an agent backend + */ + unregister(agentName: string): boolean { + const existed = this.agents.delete(agentName); + if (existed) { + logger.info(`[AgentRegistry] Unregistered agent: ${agentName}`); + } + return existed; + } + + /** + * Get a specific agent registration + */ + get(agentName: string): AgentRegistration | undefined { + return this.agents.get(agentName); + } + + /** + * Get agent instance by name + */ + getAgent(agentName: string): ICodingAgent | undefined { + return this.agents.get(agentName)?.agent; + } + + /** + * Check if an agent is registered + */ + has(agentName: string): boolean { + return this.agents.has(agentName); + } + + /** + * Check if an agent is available (CLI detected or native) + */ + isAvailable(agentName: string): boolean { + const registration = this.agents.get(agentName); + return registration?.isAvailable ?? false; + } + + /** + * Get all registered agents + */ + getAll(): Map { + return new Map(this.agents); + } + + /** + * Get all available agents (CLI detected or native) + */ + getAvailable(): AgentRegistration[] { + const available: AgentRegistration[] = []; + for (const [, registration] of Array.from(this.agents.entries())) { + if (registration.isAvailable) { + available.push(registration); + } + } + return available; + } + + /** + * Get all CLI agents (excludes native) + */ + getCLIAgents(): AgentRegistration[] { + const cliAgents: AgentRegistration[] = []; + for (const [name, registration] of Array.from(this.agents.entries())) { + if (registration.isAvailable && name !== 'native') { + cliAgents.push(registration); + } + } + return cliAgents; + } + + /** + * Get recommended agent (if available) + * + * Priority: + * 1. Available recommended CLI agents + * 2. Available stable CLI agents + * 3. Any available CLI agent + * 4. Native agent (always available) + */ + getRecommended(): { name: string; registration: AgentRegistration } | null { + // First, find available recommended CLI agents + for (const [name, registration] of Array.from(this.agents.entries())) { + if (registration.isRecommended && registration.isAvailable && name !== 'native') { + return { name, registration }; + } + } + + // Fallback: first available stable CLI agent + for (const [name, registration] of Array.from(this.agents.entries())) { + if (registration.isStable && registration.isAvailable && name !== 'native') { + return { name, registration }; + } + } + + // Next: any available CLI agent + for (const [name, registration] of Array.from(this.agents.entries())) { + if (registration.isAvailable && name !== 'native') { + return { name, registration }; + } + } + + // Last resort: native agent (always available) + const native = this.agents.get('native'); + if (native?.isAvailable) { + return { name: 'native', registration: native }; + } + + return null; + } + + /** + * Get agent names sorted by priority (recommended, stable, available) + */ + getSortedNames(): string[] { + const agents = Array.from(this.agents.entries()); + + return agents + .sort((a, b) => { + const [nameA, regA] = a; + const [nameB, regB] = b; + + // Native goes last + if (nameA === 'native' && nameB !== 'native') return 1; + if (nameB === 'native' && nameA !== 'native') return -1; + + // Sort by: recommended > stable > available > name + if (regA.isRecommended !== regB.isRecommended) { + return regA.isRecommended ? -1 : 1; + } + if (regA.isStable !== regB.isStable) { + return regA.isStable ? -1 : 1; + } + if (regA.isAvailable !== regB.isAvailable) { + return regA.isAvailable ? -1 : 1; + } + return nameA.localeCompare(nameB); + }) + .map(([name]) => name); + } + + /** + * Get formatted list of agents for display + */ + getFormattedList(): string { + const sortedNames = this.getSortedNames(); + + if (sortedNames.length === 0) { + return 'No agents registered'; + } + + const lines: string[] = []; + for (const name of sortedNames) { + const reg = this.agents.get(name)!; + const status = reg.isAvailable ? '✅' : '❌'; + const recommended = reg.isRecommended ? ' (recommended)' : ''; + const experimental = !reg.isStable ? ' [experimental]' : ''; + const native = name === 'native' ? ' [native]' : ''; + + lines.push(`${status} ${name}${recommended}${experimental}${native}`); + if (reg.description) { + lines.push(` ${reg.description}`); + } + } + + return lines.join('\n'); + } + + /** + * Get count of registered agents + */ + count(): number { + return this.agents.size; + } + + /** + * Get count of available agents + */ + countAvailable(): number { + return Array.from(this.agents.values()).filter((r) => r.isAvailable).length; + } + + /** + * Clear all registrations (for testing) + */ + clear(): void { + this.agents.clear(); + logger.info('[AgentRegistry] Cleared all agent registrations'); + } + + /** + * Get registry status summary + */ + getStatus(): { + total: number; + available: number; + stable: number; + recommended: string | null; + hasNative: boolean; + hasCLI: boolean; + } { + const recommended = this.getRecommended(); + const cliAgents = this.getCLIAgents(); + + return { + total: this.count(), + available: this.countAvailable(), + stable: Array.from(this.agents.values()).filter((r) => r.isStable).length, + recommended: recommended?.name ?? null, + hasNative: this.agents.get('native')?.isAvailable ?? false, + hasCLI: cliAgents.length > 0, + }; + } + + /** + * Parse user input to detect if a specific agent is requested + * + * WHY THIS PARSING APPROACH: + * Users naturally say things like "fix the bug using cursor" or "add tests with claude". + * Rather than requiring rigid syntax, we detect common preposition patterns: + * - "using X", "with X", "via X", "in X", "on X" + * + * WHY ALIASES: + * Users say "claude" but the canonical name is "claude-code". + * Each agent registers aliases for flexible matching, resolving to canonical names. + * + * @param text - User input text to parse + * @returns Canonical agent name if detected, null otherwise + */ + parseAgentFromInput(text: string): string | null { + const lowerText = text.toLowerCase(); + + // Build regex pattern from all agent names and aliases + const patterns: Array<{ agentName: string; pattern: RegExp }> = []; + + for (const [agentName, registration] of Array.from(this.agents.entries())) { + const names = [agentName]; + if (registration.aliases) { + names.push(...registration.aliases); + } + + // Create pattern for this agent: matches "using X", "with X", "via X", "in X", "on X" + const namePattern = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); + const pattern = new RegExp( + `\\b(?:using|with|via|in|on)\\s+(?:the\\s+)?(${namePattern})(?:\\s+agent)?\\b`, + 'i' + ); + + patterns.push({ agentName, pattern }); + } + + // Try to match each pattern + for (const { agentName, pattern } of patterns) { + if (pattern.test(lowerText)) { + return agentName; + } + } + + return null; + } +} + +/** + * Helper function to register an agent with CLI detection + */ +export async function registerAgentWithDetection( + agentName: string, + agent: ICodingAgent, + cliCommand: string, + options: { + displayName: string; + isRecommended?: boolean; + isStable?: boolean; + description?: string; + aliases?: string[]; + } +): Promise { + const isAvailable = await commandExists(cliCommand); + + const registry = AgentRegistry.getInstance(); + registry.register(agentName, { + agent, + displayName: options.displayName, + cliCommand, + isStable: options.isStable ?? true, + isRecommended: options.isRecommended ?? false, + description: options.description, + aliases: options.aliases, + isAvailable, + }); + + return isAvailable; +} diff --git a/src/services/agents/aider.agent.ts b/src/services/agents/aider.agent.ts new file mode 100644 index 0000000..64c3310 --- /dev/null +++ b/src/services/agents/aider.agent.ts @@ -0,0 +1,185 @@ +/** + * AiderAgent - Executes coding tasks using Aider CLI + * + * ============================================================================ + * VERIFIED AGENT - AI pair programming in your terminal + * ============================================================================ + * + * AUTHENTICATION: + * - Requires ANTHROPIC_API_KEY or OPENAI_API_KEY environment variable + * - Supports both Anthropic and OpenAI models + * - CLI also supports --anthropic-api-key and --openai-api-key flags (we use env vars) + * + * WHY AIDER: + * 1. MULTI-MODEL - Works with Anthropic, OpenAI, and other providers + * 2. GIT INTEGRATION - Native git commit support + * 3. CONTEXT AWARE - Understands repository structure + * 4. ACTIVE DEVELOPMENT - Frequently updated with new features + * + * CLI BINARY: 'aider' + * + * CLI COMMAND STRUCTURE: + * ```bash + * aider --yes-always [--model ] --message "" + * # Note: Prompt is passed via --message flag, NOT stdin + * ``` + * + * MODEL FORMAT: + * Provider-prefixed names: + * - anthropic/claude-sonnet-4-5-20250929 + * - openai/gpt-5.2 + */ + +import { logger } from '@elizaos/core'; +import type { IAgentRuntime } from '@elizaos/core'; +import type { ICodingAgent, ExecuteParams, AgentResult } from '../../interfaces/ICodingAgent.ts'; +import { + execCommand, + commandExists, + isValidModelName, + createFixPrompt, + classifyError, +} from './utils.ts'; + +/** Binary name for Aider CLI */ +const AIDER_BINARY = 'aider'; + +export class AiderAgent implements ICodingAgent { + getName(): string { + return 'aider'; + } + + /** + * Check if Aider CLI is available and has API keys configured. + */ + async checkStatus(): Promise<{ ready: boolean; error?: string; version?: string }> { + // Check binary exists + if (!(await commandExists(AIDER_BINARY))) { + return { + ready: false, + error: 'aider CLI not found in PATH. Install: pip install aider-chat', + }; + } + + // Check for API keys + const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY; + const hasOpenAIKey = !!process.env.OPENAI_API_KEY; + + if (!hasAnthropicKey && !hasOpenAIKey) { + return { + ready: false, + error: 'No API key set. Set ANTHROPIC_API_KEY or OPENAI_API_KEY', + }; + } + + // Get version + try { + const result = await execCommand(AIDER_BINARY, ['--version'], { cwd: '/' }); + return { ready: true, version: result.stdout.trim() }; + } catch { + return { ready: true }; + } + } + + async execute(params: ExecuteParams, runtime: IAgentRuntime): Promise { + logger.info(`[AiderAgent] Executing task in ${params.projectPath}`); + logger.debug(`[AiderAgent] Prompt: ${params.prompt.substring(0, 100)}...`); + + try { + // Check if aider CLI is available + if (!(await commandExists(AIDER_BINARY))) { + return { + success: false, + error: 'aider CLI not found. Install with: pip install aider-chat', + }; + } + + // Check for API keys + const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY; + const hasOpenAIKey = !!process.env.OPENAI_API_KEY; + + if (!hasAnthropicKey && !hasOpenAIKey) { + return { + success: false, + error: 'No API key set. Set ANTHROPIC_API_KEY or OPENAI_API_KEY', + }; + } + + // Build arguments + const args: string[] = [ + '--yes-always', // Auto-confirm all prompts + ]; + + // Add model if specified + const model = params.model || (runtime.getSetting('CODE_AIDER_MODEL') as string | undefined); + if (model && isValidModelName(model)) { + args.push('--model', model); + } + + // Pass prompt via --message argument (NOT stdin) + args.push('--message', params.prompt); + + logger.debug( + `[AiderAgent] Running: ${AIDER_BINARY} ${args.slice(0, -1).join(' ')} --message "..."` + ); + + // Execute (stdin is inherited, prompt goes via --message) + const result = await execCommand(AIDER_BINARY, args, { + cwd: params.projectPath, + timeout: 300000, // 5 minute timeout + }); + + // Filter out warnings from stderr + let filteredStderr = result.stderr; + if (result.stderr) { + filteredStderr = result.stderr + .split('\n') + .filter((line) => !line.includes('Warning')) + .join('\n'); + } + + const output = result.stdout + (filteredStderr ? `\n${filteredStderr}` : ''); + + if (result.exitCode !== 0) { + classifyError(output); + logger.error(`[AiderAgent] Failed with exit code: ${result.exitCode}`); + return { + success: false, + error: filteredStderr || 'Aider execution failed', + output, + }; + } + + logger.info('[AiderAgent] Task execution completed successfully'); + + return { + success: true, + modifiedFiles: [], // Detected via git diff + output, + }; + } catch (error: unknown) { + const err = error as Error; + logger.error('[AiderAgent] Task execution failed:', err); + return { + success: false, + error: err.message || 'Unknown error', + output: '', + }; + } + } + + async fixError( + error: string, + originalParams: ExecuteParams, + runtime: IAgentRuntime + ): Promise { + logger.info(`[AiderAgent] Fixing error in ${originalParams.projectPath}`); + + const fixParams: ExecuteParams = { + ...originalParams, + prompt: createFixPrompt(originalParams.prompt, error), + }; + + return this.execute(fixParams, runtime); + } +} diff --git a/src/services/agents/claudeCode.agent.ts b/src/services/agents/claudeCode.agent.ts new file mode 100644 index 0000000..9b59900 --- /dev/null +++ b/src/services/agents/claudeCode.agent.ts @@ -0,0 +1,228 @@ +/** + * ClaudeCodeAgent - Executes coding tasks using Claude Code CLI + * + * ============================================================================ + * RECOMMENDED AGENT - Fully verified and production-ready + * ============================================================================ + * + * AUTHENTICATION: + * - Requires ANTHROPIC_API_KEY environment variable + * - No CLI flag for API key - must use env var + * + * WHY CLAUDE CODE IS THE DEFAULT: + * 1. VERIFIED CLI API - The `claude --print` command is tested + * 2. AUTO-APPROVAL - The --dangerously-skip-permissions flag enables unattended execution + * 3. RELIABLE - Anthropic's official CLI with good error messages + * 4. PRODUCTION TESTED - This agent has been validated end-to-end + * + * WHY --dangerously-skip-permissions IS CRITICAL: + * The toolkit runs tasks automatically without user interaction. + * Without this flag, Claude Code would prompt for file write permissions, + * blocking the entire pipeline. This flag makes automated execution possible. + * + * CAVEAT: Claude Code refuses --dangerously-skip-permissions when running as root. + * In that case, we fall back to interactive mode (may hang waiting for approval). + * + * CLI BINARIES: 'claude' or 'claude-code' (we try both) + * + * CLI COMMAND STRUCTURE: + * ```bash + * claude --print --dangerously-skip-permissions [--model ] + * # Prompt is passed via stdin + * ``` + */ + +import { logger } from '@elizaos/core'; +import type { IAgentRuntime } from '@elizaos/core'; +import type { ICodingAgent, ExecuteParams, AgentResult } from '../../interfaces/ICodingAgent.ts'; +import { + execCommand, + findAvailableBinary, + isValidModelName, + createFixPrompt, + classifyError, +} from './utils.ts'; + +/** Possible binary names for Claude Code CLI */ +const CLAUDE_BINARIES = ['claude', 'claude-code']; + +/** Patterns indicating permission errors that need --dangerously-skip-permissions */ +const PERMISSION_ERROR_PATTERNS = [ + /requested permissions? to write/i, + /haven't granted it yet/i, + /permission.*denied/i, + /Unable to write.*permission/i, + /persistent permission error/i, +]; + +/** + * Check if output contains permission errors. + */ +function hasPermissionError(output: string): boolean { + return PERMISSION_ERROR_PATTERNS.some((pattern) => pattern.test(output)); +} + +/** + * Check if running as root (UID 0). + * Claude Code refuses --dangerously-skip-permissions when running as root. + */ +function isRunningAsRoot(): boolean { + return process.getuid?.() === 0; +} + +/** + * Check if skip-permissions should be enabled. + * Controlled by CODE_CLAUDE_SKIP_PERMISSIONS env var. + */ +function shouldSkipPermissions(): boolean { + const envValue = process.env.CODE_CLAUDE_SKIP_PERMISSIONS; + // Default: true (enabled), set to '0' or 'false' to disable + if (envValue === '0' || envValue === 'false') { + return false; + } + // Also disable if running as root (Claude Code will refuse anyway) + if (isRunningAsRoot()) { + logger.warn('[ClaudeCodeAgent] Running as root - cannot use --dangerously-skip-permissions'); + return false; + } + return true; +} + +export class ClaudeCodeAgent implements ICodingAgent { + private binaryPath: string | null = null; + + getName(): string { + return 'claude-code'; + } + + /** + * Check if Claude Code CLI is available and authenticated. + */ + async checkStatus(): Promise<{ ready: boolean; error?: string; version?: string }> { + // Find binary + const binary = await findAvailableBinary(CLAUDE_BINARIES); + if (!binary) { + return { ready: false, error: 'Claude Code CLI not found (tried: claude, claude-code)' }; + } + this.binaryPath = binary; + + // Check for API key + if (!process.env.ANTHROPIC_API_KEY) { + return { ready: false, error: 'ANTHROPIC_API_KEY not set' }; + } + + // Get version + try { + const result = await execCommand(binary, ['--version'], { cwd: '/' }); + const version = result.stdout.trim(); + return { ready: true, version }; + } catch { + return { ready: true }; // Binary exists but version check failed - still usable + } + } + + async execute(params: ExecuteParams, runtime: IAgentRuntime): Promise { + const taskId = params.conversationId || 'unknown'; + logger.info(`[ClaudeCodeAgent] Executing task in ${params.projectPath}`); + logger.debug(`[ClaudeCodeAgent] Prompt: ${params.prompt.substring(0, 100)}...`); + + try { + // Find binary if not already found + if (!this.binaryPath) { + this.binaryPath = await findAvailableBinary(CLAUDE_BINARIES); + } + + if (!this.binaryPath) { + return { + success: false, + error: 'Claude Code CLI not found. Install from: https://claude.com/claude-code', + }; + } + + // Check for API key + if (!process.env.ANTHROPIC_API_KEY) { + return { + success: false, + error: 'ANTHROPIC_API_KEY environment variable not set', + }; + } + + // Build arguments + const args: string[] = ['--print']; + + // Add skip-permissions flag if appropriate + if (shouldSkipPermissions()) { + args.push('--dangerously-skip-permissions'); + } + + // Add model if specified (from params or runtime settings) + const model = params.model || (runtime.getSetting('CODE_CLAUDE_MODEL') as string | undefined); + if (model && isValidModelName(model)) { + args.push('--model', model); + } + + logger.debug(`[ClaudeCodeAgent] Running: ${this.binaryPath} ${args.join(' ')}`); + + // Execute with prompt via stdin + const result = await execCommand(this.binaryPath, args, { + cwd: params.projectPath, + stdin: params.prompt, + timeout: 300000, // 5 minute timeout + }); + + const output = result.stdout + (result.stderr ? `\n${result.stderr}` : ''); + + // Check for permission errors + if (hasPermissionError(output)) { + logger.warn('[ClaudeCodeAgent] Permission error detected'); + return { + success: false, + error: + 'Claude Code requires file write permissions. Try setting CODE_CLAUDE_SKIP_PERMISSIONS=1', + output, + }; + } + + if (result.exitCode !== 0) { + classifyError(output); + logger.error(`[ClaudeCodeAgent] Failed with exit code: ${result.exitCode}`); + return { + success: false, + error: result.stderr || 'Claude Code execution failed', + output, + }; + } + + logger.info('[ClaudeCodeAgent] Task execution completed successfully'); + + return { + success: true, + modifiedFiles: [], // Can be detected via git diff if needed + output, + }; + } catch (error: unknown) { + const err = error as Error & { stdout?: string }; + logger.error('[ClaudeCodeAgent] Task execution failed:', err); + return { + success: false, + error: err.message || 'Unknown error', + output: err.stdout || '', + }; + } + } + + async fixError( + error: string, + originalParams: ExecuteParams, + runtime: IAgentRuntime + ): Promise { + logger.info(`[ClaudeCodeAgent] Fixing error in ${originalParams.projectPath}`); + + const fixParams: ExecuteParams = { + ...originalParams, + prompt: createFixPrompt(originalParams.prompt, error), + }; + + return this.execute(fixParams, runtime); + } +} diff --git a/src/services/agents/codex.agent.ts b/src/services/agents/codex.agent.ts new file mode 100644 index 0000000..994beb3 --- /dev/null +++ b/src/services/agents/codex.agent.ts @@ -0,0 +1,216 @@ +/** + * CodexAgent - Executes coding tasks using OpenAI Codex CLI + * + * ============================================================================ + * VERIFIED AGENT - OpenAI's official coding CLI + * ============================================================================ + * + * AUTHENTICATION: + * - Requires OPENAI_API_KEY environment variable + * - No CLI flag for API key - must use env var + * + * WHY CODEX: + * 1. OPENAI OFFICIAL - First-party tool from OpenAI + * 2. SANDBOX MODE - Can bypass for automation + * 3. CONTEXT DIRECTORIES - Add extra dirs for context + * 4. EXEC MODE - Non-interactive execution mode + * + * CLI BINARIES: 'codex' or 'openai-codex' (we try both) + * + * CLI COMMAND STRUCTURE: + * ```bash + * codex exec --dangerously-bypass-approvals-and-sandbox \ + * -C /path/to/project [--model ] [--add-dir ]... - + * # Prompt is read from stdin (the '-' argument) + * ``` + * + * ENV HINTS FOR AUTOMATION: + * - CI=1 - Hint that we're in non-interactive mode + * - NO_COLOR=1 - Disable ANSI colors + * - FORCE_COLOR=0 - Also disable colors + */ + +import { logger } from '@elizaos/core'; +import type { IAgentRuntime } from '@elizaos/core'; +import type { ICodingAgent, ExecuteParams, AgentResult } from '../../interfaces/ICodingAgent.ts'; +import { + execCommand, + findAvailableBinary, + isValidModelName, + createFixPrompt, + classifyError, +} from './utils.ts'; + +/** Possible binary names for Codex CLI */ +const CODEX_BINARIES = ['codex', 'openai-codex']; + +/** + * Check for TTY/cursor position errors that indicate environment issues. + */ +function isCursorPositionError(output?: string): boolean { + if (!output) return false; + // Strip ANSI codes + const cleaned = output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '').replace(/[\x00-\x1f]/g, ' '); + return /cursor.{0,10}position.{0,10}could.{0,10}not.{0,10}be.{0,10}read/i.test(cleaned); +} + +export class CodexAgent implements ICodingAgent { + private binaryPath: string | null = null; + + getName(): string { + return 'codex'; + } + + /** + * Check if Codex CLI is available and authenticated. + */ + async checkStatus(): Promise<{ ready: boolean; error?: string; version?: string }> { + // Find binary + const binary = await findAvailableBinary(CODEX_BINARIES); + if (!binary) { + return { ready: false, error: 'Codex CLI not found (tried: codex, openai-codex)' }; + } + this.binaryPath = binary; + + // Check for API key + if (!process.env.OPENAI_API_KEY) { + return { ready: false, error: 'OPENAI_API_KEY not set' }; + } + + // Get version + try { + const result = await execCommand(binary, ['--version'], { cwd: '/' }); + return { ready: true, version: result.stdout.trim() }; + } catch { + return { ready: true }; + } + } + + async execute(params: ExecuteParams, runtime: IAgentRuntime): Promise { + logger.info(`[CodexAgent] Executing task in ${params.projectPath}`); + logger.debug(`[CodexAgent] Prompt: ${params.prompt.substring(0, 100)}...`); + + try { + // Find binary if not already found + if (!this.binaryPath) { + this.binaryPath = await findAvailableBinary(CODEX_BINARIES); + } + + if (!this.binaryPath) { + return { + success: false, + error: 'Codex CLI not found. Install OpenAI Codex CLI.', + }; + } + + // Check for API key + if (!process.env.OPENAI_API_KEY) { + return { + success: false, + error: 'OPENAI_API_KEY environment variable not set', + }; + } + + // Build arguments + const args: string[] = [ + 'exec', // Non-interactive execution mode + '--dangerously-bypass-approvals-and-sandbox', // Full automation + '-C', + params.projectPath, // Working directory + ]; + + // Add model if specified + const model = params.model || (runtime.getSetting('CODE_CODEX_MODEL') as string | undefined); + if (model && isValidModelName(model)) { + args.push('--model', model); + } + + // Add extra context directories if specified + const addDirs = runtime.getSetting('CODE_CODEX_ADD_DIRS') as string | undefined; + if (addDirs) { + try { + const dirs = JSON.parse(addDirs); + if (Array.isArray(dirs)) { + for (const dir of dirs) { + if (typeof dir === 'string') { + args.push('--add-dir', dir); + } + } + } + } catch { + logger.warn('[CodexAgent] Invalid CODE_CODEX_ADD_DIRS JSON'); + } + } + + // Read prompt from stdin (the '-' argument) + args.push('-'); + + logger.debug(`[CodexAgent] Running: ${this.binaryPath} ${args.join(' ')}`); + + // Execute with prompt via stdin and automation env vars + const result = await execCommand(this.binaryPath, args, { + cwd: params.projectPath, + stdin: params.prompt, + timeout: 300000, // 5 minute timeout + env: { + CI: '1', // Hint: non-interactive + NO_COLOR: '1', // Disable ANSI colors + FORCE_COLOR: '0', + }, + }); + + const output = result.stdout + (result.stderr ? `\n${result.stderr}` : ''); + + // Check for environment issues + if (isCursorPositionError(output)) { + logger.error('[CodexAgent] TTY/cursor position error detected'); + return { + success: false, + error: 'Environment error: TTY cursor position could not be read', + output, + }; + } + + if (result.exitCode !== 0) { + classifyError(output); + logger.error(`[CodexAgent] Failed with exit code: ${result.exitCode}`); + return { + success: false, + error: result.stderr || 'Codex execution failed', + output, + }; + } + + logger.info('[CodexAgent] Task execution completed successfully'); + + return { + success: true, + modifiedFiles: [], // Detected via git diff + output, + }; + } catch (error: unknown) { + const err = error as Error; + logger.error('[CodexAgent] Task execution failed:', err); + return { + success: false, + error: err.message || 'Unknown error', + output: '', + }; + } + } + + async fixError( + error: string, + originalParams: ExecuteParams, + runtime: IAgentRuntime + ): Promise { + logger.info(`[CodexAgent] Fixing error in ${originalParams.projectPath}`); + + const fixParams: ExecuteParams = { + ...originalParams, + prompt: createFixPrompt(originalParams.prompt, error), + }; + + return this.execute(fixParams, runtime); + } +} diff --git a/src/services/agents/cursor.agent.ts b/src/services/agents/cursor.agent.ts new file mode 100644 index 0000000..95778a5 --- /dev/null +++ b/src/services/agents/cursor.agent.ts @@ -0,0 +1,207 @@ +/** + * CursorAgent - Executes coding tasks using Cursor Agent CLI + * + * ============================================================================ + * VERIFIED AGENT - Uses cursor-agent CLI with stream-json output + * ============================================================================ + * + * AUTHENTICATION: + * - OAuth via browser: `cursor-agent login` + * - No API key needed - uses OAuth tokens stored locally + * - Check status by running `cursor-agent models` + * + * WHY CURSOR AGENT: + * 1. OAUTH AUTH - No API key management needed + * 2. STREAM JSON - Structured output for progress tracking + * 3. MODEL SELECTION - Can use claude-4, gpt-4o, etc. + * 4. TOOL USE VISIBILITY - See which tools the agent uses + * + * CLI BINARY: 'cursor-agent' + * + * CLI COMMAND STRUCTURE: + * ```bash + * cursor-agent --print --output-format stream-json --stream-partial-output \ + * --workspace /path/to/project [--model ] + * # Prompt is passed via stdin + * ``` + * + * OUTPUT FORMAT (stream-json): + * Each line is a JSON object: + * - {"type": "text", "content": "I'll fix..."} + * - {"type": "tool_use", "name": "edit_file", "input": {"path": "src/foo.ts"}} + * - {"type": "tool_result", "content": "...", "is_error": false} + * - {"type": "message_start"} + * - {"type": "message_stop"} + */ + +import { logger } from '@elizaos/core'; +import type { IAgentRuntime } from '@elizaos/core'; +import type { ICodingAgent, ExecuteParams, AgentResult } from '../../interfaces/ICodingAgent.ts'; +import { execCommand, commandExists, isValidModelName, createFixPrompt } from './utils.ts'; + +/** Binary name for Cursor Agent CLI */ +const CURSOR_AGENT_BINARY = 'cursor-agent'; + +/** + * Parse stream-json output from cursor-agent. + * Extracts text content and tool usage for logging. + */ +function parseStreamJson(output: string): { text: string; toolsUsed: string[] } { + const lines = output.split('\n').filter((line) => line.trim()); + let text = ''; + const toolsUsed: string[] = []; + + for (const line of lines) { + try { + const json = JSON.parse(line); + if (json.type === 'text' && json.content) { + text += json.content; + } else if (json.type === 'tool_use') { + const toolInfo = `${json.name || 'tool'}: ${json.input?.path || json.input?.command || ''}`; + toolsUsed.push(toolInfo); + } else if (json.content) { + text += json.content; + } + } catch { + // Not JSON, include raw line + if (line.trim()) { + text += line + '\n'; + } + } + } + + return { text: text.trim(), toolsUsed }; +} + +export class CursorAgent implements ICodingAgent { + getName(): string { + return 'cursor'; + } + + /** + * Check if Cursor Agent CLI is available and authenticated. + */ + async checkStatus(): Promise<{ ready: boolean; error?: string; version?: string }> { + // Check binary exists + if (!(await commandExists(CURSOR_AGENT_BINARY))) { + return { ready: false, error: 'cursor-agent CLI not found in PATH' }; + } + + // Check authentication by listing models + try { + const result = await execCommand(CURSOR_AGENT_BINARY, ['models'], { cwd: '/' }); + const output = result.stdout + result.stderr; + + if (output.includes('Available models') || output.includes('auto')) { + // Get version + try { + const versionResult = await execCommand(CURSOR_AGENT_BINARY, ['--version'], { cwd: '/' }); + return { ready: true, version: versionResult.stdout.trim() }; + } catch { + return { ready: true }; + } + } + + if (output.includes('login') || output.includes('auth')) { + return { ready: false, error: 'Not logged in. Run: cursor-agent login' }; + } + + return { ready: true }; + } catch (error: unknown) { + const err = error as Error; + return { ready: false, error: `Failed to check auth: ${err.message}` }; + } + } + + async execute(params: ExecuteParams, runtime: IAgentRuntime): Promise { + logger.info(`[CursorAgent] Executing task in ${params.projectPath}`); + logger.debug(`[CursorAgent] Prompt: ${params.prompt.substring(0, 100)}...`); + + try { + // Check if cursor-agent CLI is available + if (!(await commandExists(CURSOR_AGENT_BINARY))) { + return { + success: false, + error: 'cursor-agent CLI not found. Please install Cursor and run: cursor-agent login', + }; + } + + // Build arguments + const args: string[] = [ + '--print', + '--output-format', + 'stream-json', + '--stream-partial-output', + '--workspace', + params.projectPath, + ]; + + // Add model if specified + const model = params.model || (runtime.getSetting('CODE_CURSOR_MODEL') as string | undefined); + if (model && isValidModelName(model)) { + args.push('--model', model); + } + + logger.debug(`[CursorAgent] Running: ${CURSOR_AGENT_BINARY} ${args.join(' ')}`); + + // Execute with prompt via stdin + const result = await execCommand(CURSOR_AGENT_BINARY, args, { + cwd: params.projectPath, + stdin: params.prompt, + timeout: 300000, // 5 minute timeout + }); + + const rawOutput = result.stdout + (result.stderr ? `\n${result.stderr}` : ''); + + // Parse stream-json output + const { text, toolsUsed } = parseStreamJson(result.stdout); + + if (toolsUsed.length > 0) { + logger.info(`[CursorAgent] Tools used: ${toolsUsed.length}`); + for (const tool of toolsUsed) { + logger.debug(`[CursorAgent] - ${tool}`); + } + } + + if (result.exitCode !== 0) { + logger.error(`[CursorAgent] Failed with exit code: ${result.exitCode}`); + return { + success: false, + error: result.stderr || 'Cursor Agent execution failed', + output: text || rawOutput, + }; + } + + logger.info('[CursorAgent] Task execution completed successfully'); + + return { + success: true, + modifiedFiles: [], // Detected via git diff + output: text || rawOutput, + }; + } catch (error: unknown) { + const err = error as Error; + logger.error('[CursorAgent] Task execution failed:', err); + return { + success: false, + error: err.message || 'Unknown error', + output: '', + }; + } + } + + async fixError( + error: string, + originalParams: ExecuteParams, + runtime: IAgentRuntime + ): Promise { + logger.info(`[CursorAgent] Fixing error in ${originalParams.projectPath}`); + + const fixParams: ExecuteParams = { + ...originalParams, + prompt: createFixPrompt(originalParams.prompt, error), + }; + + return this.execute(fixParams, runtime); + } +} diff --git a/src/services/agents/index.ts b/src/services/agents/index.ts new file mode 100644 index 0000000..15c1734 --- /dev/null +++ b/src/services/agents/index.ts @@ -0,0 +1,45 @@ +/** + * AI Coding Agents - plugin-code Agent Registry + * + * This module exports all supported AI coding agents. + * Each agent wraps a specific CLI tool (Claude Code, Cursor, Aider, etc.) + * or uses native elizaOS capabilities (NativeCoderAgent). + * + * SUPPORTED AGENTS: + * + * | Agent | Binary | Auth Method | Notes | + * |-------------|--------------|----------------------|----------------------| + * | native | (none) | elizaOS LLM | Always available | + * | claude-code | claude | ANTHROPIC_API_KEY | Recommended | + * | cursor | cursor-agent | OAuth (browser) | stream-json output | + * | aider | aider | ANTHROPIC/OPENAI_KEY | Multi-provider | + * | codex | codex | OPENAI_API_KEY | OpenAI official | + * | opencode | opencode | Provider-specific | Multi-provider | + * + * ADDING A NEW AGENT: + * 1. Create new-agent.agent.ts implementing ICodingAgent + * 2. Export it from this index file + * 3. Register it in plugin init (index.ts) + */ + +// Export all agent implementations +export { NativeCoderAgent } from './native.agent.ts'; +export { ClaudeCodeAgent } from './claudeCode.agent.ts'; +export { CursorAgent } from './cursor.agent.ts'; +export { AiderAgent } from './aider.agent.ts'; +export { CodexAgent } from './codex.agent.ts'; +export { OpenCodeAgent } from './opencode.agent.ts'; + +// Export shared utilities +export { + execCommand, + commandExists, + findAvailableBinary, + isValidModelName, + writePromptFile, + cleanupPromptFile, + classifyError, + createFixPrompt, + type ExecResult, + type ErrorType, +} from './utils.ts'; diff --git a/src/services/agents/native.agent.ts b/src/services/agents/native.agent.ts new file mode 100644 index 0000000..3216363 --- /dev/null +++ b/src/services/agents/native.agent.ts @@ -0,0 +1,201 @@ +/** + * NativeCoderAgent - Executes coding tasks using elizaOS LLM + file actions + * + * ============================================================================ + * ALWAYS AVAILABLE - No external CLI tools required + * ============================================================================ + * + * WHY NATIVE AGENT: + * 1. ALWAYS WORKS - No external CLI installation needed + * 2. USES ELIZAOS - Leverages existing LLM providers and file operations + * 3. FALLBACK - When no CLI agents are available + * 4. INTEGRATED - Works directly with CoderService file operations + * + * HOW IT WORKS: + * 1. Receives prompt from CODE action + * 2. Asks elizaOS LLM to generate file changes in a structured format + * 3. Parses response for content blocks + * 4. Writes files using CoderService + * + * PROMPT FORMAT: + * The LLM is asked to respond with file changes in XML-like format: + * ``` + * + * entire file contents here + * + * ``` + * + * WHY THIS FORMAT: + * - Easy to parse with regex + * - Clear boundaries between files + * - Works well with LLM output + * - Similar to other AI coding tool formats + */ + +import { logger, ModelType } from '@elizaos/core'; +import type { IAgentRuntime } from '@elizaos/core'; +import type { ICodingAgent, ExecuteParams, AgentResult } from '../../interfaces/ICodingAgent.ts'; +import type { CoderService } from '../coderService.ts'; +import { createFixPrompt } from './utils.ts'; + +/** + * Regex to parse file blocks from LLM response. + * Matches: content + */ +const FILE_BLOCK_REGEX = /([\s\S]*?)<\/file>/g; + +/** + * Build the system prompt for code generation. + */ +function buildSystemPrompt(projectPath: string): string { + return `You are a coding assistant. Make the requested code changes. + +Working directory: ${projectPath} + +Respond with file changes in this exact format: + +entire file contents here + + +You can include multiple blocks. Only include files that need changes. +Include the ENTIRE file contents, not just the changed parts. +Use relative paths from the working directory.`; +} + +export class NativeCoderAgent implements ICodingAgent { + getName(): string { + return 'native'; + } + + async execute(params: ExecuteParams, runtime: IAgentRuntime): Promise { + const conversationId = params.conversationId || 'default'; + logger.info(`[NativeCoderAgent] Executing task in ${params.projectPath}`); + logger.debug(`[NativeCoderAgent] Prompt: ${params.prompt.substring(0, 100)}...`); + + try { + // Get CoderService for file operations + const coderService = runtime.getService('coder'); + if (!coderService) { + return { + success: false, + error: 'CoderService not available - cannot write files', + }; + } + + // Build the full prompt + const systemPrompt = buildSystemPrompt(params.projectPath); + const fullPrompt = `${systemPrompt}\n\nTask: ${params.prompt}`; + + logger.debug('[NativeCoderAgent] Calling LLM for code generation...'); + + // Generate code changes via LLM + const response = await runtime.useModel(ModelType.TEXT_LARGE, { + prompt: fullPrompt, + }); + + if (!response || !response.text) { + return { + success: false, + error: 'LLM returned empty response', + output: '', + }; + } + + logger.debug(`[NativeCoderAgent] LLM response length: ${response.text.length}`); + + // Parse response for file blocks + const changes: { path: string; content: string }[] = []; + let match; + + // Reset regex lastIndex + FILE_BLOCK_REGEX.lastIndex = 0; + + while ((match = FILE_BLOCK_REGEX.exec(response.text)) !== null) { + const filePath = match[1].trim(); + const content = match[2].trim(); + + if (filePath && content) { + changes.push({ path: filePath, content }); + logger.debug( + `[NativeCoderAgent] Found file block: ${filePath} (${content.length} chars)` + ); + } + } + + if (changes.length === 0) { + // No file blocks found - maybe the LLM gave a different format or explanation + logger.warn('[NativeCoderAgent] No file changes found in LLM response'); + return { + success: false, + error: + 'No file changes found in LLM response. The LLM may have misunderstood the format.', + output: response.text, + }; + } + + // Apply changes using CoderService + const modifiedFiles: string[] = []; + const errors: string[] = []; + + for (const change of changes) { + logger.debug(`[NativeCoderAgent] Writing file: ${change.path}`); + + const result = await coderService.writeFile(conversationId, change.path, change.content); + + if (result.ok) { + modifiedFiles.push(change.path); + } else { + errors.push(`Failed to write ${change.path}: ${result.error}`); + logger.error(`[NativeCoderAgent] Failed to write ${change.path}: ${result.error}`); + } + } + + // If some files failed but others succeeded, partial success + if (errors.length > 0 && modifiedFiles.length === 0) { + return { + success: false, + error: errors.join('\n'), + modifiedFiles: [], + output: response.text, + }; + } + + if (errors.length > 0) { + logger.warn( + `[NativeCoderAgent] Partial success: ${modifiedFiles.length} files written, ${errors.length} errors` + ); + } + + logger.info(`[NativeCoderAgent] Task completed: ${modifiedFiles.length} file(s) modified`); + + return { + success: true, + modifiedFiles, + output: `Modified ${modifiedFiles.length} file(s): ${modifiedFiles.join(', ')}${errors.length > 0 ? `\nWarnings: ${errors.join(', ')}` : ''}`, + }; + } catch (error: unknown) { + const err = error as Error; + logger.error('[NativeCoderAgent] Task execution failed:', err); + return { + success: false, + error: err.message || 'Unknown error', + output: '', + }; + } + } + + async fixError( + error: string, + originalParams: ExecuteParams, + runtime: IAgentRuntime + ): Promise { + logger.info(`[NativeCoderAgent] Fixing error in ${originalParams.projectPath}`); + + const fixParams: ExecuteParams = { + ...originalParams, + prompt: createFixPrompt(originalParams.prompt, error), + }; + + return this.execute(fixParams, runtime); + } +} diff --git a/src/services/agents/opencode.agent.ts b/src/services/agents/opencode.agent.ts new file mode 100644 index 0000000..0f50383 --- /dev/null +++ b/src/services/agents/opencode.agent.ts @@ -0,0 +1,164 @@ +/** + * OpenCodeAgent - Executes coding tasks using OpenCode CLI + * + * ============================================================================ + * VERIFIED AGENT - Multi-provider AI coding assistant + * ============================================================================ + * + * AUTHENTICATION: + * - Requires provider-specific API key in environment + * - ANTHROPIC_API_KEY for Claude models + * - OPENAI_API_KEY for GPT models + * - Can also use config file for credentials + * + * WHY OPENCODE: + * 1. MULTI-PROVIDER - Works with Anthropic, OpenAI, and others + * 2. SIMPLE CLI - Minimal flags needed + * 3. EXTENSIBLE - Plugin architecture for providers + * 4. LIGHTWEIGHT - Fast startup time + * + * CLI BINARY: 'opencode' + * + * CLI COMMAND STRUCTURE: + * ```bash + * opencode [--model ] + * # Prompt is passed via stdin + * ``` + */ + +import { logger } from '@elizaos/core'; +import type { IAgentRuntime } from '@elizaos/core'; +import type { ICodingAgent, ExecuteParams, AgentResult } from '../../interfaces/ICodingAgent.ts'; +import { + execCommand, + commandExists, + isValidModelName, + createFixPrompt, + classifyError, +} from './utils.ts'; + +/** Binary name for OpenCode CLI */ +const OPENCODE_BINARY = 'opencode'; + +export class OpenCodeAgent implements ICodingAgent { + getName(): string { + return 'opencode'; + } + + /** + * Check if OpenCode CLI is available and has API keys configured. + */ + async checkStatus(): Promise<{ ready: boolean; error?: string; version?: string }> { + // Check binary exists + if (!(await commandExists(OPENCODE_BINARY))) { + return { ready: false, error: 'opencode CLI not found in PATH' }; + } + + // Check for API keys (at least one provider) + const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY; + const hasOpenAIKey = !!process.env.OPENAI_API_KEY; + + if (!hasAnthropicKey && !hasOpenAIKey) { + return { + ready: false, + error: 'No API key set. Set ANTHROPIC_API_KEY or OPENAI_API_KEY', + }; + } + + // Get version + try { + const result = await execCommand(OPENCODE_BINARY, ['--version'], { cwd: '/' }); + return { ready: true, version: result.stdout.trim() }; + } catch { + return { ready: true }; + } + } + + async execute(params: ExecuteParams, runtime: IAgentRuntime): Promise { + logger.info(`[OpenCodeAgent] Executing task in ${params.projectPath}`); + logger.debug(`[OpenCodeAgent] Prompt: ${params.prompt.substring(0, 100)}...`); + + try { + // Check if opencode CLI is available + if (!(await commandExists(OPENCODE_BINARY))) { + return { + success: false, + error: 'opencode CLI not found in PATH', + }; + } + + // Check for API keys + const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY; + const hasOpenAIKey = !!process.env.OPENAI_API_KEY; + + if (!hasAnthropicKey && !hasOpenAIKey) { + return { + success: false, + error: 'No API key set. Set ANTHROPIC_API_KEY or OPENAI_API_KEY', + }; + } + + // Build arguments + const args: string[] = []; + + // Add model if specified + const model = + params.model || (runtime.getSetting('CODE_OPENCODE_MODEL') as string | undefined); + if (model && isValidModelName(model)) { + args.push('--model', model); + } + + logger.debug(`[OpenCodeAgent] Running: ${OPENCODE_BINARY} ${args.join(' ')}`); + + // Execute with prompt via stdin + const result = await execCommand(OPENCODE_BINARY, args, { + cwd: params.projectPath, + stdin: params.prompt, + timeout: 300000, // 5 minute timeout + }); + + const output = result.stdout + (result.stderr ? `\n${result.stderr}` : ''); + + if (result.exitCode !== 0) { + classifyError(output); + logger.error(`[OpenCodeAgent] Failed with exit code: ${result.exitCode}`); + return { + success: false, + error: result.stderr || 'OpenCode execution failed', + output, + }; + } + + logger.info('[OpenCodeAgent] Task execution completed successfully'); + + return { + success: true, + modifiedFiles: [], // Detected via git diff + output, + }; + } catch (error: unknown) { + const err = error as Error; + logger.error('[OpenCodeAgent] Task execution failed:', err); + return { + success: false, + error: err.message || 'Unknown error', + output: '', + }; + } + } + + async fixError( + error: string, + originalParams: ExecuteParams, + runtime: IAgentRuntime + ): Promise { + logger.info(`[OpenCodeAgent] Fixing error in ${originalParams.projectPath}`); + + const fixParams: ExecuteParams = { + ...originalParams, + prompt: createFixPrompt(originalParams.prompt, error), + }; + + return this.execute(fixParams, runtime); + } +} diff --git a/src/services/coderService.ts b/src/services/coderService.ts index bb851c8..77cfb1c 100644 --- a/src/services/coderService.ts +++ b/src/services/coderService.ts @@ -2,8 +2,18 @@ import { execSync } from 'node:child_process'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { type IAgentRuntime, logger, Service } from '@elizaos/core'; -import type { CoderConfig, CommandHistoryEntry, CommandResult, FileOperation } from '../types/index.ts'; -import { isForbiddenCommand, isSafeCommand, loadCoderConfig, validatePath } from '../utils/index.ts'; +import type { + CoderConfig, + CommandHistoryEntry, + CommandResult, + FileOperation, +} from '../types/index.ts'; +import { + isForbiddenCommand, + isSafeCommand, + loadCoderConfig, + validatePath, +} from '../utils/index.ts'; export class CoderService extends Service { public static serviceType = 'coder'; @@ -29,12 +39,39 @@ export class CoderService extends Service { logger.info({ src: 'plugin:code' }, 'Coder service stopped'); } - getAllowedDirectory(): string { + /** + * Get allowed directory - integrates with plugin-workspace if available + * + * PROGRESSIVE ENHANCEMENT: + * - If plugin-workspace is loaded AND has an active workspace, use that path + * - Otherwise, fall back to CODER_ALLOWED_DIRECTORY setting + * + * This allows plugin-code to work standalone OR with workspace management. + */ + getAllowedDirectory(conversationId?: string): string { + // Check for plugin-workspace and active workspace + if (this.runtime && conversationId) { + try { + const workspaceService = this.runtime.getService('workspace'); + if (workspaceService && typeof workspaceService.getActiveWorkspace === 'function') { + const activeWorkspace = workspaceService.getActiveWorkspace(conversationId); + if (activeWorkspace?.path) { + return activeWorkspace.path; + } + } + } catch { + // plugin-workspace not available, fall back to config + } + } return this.coderConfig.allowedDirectory; } + /** + * Get current directory for a conversation + */ getCurrentDirectory(conversationId: string): string { - return this.currentDirectoryByConversation.get(conversationId) ?? this.coderConfig.allowedDirectory; + const allowedDir = this.getAllowedDirectory(conversationId); + return this.currentDirectoryByConversation.get(conversationId) ?? allowedDir; } setCurrentDirectory(conversationId: string, dir: string): void { @@ -81,9 +118,10 @@ export class CoderService extends Service { targetPath: string ): { fullPath: string; relPath: string } | { error: string } { const cwd = this.getCurrentDirectory(conversationId); - const validated = validatePath(targetPath, this.coderConfig.allowedDirectory, cwd); + const allowedDir = this.getAllowedDirectory(conversationId); + const validated = validatePath(targetPath, allowedDir, cwd); if (!validated) return { error: 'Cannot access path outside allowed directory' }; - const rel = path.relative(this.coderConfig.allowedDirectory, validated); + const rel = path.relative(allowedDir, validated); return { fullPath: validated, relPath: rel.length === 0 ? '.' : rel }; } @@ -286,12 +324,13 @@ export class CoderService extends Service { try { const content = await fs.readFile(full, 'utf-8'); const lines = content.split('\n'); + const allowedDir = this.coderConfig.allowedDirectory; // Use base config for search for (let i = 0; i < lines.length; i++) { if (matches.length >= maxMatches) break; const line = lines[i] ?? ''; if (!line.toLowerCase().includes(needleLower)) continue; matches.push({ - file: path.relative(this.coderConfig.allowedDirectory, full), + file: path.relative(allowedDir, full), line: i + 1, content: line.trim().slice(0, 240), }); diff --git a/src/services/executionTracker.service.ts b/src/services/executionTracker.service.ts new file mode 100644 index 0000000..e697ecc --- /dev/null +++ b/src/services/executionTracker.service.ts @@ -0,0 +1,368 @@ +/** + * ExecutionTrackerService - Tracks code execution status per conversation + * + * WHY A DEDICATED SERVICE? + * - Centralized status tracking across all agents + * - Per-conversation isolation (multiple users don't interfere) + * - Clean separation from business logic in agents + * - Easy to query from providers and actions + * + * USAGE: + * - Agents call startExecution() when starting + * - Agents call updatePhase(), recordAction(), etc. as they progress + * - Agents call completeExecution() when done + * - Provider queries getStatus() to expose to orchestrator + */ + +import { type IAgentRuntime, logger, Service } from '@elizaos/core'; +import type { + ExecutionStatus, + ExecutionPhase, + ExecutionAction, + ExecutionTrackerConfig, +} from '../types/execution.ts'; +import { createIdleStatus } from '../types/execution.ts'; + +export class ExecutionTrackerService extends Service { + public static serviceType = 'execution-tracker'; + capabilityDescription = 'Tracks code execution status for observability'; + + private statusByConversation = new Map(); + private config: ExecutionTrackerConfig; + + constructor(runtime?: IAgentRuntime) { + super(runtime); + this.config = { + maxHistorySize: 20, + progressMode: 'auto', + }; + } + + static async start(runtime: IAgentRuntime): Promise { + const instance = new ExecutionTrackerService(runtime); + logger.info('[ExecutionTracker] Service started'); + return instance; + } + + async stop(): Promise { + this.statusByConversation.clear(); + logger.info('[ExecutionTracker] Service stopped'); + } + + /** + * Get current execution status for a conversation. + * Returns idle status if no execution is tracked. + */ + getStatus(conversationId: string): ExecutionStatus { + const status = this.statusByConversation.get(conversationId); + if (!status) { + return createIdleStatus(conversationId); + } + + // Update elapsed time if executing + if (status.isExecuting && status.startedAt) { + status.elapsedMs = Date.now() - status.startedAt; + } + + return { ...status }; + } + + /** + * Start tracking a new execution. + */ + startExecution(conversationId: string, agent: string): void { + const status: ExecutionStatus = { + isExecuting: true, + agent, + phase: 'starting', + currentFile: '', + filesRead: [], + filesWritten: [], + progress: 0, + elapsedMs: 0, + startedAt: Date.now(), + lastAction: `Starting execution with ${agent}`, + actionHistory: [], + conversationId, + }; + + this.statusByConversation.set(conversationId, status); + this.recordAction(conversationId, { + type: 'info', + description: `Started execution with ${agent}`, + timestamp: Date.now(), + success: true, + }); + + logger.debug(`[ExecutionTracker] Started execution for ${conversationId} with ${agent}`); + } + + /** + * Update the current execution phase. + */ + updatePhase(conversationId: string, phase: ExecutionPhase, details?: string): void { + const status = this.statusByConversation.get(conversationId); + if (!status) return; + + status.phase = phase; + if (details) { + status.lastAction = details; + } + + // Update progress based on phase + if (this.config.progressMode === 'auto') { + status.progress = this.calculateProgress(status); + } + } + + /** + * Record a file being read. + */ + recordFileRead(conversationId: string, filePath: string): void { + const status = this.statusByConversation.get(conversationId); + if (!status) return; + + if (!status.filesRead.includes(filePath)) { + status.filesRead.push(filePath); + } + status.currentFile = filePath; + status.phase = 'reading'; + status.lastAction = `Reading ${this.shortenPath(filePath)}`; + + this.recordAction(conversationId, { + type: 'read', + description: `Read ${this.shortenPath(filePath)}`, + file: filePath, + timestamp: Date.now(), + success: true, + }); + + if (this.config.progressMode === 'auto') { + status.progress = this.calculateProgress(status); + } + } + + /** + * Record a file being written or modified. + */ + recordFileWrite(conversationId: string, filePath: string, isEdit: boolean = false): void { + const status = this.statusByConversation.get(conversationId); + if (!status) return; + + if (!status.filesWritten.includes(filePath)) { + status.filesWritten.push(filePath); + } + status.currentFile = filePath; + status.phase = 'writing'; + const action = isEdit ? 'Edited' : 'Wrote'; + status.lastAction = `${action} ${this.shortenPath(filePath)}`; + + this.recordAction(conversationId, { + type: isEdit ? 'edit' : 'write', + description: `${action} ${this.shortenPath(filePath)}`, + file: filePath, + timestamp: Date.now(), + success: true, + }); + + if (this.config.progressMode === 'auto') { + status.progress = this.calculateProgress(status); + } + } + + /** + * Record a shell command execution. + */ + recordShellCommand(conversationId: string, command: string, success: boolean): void { + const status = this.statusByConversation.get(conversationId); + if (!status) return; + + const shortCmd = command.length > 50 ? command.substring(0, 50) + '...' : command; + status.lastAction = `Shell: ${shortCmd}`; + + this.recordAction(conversationId, { + type: 'shell', + description: `Shell: ${shortCmd}`, + timestamp: Date.now(), + success, + }); + } + + /** + * Record a git operation. + */ + recordGitOperation(conversationId: string, operation: string, success: boolean): void { + const status = this.statusByConversation.get(conversationId); + if (!status) return; + + status.lastAction = `Git: ${operation}`; + + this.recordAction(conversationId, { + type: 'git', + description: `Git: ${operation}`, + timestamp: Date.now(), + success, + }); + } + + /** + * Record an error. + */ + recordError(conversationId: string, error: string): void { + const status = this.statusByConversation.get(conversationId); + if (!status) return; + + status.phase = 'error'; + status.error = error; + status.lastAction = `Error: ${error}`; + + this.recordAction(conversationId, { + type: 'error', + description: error, + timestamp: Date.now(), + success: false, + }); + } + + /** + * Set progress manually (when progressMode is 'manual'). + */ + setProgress(conversationId: string, progress: number): void { + const status = this.statusByConversation.get(conversationId); + if (!status) return; + + status.progress = Math.max(0, Math.min(100, progress)); + } + + /** + * Complete the execution. + */ + completeExecution( + conversationId: string, + success: boolean, + summary?: string + ): ExecutionStatus { + const status = this.statusByConversation.get(conversationId); + if (!status) { + return createIdleStatus(conversationId); + } + + status.isExecuting = false; + status.phase = success ? 'idle' : 'error'; + status.progress = success ? 100 : status.progress; + status.currentFile = ''; + + if (status.startedAt) { + status.elapsedMs = Date.now() - status.startedAt; + } + + const filesInfo = `read ${status.filesRead.length}, wrote ${status.filesWritten.length}`; + const timeInfo = `${Math.round(status.elapsedMs / 1000)}s`; + status.lastAction = summary || `Completed: ${filesInfo} files in ${timeInfo}`; + + this.recordAction(conversationId, { + type: 'info', + description: status.lastAction, + timestamp: Date.now(), + success, + }); + + logger.debug( + `[ExecutionTracker] Completed execution for ${conversationId}: ${status.lastAction}` + ); + + // Return a copy + return { ...status }; + } + + /** + * Get a summary string suitable for display. + */ + getSummary(conversationId: string): string { + const status = this.getStatus(conversationId); + + if (!status.isExecuting && status.phase === 'idle') { + return 'No execution in progress'; + } + + const parts: string[] = []; + + // Agent and phase + parts.push(`${status.agent} [${status.phase}]`); + + // Current activity + if (status.currentFile) { + parts.push(`→ ${this.shortenPath(status.currentFile)}`); + } + + // Files processed + const filesInfo: string[] = []; + if (status.filesRead.length > 0) { + filesInfo.push(`${status.filesRead.length} read`); + } + if (status.filesWritten.length > 0) { + filesInfo.push(`${status.filesWritten.length} written`); + } + if (filesInfo.length > 0) { + parts.push(`(${filesInfo.join(', ')})`); + } + + // Time + if (status.elapsedMs > 0) { + parts.push(`${Math.round(status.elapsedMs / 1000)}s`); + } + + // Progress + parts.push(`${status.progress}%`); + + return parts.join(' '); + } + + // Private helpers + + private recordAction(conversationId: string, action: ExecutionAction): void { + const status = this.statusByConversation.get(conversationId); + if (!status) return; + + status.actionHistory.push(action); + + // Trim history if needed + if (status.actionHistory.length > this.config.maxHistorySize) { + status.actionHistory = status.actionHistory.slice(-this.config.maxHistorySize); + } + } + + private calculateProgress(status: ExecutionStatus): number { + // Simple heuristic based on phase and file counts + const phaseWeights: Record = { + idle: 0, + starting: 5, + planning: 15, + reading: 30, + writing: 70, + verifying: 90, + completing: 95, + error: status.progress, // Keep current on error + }; + + let progress = phaseWeights[status.phase] || 0; + + // Adjust based on file activity + const fileActivity = status.filesRead.length + status.filesWritten.length * 2; + if (fileActivity > 0) { + // Add up to 20% based on file activity (capped) + progress += Math.min(20, fileActivity * 2); + } + + return Math.min(99, progress); // Never auto-complete to 100 + } + + private shortenPath(filePath: string): string { + // Get just the filename or last 2 parts of the path + const parts = filePath.split('/'); + if (parts.length <= 2) { + return filePath; + } + return parts.slice(-2).join('/'); + } +} diff --git a/src/services/index.ts b/src/services/index.ts index 5c72cd2..df170a8 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1 +1,30 @@ +// Existing service export { CoderService } from './coderService.ts'; + +// New services +export { AgentRegistry, registerAgentWithDetection } from './agentRegistry.service.ts'; +export type { AgentRegistration } from './agentRegistry.service.ts'; + +export { LessonsService } from './lessons.service.ts'; +export type { Lesson, LessonsOverview } from './lessons.service.ts'; + +export { StatsService } from './stats.service.ts'; +export type { AgentStats, StatsOverview } from './stats.service.ts'; + +export { ExecutionTrackerService } from './executionTracker.service.ts'; + +// Agent implementations +export { + NativeCoderAgent, + ClaudeCodeAgent, + CursorAgent, + AiderAgent, + CodexAgent, + OpenCodeAgent, + execCommand, + commandExists, + findAvailableBinary, + isValidModelName, + classifyError, + createFixPrompt, +} from './agents/index.ts'; diff --git a/src/services/lessons.service.ts b/src/services/lessons.service.ts new file mode 100644 index 0000000..cf568b3 --- /dev/null +++ b/src/services/lessons.service.ts @@ -0,0 +1,253 @@ +/** + * LessonsService - Track what failed and learn from it + * + * WHY THIS SERVICE: + * - Records failures with context (prompt, agent, error, file) + * - Provides history for debugging and improvement + * - Enables learning from past mistakes + * - Stores data on disk in project directory + * + * STORAGE LOCATION: + * - .plugin-code/lessons.json in the project root + * - Single source of truth for lesson history + * - Persists across restarts + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { type IAgentRuntime, logger, Service } from '@elizaos/core'; +import type { CoderService } from './coderService.ts'; + +/** + * A lesson learned from a failed coding attempt. + */ +export interface Lesson { + /** Unique lesson identifier */ + id: string; + /** When the failure occurred */ + timestamp: number; + /** The original prompt that failed */ + prompt: string; + /** Which agent was used */ + agent: string; + /** The error message */ + error: string; + /** File(s) involved, if any */ + files?: string[]; + /** Project path where failure occurred */ + projectPath?: string; +} + +/** + * Lessons overview for quick stats. + */ +export interface LessonsOverview { + total: number; + byAgent: Record; + recentCount: number; +} + +export class LessonsService extends Service { + static serviceType = 'code-lessons'; + capabilityDescription = 'Tracks coding failures and lessons learned'; + + private lessons: Lesson[] = []; + private storagePath: string = ''; + private maxLessons = 1000; + private loaded = false; + + constructor(runtime?: IAgentRuntime) { + super(runtime); + } + + static async start(runtime: IAgentRuntime): Promise { + const instance = new LessonsService(runtime); + await instance.initialize(runtime); + logger.info('[LessonsService] Started'); + return instance; + } + + async stop(): Promise { + // Save any pending lessons before stopping + if (this.loaded && this.storagePath) { + await this.save(); + } + logger.info('[LessonsService] Stopped'); + } + + private async initialize(runtime: IAgentRuntime): Promise { + // Get project root from CoderService + const coderService = runtime.getService('coder'); + if (coderService) { + const projectRoot = coderService.getAllowedDirectory(); + this.storagePath = path.join(projectRoot, '.plugin-code', 'lessons.json'); + } + + // Load existing lessons if file exists + await this.load(); + } + + /** + * Load lessons from disk. + */ + private async load(): Promise { + if (!this.storagePath) { + this.loaded = true; + return; + } + + try { + const content = await fs.readFile(this.storagePath, 'utf-8'); + const data = JSON.parse(content); + + if (Array.isArray(data)) { + this.lessons = data; + } else if (data.lessons && Array.isArray(data.lessons)) { + this.lessons = data.lessons; + } + + logger.info(`[LessonsService] Loaded ${this.lessons.length} lessons from disk`); + } catch (error: unknown) { + const err = error as NodeJS.ErrnoException; + if (err.code !== 'ENOENT') { + logger.warn(`[LessonsService] Failed to load lessons: ${err.message}`); + } + // File doesn't exist yet - that's fine + } + + this.loaded = true; + } + + /** + * Save lessons to disk. + */ + private async save(): Promise { + if (!this.storagePath) return; + + try { + // Ensure directory exists + await fs.mkdir(path.dirname(this.storagePath), { recursive: true }); + + // Write lessons + const data = JSON.stringify(this.lessons, null, 2); + await fs.writeFile(this.storagePath, data, 'utf-8'); + + logger.debug(`[LessonsService] Saved ${this.lessons.length} lessons to disk`); + } catch (error: unknown) { + const err = error as Error; + logger.error(`[LessonsService] Failed to save lessons: ${err.message}`); + } + } + + /** + * Record a failure as a lesson. + */ + async recordFailure( + prompt: string, + agent: string, + error: string, + options?: { + files?: string[]; + projectPath?: string; + } + ): Promise { + const lesson: Lesson = { + id: `L${Date.now()}-${Math.random().toString(36).substring(2, 8)}`, + timestamp: Date.now(), + prompt: prompt.substring(0, 500), // Truncate long prompts + agent, + error: error.substring(0, 1000), // Truncate long errors + files: options?.files, + projectPath: options?.projectPath, + }; + + this.lessons.push(lesson); + + // Trim to max lessons + if (this.lessons.length > this.maxLessons) { + this.lessons = this.lessons.slice(-this.maxLessons); + } + + // Save to disk + await this.save(); + + logger.info(`[LessonsService] Recorded lesson: ${lesson.id}`); + return lesson; + } + + /** + * Get all lessons. + */ + getLessons(): Lesson[] { + return [...this.lessons]; + } + + /** + * Get recent lessons. + */ + getRecent(count: number = 10): Lesson[] { + return this.lessons.slice(-count); + } + + /** + * Get lessons for a specific agent. + */ + getByAgent(agent: string): Lesson[] { + return this.lessons.filter((l) => l.agent === agent); + } + + /** + * Get lessons overview stats. + */ + getOverview(): LessonsOverview { + const byAgent: Record = {}; + const oneHourAgo = Date.now() - 60 * 60 * 1000; + + for (const lesson of this.lessons) { + byAgent[lesson.agent] = (byAgent[lesson.agent] || 0) + 1; + } + + const recentCount = this.lessons.filter((l) => l.timestamp > oneHourAgo).length; + + return { + total: this.lessons.length, + byAgent, + recentCount, + }; + } + + /** + * Search lessons by keyword. + */ + search(keyword: string): Lesson[] { + const lower = keyword.toLowerCase(); + return this.lessons.filter( + (l) => + l.prompt.toLowerCase().includes(lower) || + l.error.toLowerCase().includes(lower) || + l.files?.some((f) => f.toLowerCase().includes(lower)) + ); + } + + /** + * Clear all lessons. + */ + async clear(): Promise { + this.lessons = []; + await this.save(); + logger.info('[LessonsService] Cleared all lessons'); + } + + /** + * Get lessons as CSV for provider. + */ + toCSV(): string { + const lines = ['id,timestamp,agent,error']; + for (const lesson of this.lessons.slice(-50)) { + // Last 50 for CSV + const error = lesson.error.replace(/"/g, '""').substring(0, 100); + lines.push(`${lesson.id},${lesson.timestamp},${lesson.agent},"${error}"`); + } + return lines.join('\n'); + } +} diff --git a/src/services/stats.service.ts b/src/services/stats.service.ts new file mode 100644 index 0000000..e13cb3c --- /dev/null +++ b/src/services/stats.service.ts @@ -0,0 +1,268 @@ +/** + * StatsService - Track agent performance over time + * + * WHY THIS SERVICE: + * - Tracks success/failure rates per agent + * - Helps identify which agents work best + * - Enables smart agent selection in 'auto' mode + * - Stores data on disk in project directory + * + * STORAGE LOCATION: + * - .plugin-code/stats.json in the project root + * - Single source of truth for performance stats + * - Persists across restarts + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { type IAgentRuntime, logger, Service } from '@elizaos/core'; +import type { CoderService } from './coderService.ts'; + +/** + * Stats for a single agent. + */ +export interface AgentStats { + agent: string; + attempts: number; + successes: number; + failures: number; + lastUsed?: number; + avgDurationMs?: number; + totalDurationMs?: number; +} + +/** + * Stats overview for quick summary. + */ +export interface StatsOverview { + totalAttempts: number; + totalSuccesses: number; + totalFailures: number; + bestAgent: string | null; + bestSuccessRate: number; + agentCount: number; +} + +export class StatsService extends Service { + static serviceType = 'code-stats'; + capabilityDescription = 'Tracks coding agent performance statistics'; + + private stats: Map = new Map(); + private storagePath: string = ''; + private loaded = false; + + constructor(runtime?: IAgentRuntime) { + super(runtime); + } + + static async start(runtime: IAgentRuntime): Promise { + const instance = new StatsService(runtime); + await instance.initialize(runtime); + logger.info('[StatsService] Started'); + return instance; + } + + async stop(): Promise { + // Save any pending stats before stopping + if (this.loaded && this.storagePath) { + await this.save(); + } + logger.info('[StatsService] Stopped'); + } + + private async initialize(runtime: IAgentRuntime): Promise { + // Get project root from CoderService + const coderService = runtime.getService('coder'); + if (coderService) { + const projectRoot = coderService.getAllowedDirectory(); + this.storagePath = path.join(projectRoot, '.plugin-code', 'stats.json'); + } + + // Load existing stats if file exists + await this.load(); + } + + /** + * Load stats from disk. + */ + private async load(): Promise { + if (!this.storagePath) { + this.loaded = true; + return; + } + + try { + const content = await fs.readFile(this.storagePath, 'utf-8'); + const data = JSON.parse(content); + + if (Array.isArray(data)) { + for (const stat of data) { + if (stat.agent) { + this.stats.set(stat.agent, stat); + } + } + } else if (data.stats && Array.isArray(data.stats)) { + for (const stat of data.stats) { + if (stat.agent) { + this.stats.set(stat.agent, stat); + } + } + } + + logger.info(`[StatsService] Loaded stats for ${this.stats.size} agents from disk`); + } catch (error: unknown) { + const err = error as NodeJS.ErrnoException; + if (err.code !== 'ENOENT') { + logger.warn(`[StatsService] Failed to load stats: ${err.message}`); + } + // File doesn't exist yet - that's fine + } + + this.loaded = true; + } + + /** + * Save stats to disk. + */ + private async save(): Promise { + if (!this.storagePath) return; + + try { + // Ensure directory exists + await fs.mkdir(path.dirname(this.storagePath), { recursive: true }); + + // Write stats + const data = JSON.stringify(Array.from(this.stats.values()), null, 2); + await fs.writeFile(this.storagePath, data, 'utf-8'); + + logger.debug(`[StatsService] Saved stats for ${this.stats.size} agents to disk`); + } catch (error: unknown) { + const err = error as Error; + logger.error(`[StatsService] Failed to save stats: ${err.message}`); + } + } + + /** + * Record an attempt and its outcome. + */ + async recordAttempt(agent: string, success: boolean, durationMs?: number): Promise { + const existing = this.stats.get(agent) || { + agent, + attempts: 0, + successes: 0, + failures: 0, + totalDurationMs: 0, + }; + + existing.attempts++; + if (success) { + existing.successes++; + } else { + existing.failures++; + } + + existing.lastUsed = Date.now(); + + if (durationMs !== undefined) { + existing.totalDurationMs = (existing.totalDurationMs || 0) + durationMs; + existing.avgDurationMs = existing.totalDurationMs / existing.attempts; + } + + this.stats.set(agent, existing); + await this.save(); + + logger.debug(`[StatsService] Recorded ${success ? 'success' : 'failure'} for ${agent}`); + } + + /** + * Get stats for a specific agent. + */ + getAgentStats(agent: string): AgentStats | undefined { + return this.stats.get(agent); + } + + /** + * Get all stats. + */ + getAllStats(): AgentStats[] { + return Array.from(this.stats.values()); + } + + /** + * Get success rate for an agent. + */ + getSuccessRate(agent: string): number { + const stat = this.stats.get(agent); + if (!stat || stat.attempts === 0) return 0; + return stat.successes / stat.attempts; + } + + /** + * Get the best performing agent. + */ + getBestAgent(): string | null { + let best: string | null = null; + let bestRate = 0; + + for (const stat of this.stats.values()) { + if (stat.attempts >= 3) { + // Require at least 3 attempts + const rate = stat.successes / stat.attempts; + if (rate > bestRate) { + bestRate = rate; + best = stat.agent; + } + } + } + + return best; + } + + /** + * Get overview stats. + */ + getOverview(): StatsOverview { + let totalAttempts = 0; + let totalSuccesses = 0; + let totalFailures = 0; + + for (const stat of this.stats.values()) { + totalAttempts += stat.attempts; + totalSuccesses += stat.successes; + totalFailures += stat.failures; + } + + const best = this.getBestAgent(); + const bestRate = best ? this.getSuccessRate(best) : 0; + + return { + totalAttempts, + totalSuccesses, + totalFailures, + bestAgent: best, + bestSuccessRate: bestRate, + agentCount: this.stats.size, + }; + } + + /** + * Clear all stats. + */ + async clear(): Promise { + this.stats.clear(); + await this.save(); + logger.info('[StatsService] Cleared all stats'); + } + + /** + * Get stats as CSV for provider. + */ + toCSV(): string { + const lines = ['agent,attempts,successes,failures,success_rate']; + for (const stat of this.stats.values()) { + const rate = stat.attempts > 0 ? (stat.successes / stat.attempts).toFixed(2) : '0.00'; + lines.push(`${stat.agent},${stat.attempts},${stat.successes},${stat.failures},${rate}`); + } + return lines.join('\n'); + } +} diff --git a/src/types/execution.ts b/src/types/execution.ts new file mode 100644 index 0000000..4eef29a --- /dev/null +++ b/src/types/execution.ts @@ -0,0 +1,118 @@ +/** + * Execution status types for CODE_EXECUTION_STATUS provider + * + * WHY THIS EXISTS: + * - Orchestrator needs visibility into what's happening during code execution + * - Users want to see progress, not just "Running..." + * - Enables smart decisions: retry, adjust timeout, show meaningful progress + * + * DESIGN DECISIONS: + * - Status is PER-CONVERSATION for isolation + * - History is limited to last N actions (default 20) for memory efficiency + * - Progress is calculated from files read/written, not agent-provided + * - Events can be added later if needed (provider is sufficient for now) + */ + +/** + * Execution phases + */ +export type ExecutionPhase = + | 'idle' // No execution in progress + | 'starting' // Initializing execution + | 'planning' // LLM is planning what to do + | 'reading' // Reading files for context + | 'writing' // Writing/editing files + | 'verifying' // Running tests/builds + | 'completing' // Wrapping up + | 'error'; // Error occurred + +/** + * A single action taken during execution + */ +export interface ExecutionAction { + /** Action type */ + type: 'read' | 'write' | 'edit' | 'shell' | 'git' | 'plan' | 'verify' | 'error' | 'info'; + /** What happened */ + description: string; + /** File involved (if any) */ + file?: string; + /** Timestamp */ + timestamp: number; + /** Success or failure */ + success: boolean; +} + +/** + * Current execution status + */ +export interface ExecutionStatus { + /** Is code execution currently running? */ + isExecuting: boolean; + + /** Which agent is running */ + agent: string; + + /** Current phase */ + phase: ExecutionPhase; + + /** Current file being worked on */ + currentFile: string; + + /** Files read so far in this execution */ + filesRead: string[]; + + /** Files written/modified so far */ + filesWritten: string[]; + + /** Estimated progress (0-100) */ + progress: number; + + /** How long execution has been running (ms) */ + elapsedMs: number; + + /** Start time of current execution */ + startedAt: number | null; + + /** Last action taken */ + lastAction: string; + + /** Recent action history */ + actionHistory: ExecutionAction[]; + + /** Conversation ID this status belongs to */ + conversationId: string; + + /** Error message if phase is 'error' */ + error?: string; +} + +/** + * Configuration for execution tracking + */ +export interface ExecutionTrackerConfig { + /** Maximum actions to keep in history (default: 20) */ + maxHistorySize: number; + + /** How to calculate progress */ + progressMode: 'auto' | 'manual'; +} + +/** + * Default execution status (idle state) + */ +export function createIdleStatus(conversationId: string): ExecutionStatus { + return { + isExecuting: false, + agent: 'none', + phase: 'idle', + currentFile: '', + filesRead: [], + filesWritten: [], + progress: 0, + elapsedMs: 0, + startedAt: null, + lastAction: 'No execution in progress', + actionHistory: [], + conversationId, + }; +} diff --git a/src/types/index.ts b/src/types/index.ts index a87f16d..7e2278c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -30,3 +30,6 @@ export interface CommandResult { error?: string; executedIn: string; } + +// Execution status types +export * from './execution.ts'; diff --git a/src/utils/pathUtils.ts b/src/utils/pathUtils.ts index af7c36e..4574858 100644 --- a/src/utils/pathUtils.ts +++ b/src/utils/pathUtils.ts @@ -48,7 +48,8 @@ export function validatePath( allowedDirectory: string, currentDirectory: string ): string | null { - const base = currentDirectory && currentDirectory.length > 0 ? currentDirectory : allowedDirectory; + const base = + currentDirectory && currentDirectory.length > 0 ? currentDirectory : allowedDirectory; const resolved = path.resolve(base, targetPath); const allowed = path.resolve(allowedDirectory);