diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa3b640..5cf9d9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ["3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index ce15203..071f9da 100644 --- a/README.md +++ b/README.md @@ -6,17 +6,34 @@ A multi-provider AI coding assistant CLI, similar to Claude Code and Gemini CLI. ![Python](https://img.shields.io/badge/Python-3.10+-green) ![License](https://img.shields.io/badge/License-MIT-yellow) +## Screenshots + +| Startup | Chat | +|---------|------| +| ![Startup](assets/startup.png) | ![Chat](assets/chat.png) | + +| Provider Selection | Model Selection | +|--------------------|-----------------| +| ![Providers](assets/providers.png) | ![Models](assets/models.png) | + +| Tool Execution | Terminal Session | +|----------------|-----------------| +| ![Tools](assets/tools.png) | ![Terminal](assets/terminal.png) | + ## Features - **13 AI Providers** - OpenAI, Anthropic, Gemini, Groq, Mistral, Together AI, OpenRouter, GitHub Copilot, DeepSeek, Kimi, Ollama, LM Studio, and Custom -- **Custom Provider Support** - Connect to ANY OpenAI-compatible API (vLLM, TGI, llama.cpp, etc.) +- **Memory System** - Persistent memory across conversations (user preferences, feedback, project context) +- **MCP Support** - Connect Model Context Protocol servers for extensible tools +- **Skills System** - Built-in and custom skills (`/commit`, `/review`, `/test`, `/fix`, `/explain`, `/simplify`) +- **Terminal Sessions** - Background processes with GUI option, persist across restarts - **Dynamic Model Listing** - Fetches latest available models from provider APIs -- **Secure API Key Storage** - Uses system keychain (Windows Credential Manager, macOS Keychain, Linux Secret Service) +- **Secure API Key Storage** - Uses system keychain (Windows, macOS, Linux) - **Session Persistence** - SQLite-based chat history with resume capability -- **Powerful Tools** - Shell, terminal sessions, file operations, search, grep, code editing, and web access -- **Interactive UI** - Arrow-key navigation for selections, syntax-highlighted output +- **Powerful Tools** - Shell, terminal sessions, file operations, search, grep, code editing, web access, memory +- **Interactive UI** - Arrow-key navigation, numbered selections, bottom status bar, markdown responses - **Permission System** - Configurable allow/deny rules with interactive prompts -- **Terminal Session Management** - Run long-running servers and background processes +- **In-Chat Auth** - Set or update API keys with `/auth` without restarting - **Multiple Modes** - Plan mode, Auto mode, and Ask mode ## Installation @@ -24,14 +41,8 @@ A multi-provider AI coding assistant CLI, similar to Claude Code and Gemini CLI. ### Option 1: Install with pipx (Recommended) ```bash -# Install pipx if you don't have it pip install pipx - -# Install DevOrch pipx install devorch - -# DevOrch is now available globally -devorch --help ``` ### Option 2: Install with pip @@ -43,445 +54,268 @@ pip install devorch ### Option 3: Install from source ```bash -# Clone the repository git clone https://github.com/Amanbig/DevOrch.git cd DevOrch - -# Install with pip pip install -e . ``` -### Why pipx? - -- ✅ Isolated environment (no conflicts with other packages) -- ✅ Available globally like `npm install -g` -- ✅ Easy to uninstall: `pipx uninstall devorch` -- ✅ Easy to upgrade: `pipx upgrade devorch` - ## Quick Start ```bash -# Start DevOrch (first run will show interactive setup) +# Start DevOrch (first run shows interactive setup) devorch -# Or specify a provider +# Specify a provider devorch -p openai devorch -p anthropic -devorch -p groq devorch -p local # Ollama ``` -## Interactive Onboarding +## Slash Commands -On first run, DevOrch guides you through setup with an interactive UI: +### Navigation & Switching -``` -╭─────────────────────────────────────────────────╮ -│ Welcome to DevOrch! │ -│ │ -│ Let's set up your AI provider to get started. │ -╰─────────────────────────────────────────────────╯ - -? Select your AI provider: (Use arrow keys) - ❯ OpenAI (GPT-4o, GPT-4, etc.) - Anthropic (Claude Sonnet, Opus, etc.) - Google Gemini (Gemini Pro, Flash, etc.) - Groq (Ultra-fast Llama, Mixtral) - ────────────── - Ollama - Local (No API key needed) - LM Studio - Local (No API key needed) -``` +| Command | Description | +|---------|-------------| +| `/help` | Show all commands grouped by category | +| `/models` | Browse and switch models (interactive, numbered list) | +| `/model ` | Switch model (supports partial match, e.g. `/model opus`) | +| `/providers` | Browse and switch providers (interactive, numbered list) | +| `/provider ` | Switch provider directly | +| `/mode` | Switch between Plan/Auto/Ask modes | +| `/status` | Show provider, model, mode, memories, skills, MCP | +| `/auth [provider]` | Set or update API key for current or specified provider | -## Usage +### Memory -### Interactive REPL +| Command | Description | +|---------|-------------| +| `/memory` | Show all saved memories | +| `/remember ` | Save something to memory | +| `/forget` | Delete a memory (interactive) | -```bash -devorch # Start interactive session -devorch -p groq # Use specific provider -devorch -m gpt-4o # Use specific model -devorch --resume abc123 # Resume a previous session +Memories persist across conversations in `~/.devorch/memory/`. Types: +- **user** - Your role, preferences, expertise +- **feedback** - Corrections and guidance for the AI +- **project** - Project decisions, context, ongoing work +- **reference** - Links to external resources + +### Skills + +| Command | Description | +|---------|-------------| +| `/skills` | List all available skills | +| `/skill ` | Run a skill | +| `/commit` | Create a git commit with a descriptive message | +| `/review` | Review code changes for bugs and issues | +| `/test` | Run project tests and analyze results | +| `/fix` | Fix the last error or failing test | +| `/explain` | Explain the current project structure | +| `/simplify` | Simplify recent code changes | + +Custom skills can be added as YAML files in `~/.devorch/skills/`: + +```yaml +# ~/.devorch/skills/deploy.yaml +name: deploy +description: Deploy to production +prompt: | + Run the deploy script and verify it succeeds. + Check the deploy logs for any errors. ``` -### Slash Commands +### Session | Command | Description | |---------|-------------| -| `/help` | Show available commands | -| `/mode` | Interactive mode selection (plan/auto/ask) | -| `/model` | Interactive model selection | -| `/provider` | Interactive provider switching | -| `/models` | List available models for current provider | -| `/providers` | List all available providers | -| `/status` | Show current provider, model, and mode | | `/session` | Show current session info | | `/history` | Show conversation history | | `/clear` | Clear conversation history | | `/compact` | Summarize and compact history | | `/save` | Save conversation to file | | `/undo` | Undo last message | -| `/tasks` | Show current task list | - -### Modes - -- **ASK** (default) - Asks before each tool execution -- **AUTO** - Executes tools automatically (dangerous commands still blocked) -- **PLAN** - Shows plan before executing, asks for approval -Switch modes interactively: -``` -? Select mode: (Use arrow keys) - ❯ PLAN - Shows plan before executing, asks for approval - AUTO - Executes tools automatically (trusted mode) - ASK - Asks before each tool execution (default) -``` - -## Tool Permissions +### MCP -DevOrch uses an interactive permission system with arrow-key navigation: - -``` -╭─────────── Permission Required ───────────╮ -│ Tool: shell │ -│ Command: npm create vite@latest │ -╰───────────────────────────────────────────╯ - -? Choose an action: (Use arrow keys) - ❯ Allow once - Allow for this session - Always allow (save to config) - Deny -``` +| Command | Description | +|---------|-------------| +| `/mcp` | Show connected MCP servers and their tools | -## Tool Output Display +## MCP (Model Context Protocol) -Tool calls are displayed in a clean, compact format: +Connect external tool servers via MCP. Configure in `~/.devorch/config.yaml`: -``` -╭──────────── Shell ────────────╮ -│ npm create vite@latest my-app │ -╰───────────────────────────────╯ - -╭─────────── Output ────────────╮ -│ STDOUT: │ -│ Scaffolding project in ./my- │ -│ app │ -│ Done! │ -╰───────────────────────────────╯ - - > write 45 lines to src/App.tsx - ✓ Successfully wrote 45 lines to src/App.tsx - - > read package.json - ✓ Read 32 lines +```yaml +mcp_servers: + filesystem: + command: npx + args: ["-y", "@modelcontextprotocol/server-filesystem", "/home/user"] + github: + command: npx + args: ["-y", "@modelcontextprotocol/server-github"] + env: + GITHUB_TOKEN: "ghp_xxx" + sqlite: + command: uvx + args: ["mcp-server-sqlite", "--db-path", "mydb.sqlite"] ``` -## New Provider Features +MCP tools automatically appear alongside built-in tools and can be used by the AI. -### GitHub Copilot Integration +## Modes -Use your GitHub Copilot subscription to access multiple premium models: +- **ASK** (default) - Asks before each tool execution +- **AUTO** - Executes tools automatically (dangerous commands still blocked) +- **PLAN** - Shows plan before executing, asks for approval -```bash -# Get GitHub token with 'copilot' scope from: -# https://github.com/settings/tokens +## Terminal Sessions -export GITHUB_TOKEN=ghp_your_token +Unified terminal tool with optional GUI window. The AI can monitor output and send input. -# Use Copilot -devorch -p github_copilot -devorch -p github_copilot -m claude-3.5-sonnet ``` +> Start a dev server (headless, AI monitors) + > terminal_session start command="npm run dev" + ✓ Session 'swift-fox-a3f2' started (PID 12345) -**Available models:** GPT-4o, GPT-4o-mini, Claude 3.5 Sonnet, o1-preview, o1-mini +> Open a visible terminal (user can type, AI reads output) + > terminal_session start command="bash" gui=true + ✓ Session 'calm-owl-b7e1' started in visible terminal -### DeepSeek AI +> Check output + > terminal_session read session_id="swift-fox-a3f2" + ✓ [Session 'swift-fox-a3f2' — running] -Powerful reasoning and coding models from DeepSeek: - -```bash -export DEEPSEEK_API_KEY=sk-... -devorch -p deepseek -m deepseek-reasoner +> Stop it + > terminal_session stop session_id="swift-fox-a3f2" + ✓ Session stopped. ``` -**Models:** deepseek-chat, deepseek-coder, deepseek-reasoner (R1) +Sessions are tracked in `~/.devorch/sessions/` and can be reconnected after restarting DevOrch. -### Kimi (Moonshot AI) +## Tool Permissions -Long context models with up to 128K tokens: +Interactive permission system with arrow-key navigation: -```bash -export MOONSHOT_API_KEY=sk-... -devorch -p kimi -m moonshot-v1-128k ``` - -**Models:** moonshot-v1-8k, moonshot-v1-32k, moonshot-v1-128k - -### Custom Providers - -Connect to ANY OpenAI-compatible API: - -#### Self-Hosted vLLM - -```yaml -# ~/.devorch/config.yaml -providers: - my_vllm: - default_model: meta-llama/Meta-Llama-3-70B-Instruct - base_url: http://localhost:8000/v1 +? Choose an action: + » Allow once + Allow for this session + Always allow (save to config) + Deny ``` ```bash -# Start vLLM server -python -m vllm.entrypoints.openai.api_server \ - --model meta-llama/Meta-Llama-3-70B-Instruct \ - --port 8000 - -# Use it -devorch -p my_vllm -``` - -#### Text Generation Inference (TGI) - -```yaml -providers: - my_tgi: - default_model: mistralai/Mistral-7B-Instruct - base_url: http://localhost:8080/v1 +devorch permissions list # Show permissions +devorch permissions set shell allow # Always allow shell +devorch permissions allow shell "git *" # Allow git commands +devorch permissions deny shell "rm -rf *" # Block dangerous commands ``` -#### llama.cpp Server - -```yaml -providers: - llamacpp: - default_model: llama-3-8b - base_url: http://localhost:8080/v1 -``` +## Supported Providers -#### Private API Endpoint +### Cloud Providers -```yaml -providers: - company_api: - default_model: custom-model-v1 - base_url: https://api.company.com/v1 - # Set CUSTOM_API_KEY environment variable -``` +| Provider | Models | API Key | +|----------|--------|---------| +| **OpenAI** | GPT-4o, GPT-4, o1 | `OPENAI_API_KEY` | +| **Anthropic** | Claude 4, Claude 3.5 | `ANTHROPIC_API_KEY` | +| **Google Gemini** | Gemini 2.0, 1.5 Pro/Flash | `GOOGLE_API_KEY` | +| **Groq** | Llama 3.3, Mixtral | `GROQ_API_KEY` | +| **Mistral** | Large, Codestral | `MISTRAL_API_KEY` | +| **Together AI** | Llama 3, Mixtral, Qwen | `TOGETHER_API_KEY` | +| **OpenRouter** | 100+ models | `OPENROUTER_API_KEY` | +| **GitHub Copilot** | GPT-4o, Claude 3.5 | `GITHUB_TOKEN` | +| **DeepSeek** | Chat, Coder, Reasoner | `DEEPSEEK_API_KEY` | +| **Kimi (Moonshot)** | 8K, 32K, 128K context | `MOONSHOT_API_KEY` | -### Dynamic Model Listing +### Local & Self-Hosted -All providers now fetch available models from their APIs automatically: +| Provider | Setup | +|----------|-------| +| **Ollama** | Install Ollama, run models locally | +| **LM Studio** | Install LM Studio, GUI for local models | +| **Custom** | Any OpenAI-compatible API (vLLM, TGI, llama.cpp) | -```bash -# List available models -devorch models list -p deepseek -devorch models list -p github_copilot -devorch models list -p my_vllm -``` +## Tools -Models are fetched in real-time from provider APIs, so you always see the latest available models! +| Tool | Description | +|------|-------------| +| **shell** | Execute shell commands | +| **terminal_session** | Managed sessions with optional GUI (start, read, send, stop) | +| **filesystem** | Read, write, and list files | +| **search** | Find files by name patterns (glob) | +| **grep** | Search for text patterns in files | +| **edit** | Make targeted edits to existing files | +| **task** | Track progress on multi-step work | +| **memory** | Save/search persistent memories across conversations | +| **websearch** | Search the web (DuckDuckGo) | +| **webfetch** | Fetch content from a URL | ## Configuration ### API Keys ```bash -# Store API keys securely in system keyring +# From CLI devorch set-key openai devorch set-key anthropic -devorch set-key groq -devorch set-key github_copilot # Uses GITHUB_TOKEN -devorch set-key deepseek -devorch set-key kimi + +# From within chat +/auth openai +/auth anthropic # Or use environment variables export OPENAI_API_KEY=sk-... export ANTHROPIC_API_KEY=sk-ant-... export GOOGLE_API_KEY=... export GROQ_API_KEY=gsk_... -export MISTRAL_API_KEY=... -export OPENROUTER_API_KEY=sk-or-... -export TOGETHER_API_KEY=... -export GITHUB_TOKEN=ghp_... -export DEEPSEEK_API_KEY=sk-... -export MOONSHOT_API_KEY=sk-... ``` -### View Configuration - -```bash -devorch config -``` +### Config File -### Configuration File - -Create `~/.devorch/config.yaml` to configure providers: +`~/.devorch/config.yaml`: ```yaml -# Set default provider default_provider: openai -# Configure each provider providers: openai: default_model: gpt-4o - anthropic: default_model: claude-sonnet-4-20250514 - github_copilot: - default_model: gpt-4o - - deepseek: - default_model: deepseek-chat - - kimi: - default_model: moonshot-v1-32k - - # Custom providers - my_vllm: - default_model: meta-llama/Meta-Llama-3-70B-Instruct - base_url: http://localhost:8000/v1 - - company_api: - default_model: custom-model-v1 - base_url: https://api.company.com/v1 -``` - -**Note:** Don't put API keys in config files! Use environment variables or keyring. - -### Session Management - -```bash -devorch sessions list # List all sessions -devorch sessions show # Show session details -devorch sessions delete # Delete a session -devorch sessions clear # Delete all sessions -``` - -### Permissions - -```bash -devorch permissions list # Show permissions -devorch permissions set shell allow # Always allow shell commands -devorch permissions allow shell "git *" # Allow git commands -devorch permissions deny shell "rm -rf *" # Block dangerous commands -devorch permissions reset # Reset to defaults -``` - -## Supported Providers - -### Cloud Providers - -| Provider | Models | API Key | Notes | -|----------|--------|---------|-------| -| **OpenAI** | GPT-4o, GPT-4, o1-preview | `OPENAI_API_KEY` | Full support with tool calling | -| **Anthropic** | Claude 4.5, Claude 3.5, Claude 3 | `ANTHROPIC_API_KEY` | Best for coding tasks | -| **Google Gemini** | Gemini 2.0, 1.5 Pro/Flash | `GOOGLE_API_KEY` | 2M token context | -| **Groq** | Llama 3.3, Mixtral, Gemma | `GROQ_API_KEY` | Ultra-fast inference | -| **Mistral** | Large, Medium, Codestral | `MISTRAL_API_KEY` | Specialized for code | -| **Together AI** | Llama 3, Mixtral, Qwen | `TOGETHER_API_KEY` | Open source models | -| **OpenRouter** | 100+ models | `OPENROUTER_API_KEY` | Access many providers via one API | - -### Developer Tools - -| Provider | Models | API Key | Notes | -|----------|--------|---------|-------| -| **GitHub Copilot** ⭐ | GPT-4o, Claude 3.5, o1 | `GITHUB_TOKEN` | Requires Copilot subscription | - -### International Providers - -| Provider | Models | API Key | Notes | -|----------|--------|---------|-------| -| **DeepSeek** ⭐ | Chat, Coder, Reasoner | `DEEPSEEK_API_KEY` | Powerful reasoning models | -| **Kimi (Moonshot)** ⭐ | 8K, 32K, 128K | `MOONSHOT_API_KEY` | Long context (128K tokens) | - -### Local & Self-Hosted - -| Provider | Models | Setup | Notes | -|----------|--------|-------|-------| -| **Ollama** | Llama 3, Mistral, CodeLlama | Install Ollama | Run models locally | -| **LM Studio** | Any GGUF model | Install LM Studio | GUI for local models | -| **Custom** ⭐ | Your choice | Configure endpoint | vLLM, TGI, llama.cpp, etc. | - -⭐ = New providers - -## Tools - -DevOrch has access to these tools: - -| Tool | Description | -|------|-------------| -| **shell** | Execute shell commands (quick commands with output capture) | -| **open_terminal** ⭐ | Open new terminal window for interactive/long-running commands | -| **terminal_session** ⭐ | Managed background sessions (start, read, send input, stop) | -| **filesystem** | Read, write, and list files | -| **search** | Find files by name patterns (glob) | -| **grep** | Search for text patterns in files | -| **edit** | Make targeted edits to existing files | -| **task** | Track progress on multi-step work with visual task list | -| **websearch** | Search the web for current information (uses DuckDuckGo) | -| **webfetch** | Fetch and read content from a URL | - -### Terminal Session Management - -Run long-running servers and interact with them without blocking the chat: - -```bash -# LLM can start a dev server in background -> terminal_session start vite_server "npm run dev" -✓ Session 'vite_server' started (PID 12345) - -# Continue chatting while server runs - -# Check server output -> terminal_session read vite_server -[Session 'vite_server' — running] -VITE v5.0.0 ready in 450 ms -➜ Local: http://localhost:5173/ - -# Send input to the process -> terminal_session send vite_server "rs\n" # Restart - -# Stop when done -> terminal_session stop vite_server +# MCP servers +mcp_servers: + github: + command: npx + args: ["-y", "@modelcontextprotocol/server-github"] + env: + GITHUB_TOKEN: "ghp_xxx" ``` -## Task Tracking - -DevOrch can track progress on complex tasks: - -``` -╭─────────── Tasks (2/4) ───────────╮ -│ ✓ Create project structure │ -│ ✓ Set up dependencies │ -│ ● Installing packages │ -│ ○ Run initial build │ -╰───────────────────────────────────╯ -``` - -Use `/tasks` to view current task list anytime. - -## Config Files - -DevOrch stores configuration in `~/.devorch/`: +### Directory Structure ``` ~/.devorch/ -├── config.yaml # Provider settings and default models -├── permissions.yaml # Tool permission rules -└── sessions.db # SQLite database for chat history +├── config.yaml # Provider settings, MCP servers +├── permissions.yaml # Tool permission rules +├── sessions.db # Chat history (SQLite) +├── memory/ # Persistent memories +│ ├── MEMORY.md # Memory index +│ ├── user_*.md # User profile memories +│ ├── feedback_*.md # Feedback memories +│ └── project_*.md # Project context memories +├── skills/ # Custom skill definitions +│ └── *.yaml # User-defined skills +└── sessions/ # Terminal session logs + ├── registry.json # Session registry + └── *.log # Session output logs ``` ## Requirements - Python 3.10+ -- Dependencies: - - typer, rich, pydantic - - openai, anthropic, google-genai - - httpx, keyring, prompt_toolkit - - questionary, pyyaml, duckduckgo-search +- Dependencies: typer, rich, pydantic, openai, anthropic, google-genai, httpx, keyring, prompt_toolkit, questionary, pyyaml, duckduckgo-search ## License diff --git a/assets/chat.png b/assets/chat.png new file mode 100644 index 0000000..a8653d2 Binary files /dev/null and b/assets/chat.png differ diff --git a/assets/models.png b/assets/models.png new file mode 100644 index 0000000..aeb7ae8 Binary files /dev/null and b/assets/models.png differ diff --git a/assets/providers.png b/assets/providers.png new file mode 100644 index 0000000..ffaf452 Binary files /dev/null and b/assets/providers.png differ diff --git a/assets/startup.png b/assets/startup.png new file mode 100644 index 0000000..24dcdf9 Binary files /dev/null and b/assets/startup.png differ diff --git a/assets/terminal.png b/assets/terminal.png new file mode 100644 index 0000000..a59d971 Binary files /dev/null and b/assets/terminal.png differ diff --git a/assets/tools.png b/assets/tools.png new file mode 100644 index 0000000..863ca89 Binary files /dev/null and b/assets/tools.png differ diff --git a/cli/main.py b/cli/main.py index f39002f..f1f8f3f 100644 --- a/cli/main.py +++ b/cli/main.py @@ -1,4 +1,5 @@ import os +from pathlib import Path import questionary import typer @@ -26,11 +27,15 @@ ) from core.agent import Agent from core.executor import ToolExecutor +from core.mcp import MCPManager +from core.memory import MemoryManager, MemoryTool from core.modes import AgentMode, ModeManager from core.planner import Planner from core.sessions import DEFAULT_MESSAGE_LIMIT, SessionManager +from core.skills import SkillManager from core.tasks import get_task_manager, reset_task_manager from providers import PROVIDER_ENV_VARS, PROVIDER_INFO, PROVIDERS, get_provider +from providers.base import ModelInfo from schemas.message import Message from tools.edit import EditTool from tools.filesystem import FilesystemTool @@ -38,7 +43,6 @@ from tools.search import SearchTool from tools.shell import ShellTool from tools.task import TaskTool -from tools.terminal import OpenTerminalTool from tools.terminal_session import TerminalSessionTool from tools.websearch import WebFetchTool, WebSearchTool from utils.logger import ( @@ -46,6 +50,7 @@ print_error, print_info, print_panel, + print_response, print_success, print_warning, ) @@ -53,30 +58,28 @@ # Custom style for questionary prompts QUESTIONARY_STYLE = QStyle( [ - ("qmark", "fg:yellow bold"), - ("question", "fg:white bold"), - ("answer", "fg:green bold"), - ("pointer", "fg:cyan bold"), - ("highlighted", "fg:white"), # Normal white text, no background - arrow shows selection - ("selected", "fg:white"), - ("instruction", "fg:gray"), + ("qmark", "fg:#55aaff bold"), # question mark + ("question", "fg:#ffffff bold"), # question text + ("answer", "fg:#44ddaa bold"), # confirmed answer + ("pointer", "fg:#55ccff bold"), # » arrow + ("highlighted", "fg:#55ccff bold"), # selected item text — matches pointer + ("selected", "fg:#55ccff"), # multi-select selected + ("text", "fg:#bbbbbb"), # unselected items + ("disabled", "fg:#555555"), # disabled items + ("instruction", "fg:#666666 italic"), # instruction hint + ("separator", "fg:#444444"), # separator lines ] ) -# ASCII Art Banner -BANNER = r""" -[bold blue] - ____ ___ _ -| _ \ _____ _/ _ \ _ __ ___| |__ -| | | |/ _ \ \ / / | | | '__/ __| '_ \ -| |_| | __/\ V /| |_| | | | (__| | | | -|____/ \___| \_/ \___/|_| \___|_| |_| -[/bold blue] -""" +# ASCII Art Banner — clean, compact +BANNER = """ +[bold cyan] ╔╦╗┌─┐┬ ┬╔═╗┬─┐┌─┐┬ ┬ + ║║├┤ └┐┌┘║ ║├┬┘│ ├─┤ + ═╩╝└─┘ └┘ ╚═╝┴└─└─┘┴ ┴[/bold cyan]""" -BANNER_SMALL = "[bold blue]DevOrch[/bold blue] - AI Coding Assistant" +BANNER_SMALL = "[bold cyan]DevOrch[/bold cyan]" -VERSION = "0.1.3" +VERSION = "0.2.1" # Slash commands with descriptions SLASH_COMMANDS = { @@ -90,38 +93,58 @@ "/config": "Show configuration settings", "/permissions": "Show permission settings", "/compact": "Summarize and compact history", - "/models": "List available models for current provider", - "/model": "Switch to a different model", - "/providers": "List all available providers", - "/provider": "Switch to a different provider", + "/models": "Browse and switch models (interactive)", + "/model": "Switch model (/model or interactive)", + "/providers": "Browse and switch providers (interactive)", + "/provider": "Switch provider (/provider or interactive)", "/history": "Show conversation history", "/undo": "Undo last message", "/save": "Save conversation to file", "/status": "Show current provider, model, and mode", "/tasks": "Show current task list", + "/memory": "Show saved memories", + "/remember": "Save something to memory", + "/forget": "Delete a memory", + "/skills": "List available skills", + "/skill": "Run a skill (e.g. /skill commit)", + "/mcp": "Show MCP server status", + "/auth": "Set or update API key for current/specified provider", } # Style for prompt_toolkit (including completion menu) PROMPT_STYLE = Style.from_dict( { - "prompt": "#00aa00 bold", - "command": "#00aaff bold", + "prompt": "#55cc55 bold", + "prompt-arrow": "#55cc55 bold", + "": "#ffffff bold", # input text — bright white, bold to stand out + "command": "#66ccff bold", "description": "#888888", # Completion menu styling - "completion-menu": "bg:#1a1a2e", - "completion-menu.completion": "bg:#1a1a2e #e0e0e0", - "completion-menu.completion.current": "bg:#0066cc #ffffff bold", - "completion-menu.meta": "bg:#1a1a2e #666666 italic", - "completion-menu.meta.current": "bg:#0066cc #cccccc italic", + "completion-menu": "bg:#252530", + "completion-menu.completion": "bg:#252530 #cccccc", + "completion-menu.completion.current": "bg:#334466 #ffffff bold", + "completion-menu.meta": "bg:#252530 #555555", + "completion-menu.meta.current": "bg:#334466 #99bbdd", # Scrollbar - "scrollbar.background": "bg:#333344", - "scrollbar.button": "bg:#0066cc", + "scrollbar.background": "bg:#2a2a3a", + "scrollbar.button": "bg:#5588bb", + # Bottom toolbar — near-invisible bg, just text + "bottom-toolbar": "bg:#0e0e18 #556677", + "bottom-toolbar.text": "bg:#0e0e18 #556677", } ) +def _xml_escape(text: str) -> str: + """Escape text for use in prompt_toolkit HTML.""" + return text.replace("&", "&").replace("<", "<").replace(">", ">") + + class SlashCommandCompleter(Completer): - """Autocomplete for slash commands.""" + """Autocomplete for slash commands and skill shortcuts.""" + + def __init__(self, skill_manager=None): + self._skill_manager = skill_manager def get_completions(self, document, complete_event): text = document.text_before_cursor @@ -133,24 +156,46 @@ def get_completions(self, document, complete_event): # Get the partial command partial = text.lower() + # Built-in slash commands for cmd, desc in SLASH_COMMANDS.items(): if cmd.startswith(partial): - # Calculate how much to complete + # Pad command name for alignment (Gemini-like layout) + padded_cmd = cmd[1:].ljust(16) # strip / for display, pad + safe_desc = _xml_escape(desc) yield Completion( cmd, start_position=-len(text), - display=HTML(f"{cmd} - {desc}"), + display=HTML( + f"{padded_cmd}{safe_desc}" + ), display_meta=desc, ) + # Skill shortcuts (e.g. /commit, /review) + if self._skill_manager: + for skill in self._skill_manager.list_skills(): + skill_cmd = f"/{skill['name']}" + if skill_cmd.startswith(partial) and skill_cmd not in SLASH_COMMANDS: + padded_cmd = skill["name"].ljust(16) + safe_desc = _xml_escape(skill["description"]) + yield Completion( + skill_cmd, + start_position=-len(text), + display=HTML( + f"{padded_cmd}{safe_desc}" + ), + display_meta=f"skill: {skill['description']}", + ) + def print_banner(small: bool = False): """Print the DevOrch banner.""" if small: - console.print(BANNER_SMALL) + console.print(f"\n {BANNER_SMALL} [dim]v{VERSION}[/dim]\n") else: console.print(BANNER) - console.print(f" [dim]v{VERSION} - Your AI Coding Assistant[/dim]\n") + console.print(f" [dim]v{VERSION}[/dim]") + console.print() SYSTEM_PROMPT = """You are DevOrch, an AI coding assistant with access to tools for interacting with the user's computer. @@ -162,19 +207,16 @@ def print_banner(small: bool = False): - Navigate directories, create files, run scripts - Any short-lived terminal command that returns output -2. **open_terminal** - Open a NEW terminal window and run a command inside it. Use this for: - - Starting dev servers or daemons: `npm run dev`, `vite`, `uvicorn`, `flask run`, `next dev`, `ng serve` - - Interactive scaffold tools that prompt the user: `npm create vite@latest`, `npx create-next-app`, `ng new`, `django-admin startproject` - - Any long-running process that should NOT block the current session - - ALWAYS prefer this over `shell` for servers and scaffolds - -3. **terminal_session** — Manage a long-running background process across turns: - - `start` — launch command in a named background session +2. **terminal_session** — The PRIMARY tool for all terminal/process needs: + - `start` — launch a command in a managed session. Defaults to 'bash' if no command. + Set gui=true to also open a visible terminal window for the user. - `read` — read recent stdout/stderr output - `send` — send input to the process stdin - `stop` — terminate the session - `list` — show all active sessions - Use this when you need to check server logs, send commands to a running process, or manage multiple background processes. + - `reconnect` — reconnect to sessions from previous DevOrch runs + Sessions persist across DevOrch restarts with unique names (e.g. 'swift-fox-a3f2'). + Use gui=true when the user wants to SEE a terminal. Use without gui for headless monitoring. 4. **filesystem** - Read/write/list files. Use this to: - Read file contents to understand code @@ -206,24 +248,200 @@ def print_banner(small: bool = False): - User asks about something you're unsure about 10. **webfetch** - Fetch content from a specific URL. Use when: - - You need to read a documentation page - - User provides a URL to check - - You found a relevant URL from search results + - You need to read a documentation page + - User provides a URL to check + - You found a relevant URL from search results + +11. **memory** - Persistent memory across conversations. Use to: + - Save important context: user preferences, project decisions, feedback + - Search/load memories from previous conversations + - Memory types: user (profile), feedback (corrections), project (context), reference (links) + - Proactively save memories when you learn something important about the user or project + - Check memories at the start of conversations for relevant context RULES: - When the user asks you to CREATE something (app, file, project), USE THE TOOLS to actually do it - Do NOT just give instructions - execute the commands yourself using the shell tool - Do NOT ask the user to run commands manually - run them for the user -- Always prefer action over explanation +- Always prefer action over explanation — do things, don't explain how to do them - For multi-step tasks, use the task tool to track and show progress -- IMPORTANT: Use `open_terminal` (not `shell`) for dev servers and interactive scaffold commands +- Use `terminal_session` for ALL terminal needs — dev servers, scaffolds, processes, interactive shells +- If the user says "open terminal", use `terminal_session start` with gui=true so they get a visible window AND you can read output +- If the user wants you to monitor a process, use `terminal_session start` (no gui needed) +- When a GUI terminal session is active and the user asks "what did I type", "see it", "check output", or anything about the terminal, IMMEDIATELY use `terminal_session read` to read the session output — do NOT say you can't see it +- For GUI sessions: the user types in the visible window, you read output with `terminal_session read` +- For headless sessions: send commands via `send` action, read output with `read` action +- When the user corrects you or gives feedback, save it to memory for future conversations +- When you learn about the user's role, preferences, or project context, save it to memory When executing shell commands, use the shell tool with the command to run.""" +def _format_model_choice( + model: ModelInfo, current_model: str = "", index: int = 0, max_name_len: int = 35 +) -> questionary.Choice: + """Build a rich questionary choice for a model.""" + is_current = model.id == current_model + + # Number + padded model name for alignment + name_part = model.id.ljust(max_name_len) + parts = [f" {index:>3}. {name_part}"] + + # Metadata column + meta = [] + if model.context_length: + meta.append(f"{model.context_length:,} ctx") + if model.description: + meta.append(model.description[:40]) + if is_current: + meta.append("● current") + + if meta: + parts.append(" " + " | ".join(meta)) + + display = "".join(parts) + return questionary.Choice(display, value=model.id) + + +def _interactive_model_select( + models: list[ModelInfo], + provider_name: str, + current_model: str = "", + prompt_text: str | None = None, +) -> str | None: + """Interactive model selection with search/filter support. + + Shows ALL models (no truncation), uses questionary fuzzy select + for large lists, regular select for small ones. + """ + if not models: + print_warning("No models available.") + return None + + prompt_text = prompt_text or f"Select model for {provider_name}:" + + # Compute max name length for aligned columns + max_name = max(len(m.id) for m in models) if models else 30 + max_name = min(max_name + 2, 45) # cap it so it doesn't get too wide + + choices = [ + _format_model_choice(m, current_model, i + 1, max_name) for i, m in enumerate(models) + ] + + try: + selected = questionary.select( + prompt_text, + choices=choices, + style=QUESTIONARY_STYLE, + instruction="(↑↓ navigate, Enter to select, Ctrl+C to cancel)", + ).ask() + return selected + except (KeyboardInterrupt, EOFError): + return None + + +def _interactive_provider_select( + current_provider: str, + current_settings: "Settings", + prompt_text: str = "Select provider:", +) -> str | None: + """Interactive provider selection with status indicators.""" + # Nice display names + display_names = { + "openai": "OpenAI", + "anthropic": "Anthropic", + "gemini": "Google Gemini", + "groq": "Groq", + "openrouter": "OpenRouter", + "mistral": "Mistral", + "together": "Together AI", + "github_copilot": "GitHub Copilot", + "deepseek": "DeepSeek", + "kimi": "Kimi (Moonshot)", + "custom": "Custom", + "local": "Ollama", + "lmstudio": "LM Studio", + } + + cloud_choices = [] + local_choices = [] + num = 1 + + for name, desc in PROVIDER_INFO.items(): + has_key = bool(current_settings.get_api_key(name)) + is_current = name == current_provider + nice_name = display_names.get(name, name.title()) + short_desc = desc.split(" - ", 1)[1] if " - " in desc else desc + + # Status indicator + if is_current: + status = "● active" + elif name in ("local", "lmstudio"): + status = "local" + elif has_key: + status = "ready" + else: + status = "needs key" + + # Current model info + model_info = "" + if is_current: + model_info = f" [{current_settings.get_default_model(name)}]" + + display = f"{num:>2}. {nice_name:<16} {short_desc:<40} ({status}){model_info}" + choice = questionary.Choice(display, value=name) + num += 1 + + if name in ("local", "lmstudio"): + local_choices.append(choice) + else: + cloud_choices.append(choice) + + provider_choices = cloud_choices + [questionary.Separator("── Local ──")] + local_choices + + try: + return questionary.select( + prompt_text, + choices=provider_choices, + style=QUESTIONARY_STYLE, + instruction="(↑↓ navigate, Enter to select, Ctrl+C to cancel)", + ).ask() + except (KeyboardInterrupt, EOFError): + return None + + +def _fuzzy_match_model(query: str, models: list[ModelInfo]) -> ModelInfo | None: + """Find the best model match for a partial name query.""" + query_lower = query.lower() + + # Exact match + for m in models: + if m.id.lower() == query_lower: + return m + + # Prefix match + prefix_matches = [m for m in models if m.id.lower().startswith(query_lower)] + if len(prefix_matches) == 1: + return prefix_matches[0] + + # Contains match + contains_matches = [m for m in models if query_lower in m.id.lower()] + if len(contains_matches) == 1: + return contains_matches[0] + + # If multiple matches, return None (ambiguous) + return None + + class SimplePlanner(Planner): + def __init__(self, memory_context: str = ""): + self.memory_context = memory_context + def plan(self, history: list[Message]) -> list[Message]: - system_prompt = Message(role="system", content=SYSTEM_PROMPT) + prompt = SYSTEM_PROMPT + if self.memory_context: + prompt += "\n" + self.memory_context + system_prompt = Message(role="system", content=prompt) return [system_prompt] + history @@ -278,26 +496,43 @@ def run_onboarding() -> str | None: ) console.print() - # Provider selection with questionary - provider_choices = [ - questionary.Choice("OpenAI (GPT-4o, GPT-4, etc.)", value="openai"), - questionary.Choice("Anthropic (Claude Sonnet, Opus, etc.)", value="anthropic"), - questionary.Choice("Google Gemini (Gemini Pro, Flash, etc.)", value="gemini"), - questionary.Choice("Groq (Ultra-fast Llama, Mixtral)", value="groq"), - questionary.Choice("OpenRouter (Access 100+ models)", value="openrouter"), - questionary.Choice("Mistral (Mistral Large, Codestral)", value="mistral"), - questionary.Choice("Together AI (Open source models)", value="together"), - questionary.Separator(), - questionary.Choice("Ollama - Local (No API key needed)", value="local"), - questionary.Choice("LM Studio - Local (No API key needed)", value="lmstudio"), - ] + # Provider selection — built dynamically from registry + cloud_providers = [] + local_providers = [] + for name, desc in PROVIDER_INFO.items(): + short_desc = desc.split(" - ", 1)[1] if " - " in desc else desc + # Title-case the provider name for display + display_name = { + "openai": "OpenAI", + "anthropic": "Anthropic", + "gemini": "Google Gemini", + "groq": "Groq", + "openrouter": "OpenRouter", + "mistral": "Mistral", + "together": "Together AI", + "github_copilot": "GitHub Copilot", + "deepseek": "DeepSeek", + "kimi": "Kimi (Moonshot)", + "custom": "Custom", + "local": "Ollama", + "lmstudio": "LM Studio", + }.get(name, name.title()) + + if name in ("local", "lmstudio"): + local_providers.append( + questionary.Choice(f"{display_name} — {short_desc} (No API key)", value=name) + ) + else: + cloud_providers.append(questionary.Choice(f"{display_name} — {short_desc}", value=name)) + + provider_choices = cloud_providers + [questionary.Separator()] + local_providers try: provider = questionary.select( "Select your AI provider:", choices=provider_choices, style=QUESTIONARY_STYLE, - instruction="(Use arrow keys to navigate, Enter to select)", + instruction="(↑↓ navigate, Enter to select, Ctrl+C to cancel)", ).ask() if not provider: @@ -321,14 +556,9 @@ def run_onboarding() -> str | None: models = temp_provider.list_models() if models: - model_choices = [questionary.Choice(m.id, value=m.id) for m in models[:15]] - - selected_model = questionary.select( - "Select a model:", - choices=model_choices, - style=QUESTIONARY_STYLE, - instruction="(Use arrow keys)", - ).ask() + selected_model = _interactive_model_select( + models, provider, prompt_text="Select a model:" + ) if selected_model: if provider not in settings.providers: @@ -395,21 +625,9 @@ def run_onboarding() -> str | None: models = temp_provider.list_models() if models: - model_choices = [] - for m in models[:15]: - desc = ( - f" - {m.description[:40]}..." - if m.description and len(m.description) > 40 - else "" - ) - model_choices.append(questionary.Choice(f"{m.id}{desc}", value=m.id)) - - selected_model = questionary.select( - "Select a model:", - choices=model_choices, - style=QUESTIONARY_STYLE, - instruction="(Use arrow keys)", - ).ask() + selected_model = _interactive_model_select( + models, provider, prompt_text="Select a model:" + ) if selected_model: settings.providers[provider].default_model = selected_model @@ -549,7 +767,6 @@ def start_repl( tools = [ ShellTool(), - OpenTerminalTool(), TerminalSessionTool(), FilesystemTool(), SearchTool(), @@ -558,13 +775,34 @@ def start_repl( TaskTool(), WebSearchTool(), WebFetchTool(), + MemoryTool(), ] + # Initialize memory manager and load context + memory_manager = MemoryManager() + memory_context = memory_manager.get_context_prompt() + + # Initialize skill manager + skill_manager = SkillManager() + + # Initialize MCP servers from config + mcp_manager = MCPManager() + mcp_config = settings.mcp_servers or {} + + mcp_tools = [] + if mcp_config: + with console.status("[bold cyan]Connecting MCP servers...", spinner="dots"): + started = mcp_manager.load_from_config(mcp_config) + if started: + print_success(f"MCP servers connected: {', '.join(started)}") + mcp_tools = mcp_manager.get_all_tools() + tools.extend(mcp_tools) + # Create mode manager (shared between agent and executor) mode_manager = ModeManager(default_mode=AgentMode.ASK) executor = ToolExecutor(tools=tools, require_confirmation=True, mode_manager=mode_manager) - planner = SimplePlanner() + planner = SimplePlanner(memory_context=memory_context) def on_session_continue(new_session_id: str): print_info(f"Session continued: {new_session_id}") @@ -587,50 +825,74 @@ def on_session_continue(new_session_id: str): # Get current working directory for display cwd = os.getcwd() - cwd_short = os.path.basename(cwd) or cwd + cwd_display = cwd.replace(str(Path.home()), "~") + + # Show startup info — clean Gemini-like display + mem_count = len(memory_manager.list_all()) + skill_count = len(skill_manager.list_skills()) + mcp_count = len(mcp_manager.servers) + + # Build status line items + status_parts = [ + f"[bold white]Provider:[/bold white] [cyan]{llm.name}[/cyan]", + f"[bold white]Model:[/bold white] [cyan]{llm.model}[/cyan]", + ] + extra_parts = [] + if mem_count: + extra_parts.append(f"[dim]{mem_count} memories[/dim]") + extra_parts.append(f"[dim]{skill_count} skills[/dim]") + if mcp_count: + extra_parts.append(f"[dim]{mcp_count} MCP[/dim]") - # Show session info - console.print( - f" [dim]Provider:[/dim] [cyan]{llm.name}[/cyan] [dim]Model:[/dim] [cyan]{llm.model}[/cyan]" - ) console.print( - f" [dim]Session:[/dim] {session_manager.current_session_id} [dim]cwd:[/dim] {cwd_short}" + Panel( + " [dim]|[/dim] ".join(status_parts) + "\n" + " [dim]|[/dim] ".join(extra_parts), + border_style="bright_black", + padding=(0, 2), + ) ) + + # Getting started tips console.print( - f" [dim]Mode:[/dim] {mode_manager.get_mode_display()} [dim]- Type[/dim] / [dim]to see commands[/dim]\n" + " [dim]Getting started:[/dim]\n" + " [dim]1.[/dim] Type [cyan]/[/cyan] to see all commands\n" + " [dim]2.[/dim] [cyan]/help[/cyan] for detailed help\n" + " [dim]3.[/dim] Ask coding questions or run commands\n" + " [dim]4.[/dim] [cyan]Ctrl+C[/cyan] to exit\n" ) # Create completer for slash commands - completer = SlashCommandCompleter() + completer = SlashCommandCompleter(skill_manager=skill_manager) # Track current provider/model for switching current_llm = llm current_settings = settings - def get_prompt(): - """Generate prompt with mode indicator.""" - mode_indicator = { - AgentMode.PLAN: "[yellow]P[/yellow]", - AgentMode.AUTO: "[green]A[/green]", - AgentMode.ASK: "[blue]?[/blue]", - }.get(mode_manager.mode, "") - return f"[{mode_indicator}] {cwd_short}> " + def get_bottom_toolbar(): + """Clean bottom status bar.""" + mode_char = {"plan": "Plan", "auto": "Auto", "ask": "Ask"}.get(mode_manager.mode.value, "?") + parts = [cwd_display, f"{current_llm.name}/{current_llm.model}", mode_char] + mcp_n = len(mcp_manager.servers) + if mcp_n: + parts.append(f"MCP: {mcp_n}") + return " " + " ".join(parts) while True: try: - # Build prompt with mode indicator - mode_char = {"plan": "P", "auto": "A", "ask": "?"}.get(mode_manager.mode.value, "?") - prompt_str = f"[{mode_char}] {cwd_short}> " + # Print a separator line above the prompt (Gemini-like) + console.print("[dim]─[/dim]" * console.width, highlight=False) - # Use prompt_toolkit with autocomplete + # Use prompt_toolkit with autocomplete and bottom toolbar user_input = pt_prompt( - prompt_str, + [("class:prompt-arrow", "> ")], completer=completer, complete_while_typing=True, style=PROMPT_STYLE, + bottom_toolbar=get_bottom_toolbar, ) if user_input.lower() in ("exit", "quit", "q"): + mcp_manager.stop_all() print_info(f"Session saved: {session_manager.current_session_id}") break @@ -644,19 +906,54 @@ def get_prompt(): cmd_arg = cmd_parts[1] if len(cmd_parts) > 1 else None if cmd == "help": - console.print("\n[bold]Available Commands:[/bold]") - for slash_cmd, desc in SLASH_COMMANDS.items(): - console.print(f" [cyan]{slash_cmd:<14}[/cyan] - {desc}") - console.print(f" [cyan]{'exit':<14}[/cyan] - Exit DevOrch") - console.print("\n[bold]Modes:[/bold]") + console.print() + + # Group commands by category + categories = { + "Modes": ["/mode", "/plan", "/auto", "/ask"], + "Provider & Model": ["/providers", "/provider", "/models", "/model"], + "Session": ["/session", "/history", "/undo", "/clear", "/compact", "/save"], + "Memory": ["/memory", "/remember", "/forget"], + "Skills": ["/skills", "/skill"], + "Tools & Config": [ + "/tasks", + "/config", + "/permissions", + "/mcp", + "/status", + "/auth", + ], + } + + for category, cmds in categories.items(): + console.print(f" [bold]{category}[/bold]") + for slash_cmd in cmds: + desc = SLASH_COMMANDS.get(slash_cmd, "") + console.print(f" [cyan]{slash_cmd:<16}[/cyan] {desc}") + console.print() + + console.print(f" [cyan]{'exit':<16}[/cyan] Exit DevOrch") + + # Show available skills as shortcuts + skill_names = [s["name"] for s in skill_manager.list_skills()] + if skill_names: + console.print( + f"\n [bold]Skill Shortcuts:[/bold] /{', /'.join(skill_names)}" + ) + + console.print("\n [bold]Modes:[/bold]") console.print( - " [yellow]PLAN[/yellow] - Shows plan before executing, asks for approval" + " [yellow]PLAN[/yellow] - Shows plan before executing, asks for approval" ) console.print( - " [green]AUTO[/green] - Executes tools automatically (trusted mode)" + " [green]AUTO[/green] - Executes tools automatically (trusted mode)" + ) + console.print( + " [blue]ASK[/blue] - Asks before each tool execution (default)" + ) + console.print( + "\n[dim] Tip: Type / for autocomplete | /model and /provider support partial match[/dim]\n" ) - console.print(" [blue]ASK[/blue] - Asks before each tool execution (default)") - console.print("\n[dim]Tip: Type / and use Tab for autocomplete[/dim]\n") continue elif cmd == "mode": @@ -666,15 +963,15 @@ def get_prompt(): # Interactive mode selection mode_choices = [ questionary.Choice( - f"{'> ' if mode_manager.mode == AgentMode.PLAN else ' '}PLAN - Shows plan before executing, asks for approval", + f" 1. {'[current] ' if mode_manager.mode == AgentMode.PLAN else ''}PLAN - Shows plan before executing, asks for approval", value="plan", ), questionary.Choice( - f"{'> ' if mode_manager.mode == AgentMode.AUTO else ' '}AUTO - Executes tools automatically (trusted mode)", + f" 2. {'[current] ' if mode_manager.mode == AgentMode.AUTO else ''}AUTO - Executes tools automatically (trusted mode)", value="auto", ), questionary.Choice( - f"{'> ' if mode_manager.mode == AgentMode.ASK else ' '}ASK - Asks before each tool execution (default)", + f" 3. {'[current] ' if mode_manager.mode == AgentMode.ASK else ''}ASK - Asks before each tool execution (default)", value="ask", ), ] @@ -683,7 +980,7 @@ def get_prompt(): "Select mode:", choices=mode_choices, style=QUESTIONARY_STYLE, - instruction="(Use arrow keys)", + instruction="(↑↓ navigate, Enter to select, Ctrl+C to cancel)", ).ask() if not mode_name: continue @@ -725,9 +1022,17 @@ def get_prompt(): elif cmd == "status": console.print("\n[bold]Status[/bold]") - console.print(f" [dim]Provider:[/dim] [cyan]{current_llm.name}[/cyan]") - console.print(f" [dim]Model:[/dim] [cyan]{current_llm.model}[/cyan]") - console.print(f" [dim]Mode:[/dim] {mode_manager.get_mode_display()}") + console.print(f" [dim]Provider:[/dim] [cyan]{current_llm.name}[/cyan]") + console.print(f" [dim]Model:[/dim] [cyan]{current_llm.model}[/cyan]") + console.print(f" [dim]Mode:[/dim] {mode_manager.get_mode_display()}") + console.print(f" [dim]Session:[/dim] {session_manager.current_session_id}") + console.print(f" [dim]Messages:[/dim] {len(agent.history)}") + _mem_count = len(memory_manager.list_all()) + console.print(f" [dim]Memories:[/dim] {_mem_count}") + console.print(f" [dim]Skills:[/dim] {len(skill_manager.list_skills())}") + _mcp_count = len(mcp_manager.servers) + if _mcp_count: + console.print(f" [dim]MCP:[/dim] {_mcp_count} server(s)") console.print() continue @@ -752,6 +1057,65 @@ def get_prompt(): console.print() continue + elif cmd == "auth": + # /auth [provider] — set or update API key + target_provider = cmd_arg.lower() if cmd_arg else current_llm.name + if target_provider in ("local", "ollama", "lmstudio"): + print_info(f"{target_provider} doesn't need an API key.") + continue + + env_var = PROVIDER_ENV_VARS.get( + target_provider, f"{target_provider.upper()}_API_KEY" + ) + console.print( + Panel( + f"[bold]Set API key for {target_provider}[/bold]\n" + f"[dim]Or set {env_var} environment variable[/dim]", + border_style="cyan", + ) + ) + + try: + api_key = questionary.password( + f"Enter API key for {target_provider}:", + style=QUESTIONARY_STYLE, + ).ask() + + if not api_key or not api_key.strip(): + print_error("API key cannot be empty.") + continue + + entered_key = api_key.strip() + + # Store in keyring + if keyring_available(): + set_api_key(target_provider, entered_key) + print_success("API key stored in keychain!") + else: + print_warning("Keyring not available — key stored in memory only.") + + # Update settings + if target_provider not in current_settings.providers: + current_settings.providers[target_provider] = ProviderConfig() + current_settings.providers[target_provider].api_key = entered_key + + # If updating the current provider, reload it + if target_provider == current_llm.name: + try: + new_llm = get_provider( + target_provider, model=current_llm.model, api_key=entered_key + ) + current_llm = new_llm + agent.provider = new_llm + print_success(f"Reloaded {target_provider} with new key.") + except Exception as e: + print_error(f"Key saved but failed to reload: {e}") + else: + print_success(f"API key saved for {target_provider}.") + except (KeyboardInterrupt, EOFError): + console.print("\nCancelled.") + continue + elif cmd == "permissions": perms = get_permissions() console.print("\n[bold]Tool Permissions:[/bold]") @@ -777,66 +1141,53 @@ def get_prompt(): print_success("History compacted. Summary preserved.") continue - elif cmd == "models": - console.print(f"\n[bold]Available models for {current_llm.name}:[/bold]") + elif cmd in ("models", "model"): + selected_model = cmd_arg + try: - with console.status("[dim]Fetching models...", spinner="dots"): + with console.status("[cyan]Fetching models...", spinner="dots"): models = current_llm.list_models() - for m in models[:30]: # Limit display - marker = "[green]>[/green]" if m.id == current_llm.model else " " - ctx = f" ({m.context_length} ctx)" if m.context_length else "" - # Show tool capability warning for local models - desc = "" - if m.description: - if "no tool" in m.description.lower(): - desc = f" [yellow]{m.description}[/yellow]" - else: - desc = f" [dim]{m.description}[/dim]" - console.print(f" {marker} {m.id}{ctx}{desc}") - if len(models) > 30: - console.print(f" [dim]... and {len(models) - 30} more[/dim]") - if current_llm.name == "local": - console.print( - "\n [dim]For tool/function calling, use 7B+ models[/dim]" - ) except Exception as e: print_error(f"Failed to fetch models: {e}") - console.print("\n[dim]Use /model to switch[/dim]\n") - continue - - elif cmd == "model": - selected_model = cmd_arg + continue - if not selected_model: - # Interactive model selection - try: - with console.status("[cyan]Fetching models...", spinner="dots"): - models = current_llm.list_models() - - if models: - model_choices = [] - for m in models[:20]: - is_current = m.id == current_llm.model - prefix = "> " if is_current else " " - ctx = f" ({m.context_length} ctx)" if m.context_length else "" - model_choices.append( - questionary.Choice(f"{prefix}{m.id}{ctx}", value=m.id) - ) - - selected_model = questionary.select( - f"Select model for {current_llm.name}:", - choices=model_choices, - style=QUESTIONARY_STYLE, - instruction="(Use arrow keys)", - ).ask() + if not models: + print_warning("No models available") + continue + if selected_model: + # User typed /model — try fuzzy match + match = _fuzzy_match_model(selected_model, models) + if match: + selected_model = match.id + if match.id != cmd_arg: + print_info(f"Matched: {match.id}") + else: + # Check if there are multiple partial matches + partial = [m for m in models if cmd_arg.lower() in m.id.lower()] + if partial: + console.print( + f"\n[yellow]Multiple matches for '{cmd_arg}':[/yellow]" + ) + selected_model = _interactive_model_select( + partial, + current_llm.name, + current_llm.model, + prompt_text="Select from matches:", + ) if not selected_model: continue else: - print_warning("No models available") - continue - except Exception as e: - print_error(f"Failed to fetch models: {e}") + # No match at all — use it as-is (user might know what they want) + selected_model = cmd_arg + else: + # Interactive selection — show ALL models + selected_model = _interactive_model_select( + models, + current_llm.name, + current_llm.model, + ) + if not selected_model: continue try: @@ -876,55 +1227,14 @@ def get_prompt(): print_error(f"Failed to switch model: {e}") continue - elif cmd == "providers": - console.print("\n[bold]Available Providers:[/bold]") - for name, desc in PROVIDER_INFO.items(): - marker = "[green]>[/green]" if name == current_llm.name else " " - env_var = PROVIDER_ENV_VARS.get(name) - key_status = "" - if env_var: - has_key = bool(current_settings.get_api_key(name)) - key_status = ( - " [green](configured)[/green]" - if has_key - else " [yellow](needs key)[/yellow]" - ) - elif name in ("local", "lmstudio"): - key_status = " [dim](no key needed)[/dim]" - console.print(f" {marker} [cyan]{name:<12}[/cyan] - {desc}{key_status}") - console.print("\n[dim]Use /provider to switch[/dim]\n") - continue - - elif cmd == "provider": + elif cmd in ("providers", "provider"): if cmd_arg: new_provider = cmd_arg.lower() else: - # Interactive provider selection - provider_choices = [] - for name, desc in PROVIDER_INFO.items(): - has_key = bool(current_settings.get_api_key(name)) - status = "" - if name in ("local", "lmstudio"): - status = " (local)" - elif has_key: - status = " (configured)" - else: - status = " (needs key)" - - is_current = name == current_llm.name - display = f"{'> ' if is_current else ' '}{name} - {desc}{status}" - provider_choices.append(questionary.Choice(display, value=name)) - - try: - new_provider = questionary.select( - "Select provider:", - choices=provider_choices, - style=QUESTIONARY_STYLE, - instruction="(Use arrow keys)", - ).ask() - if not new_provider: - continue - except (KeyboardInterrupt, EOFError): + new_provider = _interactive_provider_select( + current_llm.name, current_settings + ) + if not new_provider: continue if new_provider not in PROVIDERS: @@ -969,21 +1279,17 @@ def get_prompt(): current_settings.providers[new_provider] = ProviderConfig() current_settings.providers[new_provider].api_key = entered_key - # Offer model selection with questionary + # Offer model selection try: with console.status("[cyan]Fetching models...", spinner="dots"): temp_llm = get_provider(new_provider, api_key=entered_key) models = temp_llm.list_models() if models: - model_choices = [ - questionary.Choice(m.id, value=m.id) - for m in models[:12] - ] - selected_model = questionary.select( - "Select a model:", - choices=model_choices, - style=QUESTIONARY_STYLE, - ).ask() + selected_model = _interactive_model_select( + models, + new_provider, + prompt_text="Select a model:", + ) if selected_model: current_settings.providers[ new_provider @@ -1080,20 +1386,169 @@ def get_prompt(): console.print(panel) continue + elif cmd == "memory": + memories = memory_manager.list_all() + if not memories: + print_info("No memories saved yet.") + else: + console.print(f"\n[bold]Saved Memories ({len(memories)}):[/bold]") + for mem in memories: + type_color = { + "user": "cyan", + "feedback": "yellow", + "project": "green", + "reference": "blue", + }.get(mem["type"], "white") + console.print( + f" [{type_color}][{mem['type']}][/{type_color}] " + f"[bold]{mem['name']}[/bold]" + ) + console.print(f" [dim]{mem['description']}[/dim]") + console.print(f" [dim]File: {mem['filename']}[/dim]") + console.print() + continue + + elif cmd == "remember": + if not cmd_arg: + print_warning("Usage: /remember ") + print_info("Example: /remember I prefer tabs over spaces") + continue + # Feed it to the agent as a memory save instruction + remember_prompt = ( + f'The user wants you to remember this: "{cmd_arg}"\n' + f"Save this to memory using the memory tool. Choose the appropriate " + f"memory type (user/feedback/project/reference) and write a clear " + f"name and description." + ) + result = agent.run(remember_prompt, max_iterations=5) + print_response(result) + continue + + elif cmd == "forget": + if not cmd_arg: + memories = memory_manager.list_all() + if not memories: + print_info("No memories to forget.") + continue + # Let user pick which to delete + memory_choices = [ + questionary.Choice( + f"[{mem['type']}] {mem['name']}", + value=mem["filename"], + ) + for mem in memories + ] + try: + to_delete = questionary.select( + "Select memory to forget:", + choices=memory_choices, + style=QUESTIONARY_STYLE, + ).ask() + if to_delete and memory_manager.delete(to_delete): + print_success(f"Forgot: {to_delete}") + else: + print_info("Cancelled.") + except (KeyboardInterrupt, EOFError): + continue + else: + # Try to find and delete by name match + memories = memory_manager.search(query=cmd_arg) + if memories: + if memory_manager.delete(memories[0]["filename"]): + print_success(f"Forgot: {memories[0]['name']}") + else: + print_error("Failed to delete memory.") + else: + print_warning(f"No memory found matching '{cmd_arg}'") + continue + + elif cmd == "skills": + skills = skill_manager.list_skills() + console.print(f"\n[bold]Available Skills ({len(skills)}):[/bold]") + for sk in skills: + source = ( + "[dim](built-in)[/dim]" + if sk["source"] == "built-in" + else "[dim](custom)[/dim]" + ) + console.print( + f" [cyan]/{sk['name']}[/cyan] - {sk['description']} {source}" + ) + console.print( + "\n[dim]Use /skill to run a skill. " + "Add custom skills in ~/.devorch/skills/[/dim]\n" + ) + continue + + elif cmd == "skill": + if not cmd_arg: + print_warning("Usage: /skill ") + print_info("Use /skills to see available skills") + continue + skill_name = cmd_arg.split()[0] + skill = skill_manager.get(skill_name) + if not skill: + print_error(f"Unknown skill: {skill_name}") + print_info("Use /skills to see available skills") + continue + console.print( + f" [dim]Running skill:[/dim] [cyan]{skill_name}[/cyan] - {skill['description']}" + ) + result = agent.run(skill["prompt"], max_iterations=15) + print_response(result) + continue + + elif cmd == "mcp": + servers = mcp_manager.list_servers() + if not servers: + console.print("\n[bold]MCP Servers:[/bold] None connected") + console.print("[dim]Configure MCP servers in ~/.devorch/config.yaml:[/dim]") + console.print( + "[dim] mcp_servers:\n" + " my-server:\n" + " command: npx\n" + ' args: ["-y", "@modelcontextprotocol/server-xxx"][/dim]\n' + ) + else: + console.print(f"\n[bold]MCP Servers ({len(servers)}):[/bold]") + for srv in servers: + status = ( + "[green]running[/green]" if srv["running"] else "[red]stopped[/red]" + ) + console.print(f" [cyan]{srv['name']}[/cyan] - {status}") + if srv["tools"]: + console.print(f" Tools: {', '.join(srv['tools'])}") + console.print() + continue + + # Also handle direct skill invocation (e.g. /commit, /review) + elif cmd in [s["name"] for s in skill_manager.list_skills()]: + skill = skill_manager.get(cmd) + if skill: + console.print( + f" [dim]Running skill:[/dim] [cyan]{cmd}[/cyan] - {skill['description']}" + ) + result = agent.run(skill["prompt"], max_iterations=15) + print_response(result) + continue + else: print_warning(f"Unknown command: /{cmd}") print_info("Type /help to see available commands") continue result = agent.run(user_input, max_iterations=15) - print_panel(result, title="DevOrch", border_style="green") + print_response(result) except (typer.Abort, EOFError): + mcp_manager.stop_all() print_info(f"\nSession saved: {session_manager.current_session_id}") break except KeyboardInterrupt: - console.print() # New line after ^C - continue # Don't exit on Ctrl+C, just cancel current input + mcp_manager.stop_all() + console.print() + print_info(f"Session saved: {session_manager.current_session_id}") + break except Exception as e: error_str = str(e).lower() print_error(str(e)) @@ -1193,7 +1648,6 @@ def ask( tools = [ ShellTool(), - OpenTerminalTool(), TerminalSessionTool(), FilesystemTool(), SearchTool(), @@ -1202,16 +1656,21 @@ def ask( TaskTool(), WebSearchTool(), WebFetchTool(), + MemoryTool(), ] + + memory_mgr = MemoryManager() + memory_ctx = memory_mgr.get_context_prompt() + executor = ToolExecutor(tools=tools) - planner = SimplePlanner() + planner = SimplePlanner(memory_context=memory_ctx) agent = Agent(provider=llm, planner=planner, executor=executor, tools=tools) console.print(f"[dim]Using {llm.name}/{llm.model}[/dim]") try: result = agent.run(prompt, max_iterations=15) - print_panel(result, title="DevOrch", border_style="green") + print_panel(result, title="DevOrch", border_style="cyan") except Exception as e: print_error(str(e)) diff --git a/config/settings.py b/config/settings.py index 84a00bd..2a4e52d 100644 --- a/config/settings.py +++ b/config/settings.py @@ -33,6 +33,7 @@ class ProviderConfig: class Settings: default_provider: str = "openai" providers: dict[str, ProviderConfig] = field(default_factory=dict) + mcp_servers: dict[str, dict] = field(default_factory=dict) @classmethod def load(cls) -> "Settings": @@ -47,6 +48,7 @@ def load(cls) -> "Settings": settings.default_provider = data.get("default_provider", "openai") for name, config in data.get("providers", {}).items(): settings.providers[name] = ProviderConfig(**config) + settings.mcp_servers = data.get("mcp_servers", {}) except Exception: pass # Fall back to defaults if config is invalid @@ -169,6 +171,10 @@ def save_config(settings: Settings): if provider_data: data["providers"][name] = provider_data + # Include MCP server config + if settings.mcp_servers: + data["mcp_servers"] = settings.mcp_servers + with open(CONFIG_FILE, "w") as f: yaml.safe_dump(data, f, default_flow_style=False) diff --git a/core/agent.py b/core/agent.py index c1545da..0d86240 100644 --- a/core/agent.py +++ b/core/agent.py @@ -1,10 +1,6 @@ import json from collections.abc import Callable -from rich.panel import Panel -from rich.syntax import Syntax -from rich.text import Text - from core.executor import Executor from core.modes import AgentMode, ModeManager from core.planner import Planner @@ -78,15 +74,8 @@ def _display_tool_call(self, call: ToolCall): # Build a clean summary based on tool type if call.name == "shell": cmd = args.get("command", "") - syntax = Syntax(cmd, "bash", theme="monokai", line_numbers=False, word_wrap=True) - console.print( - Panel( - syntax, - title="[bold magenta]Shell[/bold magenta]", - border_style="magenta", - padding=(0, 1), - ) - ) + # Shell command with subtle background + console.print(f" [dim]>[/dim] [on #1e2030][cyan]shell[/cyan] [bold]{cmd}[/bold][/]") elif call.name == "filesystem": action = args.get("action", "") @@ -133,6 +122,27 @@ def _display_tool_call(self, call: ToolCall): display_url = url[:60] + "..." if len(url) > 60 else url console.print(f" [dim]>[/dim] [cyan]fetching[/cyan] [bold]{display_url}[/bold]") + elif call.name == "memory": + action = args.get("action", "") + name = args.get("name", "") + query = args.get("query", "") + if action == "save": + console.print(f" [dim]>[/dim] [cyan]saving memory[/cyan] [bold]{name}[/bold]") + elif action == "search": + console.print(f" [dim]>[/dim] [cyan]searching memory[/cyan] [bold]{query}[/bold]") + elif action == "list": + console.print(" [dim]>[/dim] [cyan]listing memories[/cyan]") + elif action == "delete": + console.print( + f" [dim]>[/dim] [cyan]deleting memory[/cyan] [bold]{args.get('filename', '')}[/bold]" + ) + else: + console.print(f" [dim]>[/dim] [cyan]memory {action}[/cyan]") + + elif call.name.startswith("mcp_"): + # MCP tool call + console.print(f" [dim]>[/dim] [#6a8aaa]MCP[/#6a8aaa] [cyan]{call.name}[/cyan]") + else: # Generic fallback - show tool name and brief args brief_args = { @@ -142,90 +152,82 @@ def _display_tool_call(self, call: ToolCall): console.print(f" [dim]>[/dim] [cyan]{call.name}[/cyan] {brief_args}") def _display_tool_result(self, tool_name: str, result: str): - """Display tool result in a compact, user-friendly format.""" + """Display tool result in a compact, user-friendly format. + + The full result always goes to the LLM — this only controls + what the USER sees in the terminal. + """ result_str = str(result) - # Skip display for task tool (it shows its own panel) + # Skip display entirely for these tools if tool_name == "task": return - # For filesystem writes, just show success - if "Successfully wrote" in result_str or "Successfully created" in result_str: - console.print(f" [green]✓[/green] [dim]{result_str}[/dim]") + # ── Errors — always show ───────────────────────────────────────── + if "Error:" in result_str or result_str.startswith("Error"): + console.print(f" [red]✗ {result_str[:300]}[/red]") return - # For successful file reads with long content, truncate - if tool_name == "filesystem" and len(result_str) > 200 and "Error" not in result_str: - lines = result_str.count("\n") - console.print(f" [green]✓[/green] [dim]Read {lines} lines[/dim]") + # ── File reads — never dump content, just show summary ─────────── + if tool_name == "filesystem": + if "Successfully wrote" in result_str or "Successfully created" in result_str: + console.print(f" [green]✓[/green] [dim]{result_str[:100]}[/dim]") + elif "] Lines " in result_str[:120]: + # Extract header like "[README.md] Lines 1-200 of 488" + header = result_str.split("\n", 1)[0] + console.print(f" [green]✓[/green] [dim]{header}[/dim]") + else: + lines = result_str.count("\n") + console.print(f" [green]✓[/green] [dim]Done ({lines} lines)[/dim]") return - # For search/grep results (file search, not web search) - if tool_name in ("search", "grep") and "Error" not in result_str: - matches = result_str.strip().split("\n") - count = len([m for m in matches if m.strip()]) - if count > 5: - console.print(f" [green]✓[/green] [dim]Found {count} matches[/dim]") - return - - # For websearch results, show them nicely - if tool_name == "websearch" and "Search results for:" in result_str: - # Show a brief summary, full results go to the AI - lines = result_str.strip().split("\n") + # ── Search/grep — just show match count ────────────────────────── + if tool_name in ("search", "grep"): + matches = [m for m in result_str.strip().split("\n") if m.strip()] + console.print(f" [green]✓[/green] [dim]Found {len(matches)} matches[/dim]") + return + + # ── Web tools — show summary ───────────────────────────────────── + if tool_name == "websearch": result_count = sum( - 1 for line in lines if line.strip().startswith(("1.", "2.", "3.", "4.", "5.")) + 1 + for line in result_str.strip().split("\n") + if line.strip().startswith(("1.", "2.", "3.", "4.", "5.")) ) console.print(f" [green]✓[/green] [dim]Found {result_count} web results[/dim]") return - # For webfetch results - if tool_name == "webfetch" and "Content from" in result_str: + if tool_name == "webfetch": lines = result_str.count("\n") console.print(f" [green]✓[/green] [dim]Fetched page ({lines} lines)[/dim]") return - # Check for errors - if "Error:" in result_str or result_str.startswith("Error"): - console.print( - Panel( - Text(result_str[:300], style="red"), - title="[bold red]Error[/bold red]", - border_style="red", - padding=(0, 1), - ) - ) + # ── Memory — show brief ────────────────────────────────────────── + if tool_name == "memory": + first_line = result_str.split("\n", 1)[0] + console.print(f" [green]✓[/green] [dim]{first_line[:80]}[/dim]") return - # Shell output - show in panel + # ── Shell output — truncate heavily ────────────────────────────── if result_str.startswith("STDOUT:") or result_str.startswith("STDERR:"): - # Truncate long output - max_display = 400 + max_display = 200 if len(result_str) > max_display: display_result = ( - result_str[:max_display] - + f"\n[dim]... ({len(result_str) - max_display} more chars)[/dim]" + result_str[:max_display] + f"\n... ({result_str.count(chr(10))} total lines)" ) else: display_result = result_str - console.print( - Panel( - Syntax( - display_result, "text", theme="monokai", line_numbers=False, word_wrap=True - ), - title="[bold green]Output[/bold green]", - border_style="green", - padding=(0, 1), - ) - ) + # Show output with subtle background + for line in display_result.split("\n"): + console.print(f" [dim on #1a1a28]{line}[/]") return - # Brief result for simple operations - if len(result_str) < 100: - console.print(f" [green]✓[/green] [dim]{result_str}[/dim]") - else: - # Truncate longer results - console.print(f" [green]✓[/green] [dim]{result_str[:100]}...[/dim]") + # ── Everything else — brief one-liner ──────────────────────────── + first_line = result_str.split("\n", 1)[0] + if len(first_line) > 80: + first_line = first_line[:80] + "..." + console.print(f" [green]✓[/green] [dim]{first_line}[/dim]") def _save_message(self, message: Message): """Save a message to history and session storage.""" diff --git a/core/executor.py b/core/executor.py index 3f6107d..d441ea1 100644 --- a/core/executor.py +++ b/core/executor.py @@ -18,13 +18,14 @@ # Custom style for questionary prompts PROMPT_STYLE = QStyle( [ - ("qmark", "fg:yellow bold"), - ("question", "fg:white bold"), - ("answer", "fg:green bold"), - ("pointer", "fg:cyan bold"), - ("highlighted", "noreverse fg:cyan bold bg:default"), # cyan text like pointer, no box - ("selected", "noreverse fg:cyan bold bg:default"), # default item — same as highlighted - ("text", "fg:white"), # plain items + ("qmark", "fg:#55aaff bold"), + ("question", "fg:#ffffff bold"), + ("answer", "fg:#44ddaa bold"), + ("pointer", "fg:#55ccff bold"), + ("highlighted", "noreverse fg:#55ccff bold bg:default"), + ("selected", "noreverse fg:#55ccff bold bg:default"), + ("text", "fg:#bbbbbb"), + ("instruction", "fg:#666666 italic"), ] ) @@ -78,7 +79,7 @@ def _ask_permission( # Create a nice panel for the command command_display = Text() command_display.append("Tool: ", style="dim") - command_display.append(f"{tool_name}\n", style="bold yellow") + command_display.append(f"{tool_name}\n", style="bold white") command_display.append("Command: ", style="dim") command_display.append(command, style="bold cyan") @@ -87,8 +88,8 @@ def _ask_permission( panel = Panel( command_display, - title="[bold yellow]Permission Required[/bold yellow]", - border_style="yellow", + title="[bold #e0a050]Permission Required[/bold #e0a050]", + border_style="#8a6a3a", padding=(0, 1), ) console.print(panel) diff --git a/core/mcp.py b/core/mcp.py new file mode 100644 index 0000000..402fe53 --- /dev/null +++ b/core/mcp.py @@ -0,0 +1,383 @@ +""" +MCP (Model Context Protocol) client for DevOrch — connects to MCP servers +and exposes their tools alongside built-in tools. + +MCP servers are configured in ~/.devorch/config.yaml under the `mcp_servers` key: + + mcp_servers: + filesystem: + command: npx + args: ["-y", "@modelcontextprotocol/server-filesystem", "/home/user"] + github: + command: npx + args: ["-y", "@modelcontextprotocol/server-github"] + env: + GITHUB_TOKEN: "ghp_xxx" + sqlite: + command: uvx + args: ["mcp-server-sqlite", "--db-path", "mydb.sqlite"] + +Each server communicates via JSON-RPC over stdio. +""" + +import json +import os +import subprocess +import threading +import time +from typing import Any + +from tools.base import Tool + +# ── JSON-RPC helpers ───────────────────────────────────────────────────────── + +_MSG_ID = 0 +_ID_LOCK = threading.Lock() + + +def _next_id() -> int: + global _MSG_ID + with _ID_LOCK: + _MSG_ID += 1 + return _MSG_ID + + +def _jsonrpc_request(method: str, params: dict | None = None, req_id: int | None = None) -> bytes: + """Build a JSON-RPC 2.0 request.""" + msg = { + "jsonrpc": "2.0", + "method": method, + "id": req_id if req_id is not None else _next_id(), + } + if params is not None: + msg["params"] = params + return (json.dumps(msg) + "\n").encode("utf-8") + + +def _jsonrpc_notification(method: str, params: dict | None = None) -> bytes: + """Build a JSON-RPC 2.0 notification (no id).""" + msg = {"jsonrpc": "2.0", "method": method} + if params is not None: + msg["params"] = params + return (json.dumps(msg) + "\n").encode("utf-8") + + +# ── MCP Server Connection ─────────────────────────────────────────────────── + + +class MCPServer: + """Manages a connection to a single MCP server process.""" + + def __init__( + self, + name: str, + command: str, + args: list[str] | None = None, + env: dict[str, str] | None = None, + cwd: str | None = None, + ): + self.name = name + self.command = command + self.args = args or [] + self.env = env or {} + self.cwd = cwd + self.process: subprocess.Popen | None = None + self.tools: list[dict] = [] + self.resources: list[dict] = [] + self._lock = threading.Lock() + self._response_buffer: dict[int, dict] = {} + self._reader_thread: threading.Thread | None = None + self._running = False + self._server_info: dict = {} + + def start(self) -> bool: + """Start the MCP server process and initialize the connection.""" + try: + # Build environment + proc_env = os.environ.copy() + proc_env.update(self.env) + + cmd = [self.command] + self.args + + self.process = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=proc_env, + cwd=self.cwd, + bufsize=0, + ) + + self._running = True + + # Start reader thread + self._reader_thread = threading.Thread( + target=self._read_loop, + daemon=True, + name=f"mcp-reader-{self.name}", + ) + self._reader_thread.start() + + # Initialize MCP protocol + if not self._initialize(): + self.stop() + return False + + # List available tools + self._list_tools() + + return True + + except FileNotFoundError: + return False + except Exception: + return False + + def stop(self): + """Stop the MCP server process.""" + self._running = False + if self.process: + try: + self.process.terminate() + self.process.wait(timeout=5) + except Exception: + try: + self.process.kill() + except Exception: + pass + self.process = None + + def call_tool(self, tool_name: str, arguments: dict) -> str: + """Call a tool on the MCP server.""" + if not self.process or not self._running: + return f"Error: MCP server '{self.name}' is not running." + + req_id = _next_id() + request = _jsonrpc_request( + "tools/call", + params={"name": tool_name, "arguments": arguments}, + req_id=req_id, + ) + + try: + self.process.stdin.write(request) + self.process.stdin.flush() + + # Wait for response (with timeout) + response = self._wait_for_response(req_id, timeout=30) + if response is None: + return f"Error: Timeout waiting for response from MCP server '{self.name}'." + + if "error" in response: + err = response["error"] + return f"Error from MCP server: {err.get('message', str(err))}" + + result = response.get("result", {}) + + # MCP tool results contain a "content" array + content_parts = result.get("content", []) + output_parts = [] + for part in content_parts: + if part.get("type") == "text": + output_parts.append(part.get("text", "")) + elif part.get("type") == "image": + output_parts.append(f"[Image: {part.get('mimeType', 'unknown')}]") + else: + output_parts.append(str(part)) + + return "\n".join(output_parts) if output_parts else str(result) + + except Exception as e: + return f"Error calling tool on MCP server '{self.name}': {e}" + + def _initialize(self) -> bool: + """Send MCP initialize handshake.""" + req_id = _next_id() + request = _jsonrpc_request( + "initialize", + params={ + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { + "name": "devorch", + "version": "0.1.3", + }, + }, + req_id=req_id, + ) + + try: + self.process.stdin.write(request) + self.process.stdin.flush() + + response = self._wait_for_response(req_id, timeout=10) + if response is None: + return False + + if "error" in response: + return False + + self._server_info = response.get("result", {}) + + # Send initialized notification + notification = _jsonrpc_notification("notifications/initialized") + self.process.stdin.write(notification) + self.process.stdin.flush() + + return True + + except Exception: + return False + + def _list_tools(self): + """List available tools from the MCP server.""" + req_id = _next_id() + request = _jsonrpc_request("tools/list", req_id=req_id) + + try: + self.process.stdin.write(request) + self.process.stdin.flush() + + response = self._wait_for_response(req_id, timeout=10) + if response and "result" in response: + self.tools = response["result"].get("tools", []) + except Exception: + self.tools = [] + + def _read_loop(self): + """Background thread that reads responses from the MCP server.""" + try: + while self._running and self.process and self.process.stdout: + line = self.process.stdout.readline() + if not line: + break + + line = line.strip() + if not line: + continue + + try: + msg = json.loads(line) + msg_id = msg.get("id") + if msg_id is not None: + with self._lock: + self._response_buffer[msg_id] = msg + except json.JSONDecodeError: + continue + except Exception: + pass + finally: + self._running = False + + def _wait_for_response(self, req_id: int, timeout: float = 30) -> dict | None: + """Wait for a response with the given ID.""" + deadline = time.time() + timeout + while time.time() < deadline: + with self._lock: + if req_id in self._response_buffer: + return self._response_buffer.pop(req_id) + time.sleep(0.05) + return None + + +# ── MCP Manager ────────────────────────────────────────────────────────────── + + +class MCPManager: + """Manages multiple MCP server connections.""" + + def __init__(self): + self.servers: dict[str, MCPServer] = {} + + def load_from_config(self, mcp_config: dict[str, dict]) -> list[str]: + """Load and start MCP servers from config. + Returns list of successfully started server names. + """ + started = [] + for name, config in mcp_config.items(): + command = config.get("command", "") + if not command: + continue + + args = config.get("args", []) + env = config.get("env", {}) + cwd = config.get("cwd") + + server = MCPServer(name=name, command=command, args=args, env=env, cwd=cwd) + if server.start(): + self.servers[name] = server + started.append(name) + + return started + + def get_all_tools(self) -> list["MCPToolProxy"]: + """Get Tool instances for all tools across all connected MCP servers.""" + tools = [] + for server_name, server in self.servers.items(): + for tool_def in server.tools: + proxy = MCPToolProxy( + server=server, + server_name=server_name, + tool_def=tool_def, + ) + tools.append(proxy) + return tools + + def stop_all(self): + """Stop all MCP servers.""" + for server in self.servers.values(): + server.stop() + self.servers.clear() + + def list_servers(self) -> list[dict]: + """List all connected servers and their tools.""" + result = [] + for name, server in self.servers.items(): + result.append( + { + "name": name, + "running": server._running, + "tools": [t.get("name", "") for t in server.tools], + "server_info": server._server_info, + } + ) + return result + + +# ── MCP Tool Proxy ─────────────────────────────────────────────────────────── + + +class MCPToolProxy(Tool): + """Wraps an MCP server tool as a DevOrch Tool so it integrates seamlessly.""" + + def __init__(self, server: MCPServer, server_name: str, tool_def: dict): + self._server = server + self._server_name = server_name + self._tool_def = tool_def + + # Set tool name with server prefix to avoid conflicts + self.name = f"mcp_{server_name}_{tool_def.get('name', 'unknown')}" + self.description = f"[MCP: {server_name}] {tool_def.get('description', 'No description')}" + self.args_schema = None # MCP tools use raw JSON schema + + def schema(self) -> dict[str, Any]: + """Return the tool schema for LLM consumption.""" + input_schema = self._tool_def.get( + "inputSchema", + { + "type": "object", + "properties": {}, + }, + ) + + return { + "name": self.name, + "description": self.description, + "parameters": input_schema, + } + + def run(self, arguments: dict[str, Any]) -> Any: + """Execute the tool via the MCP server.""" + original_name = self._tool_def.get("name", "") + return self._server.call_tool(original_name, arguments) diff --git a/core/memory.py b/core/memory.py new file mode 100644 index 0000000..31b10bf --- /dev/null +++ b/core/memory.py @@ -0,0 +1,411 @@ +""" +Persistent memory system for DevOrch — stores user preferences, feedback, +project context, and references across conversations. + +Memory files are stored as markdown with YAML frontmatter under +~/.devorch/memory/, with a MEMORY.md index file. +""" + +import re +from datetime import datetime +from pathlib import Path +from typing import Any + +from pydantic import BaseModel, Field + +from tools.base import Tool + +MEMORY_DIR = Path.home() / ".devorch" / "memory" +MEMORY_INDEX = MEMORY_DIR / "MEMORY.md" + +# Valid memory types +MEMORY_TYPES = {"user", "feedback", "project", "reference"} + + +def _ensure_memory_dir(): + """Create memory directory if needed.""" + MEMORY_DIR.mkdir(parents=True, exist_ok=True) + + +def _parse_frontmatter(content: str) -> tuple[dict, str]: + """Parse YAML frontmatter from markdown content. + Returns (metadata_dict, body_text). + """ + if not content.startswith("---"): + return {}, content + + parts = content.split("---", 2) + if len(parts) < 3: + return {}, content + + frontmatter = parts[1].strip() + body = parts[2].strip() + + metadata = {} + for line in frontmatter.split("\n"): + line = line.strip() + if ":" in line: + key, _, value = line.partition(":") + metadata[key.strip()] = value.strip() + + return metadata, body + + +def _create_frontmatter(name: str, description: str, mem_type: str) -> str: + """Create YAML frontmatter block.""" + return f"""--- +name: {name} +description: {description} +type: {mem_type} +created: {datetime.now().strftime("%Y-%m-%d %H:%M")} +---""" + + +def _slugify(text: str) -> str: + """Convert text to a filename-safe slug.""" + slug = re.sub(r"[^\w\s-]", "", text.lower()) + slug = re.sub(r"[\s_]+", "_", slug) + return slug[:60].strip("_") + + +class MemoryManager: + """Manages persistent memory files.""" + + def __init__(self, memory_dir: Path | None = None): + self.memory_dir = memory_dir or MEMORY_DIR + self.index_file = self.memory_dir / "MEMORY.md" + _ensure_memory_dir() + + def save(self, name: str, description: str, mem_type: str, content: str) -> str: + """Save a memory to a markdown file and update the index. + Returns the file path. + """ + if mem_type not in MEMORY_TYPES: + raise ValueError(f"Invalid memory type: {mem_type}. Must be one of: {MEMORY_TYPES}") + + filename = f"{mem_type}_{_slugify(name)}.md" + filepath = self.memory_dir / filename + + frontmatter = _create_frontmatter(name, description, mem_type) + file_content = f"{frontmatter}\n\n{content}\n" + + filepath.write_text(file_content, encoding="utf-8") + + # Update index + self._update_index(filename, description, mem_type) + + return str(filepath) + + def load(self, filename: str) -> dict | None: + """Load a specific memory file. Returns dict with metadata + body.""" + filepath = self.memory_dir / filename + if not filepath.exists(): + return None + + content = filepath.read_text(encoding="utf-8") + metadata, body = _parse_frontmatter(content) + return { + "filename": filename, + "name": metadata.get("name", ""), + "description": metadata.get("description", ""), + "type": metadata.get("type", ""), + "created": metadata.get("created", ""), + "content": body, + } + + def search(self, query: str = "", mem_type: str = "") -> list[dict]: + """Search memories by keyword or type. Returns list of memory dicts.""" + results = [] + if not self.memory_dir.exists(): + return results + + for filepath in sorted(self.memory_dir.glob("*.md")): + if filepath.name == "MEMORY.md": + continue + + content = filepath.read_text(encoding="utf-8") + metadata, body = _parse_frontmatter(content) + + # Filter by type + if mem_type and metadata.get("type", "") != mem_type: + continue + + # Filter by query (search name, description, and body) + if query: + searchable = f"{metadata.get('name', '')} {metadata.get('description', '')} {body}" + if query.lower() not in searchable.lower(): + continue + + results.append( + { + "filename": filepath.name, + "name": metadata.get("name", ""), + "description": metadata.get("description", ""), + "type": metadata.get("type", ""), + "created": metadata.get("created", ""), + "content": body, + } + ) + + return results + + def delete(self, filename: str) -> bool: + """Delete a memory file and remove from index.""" + filepath = self.memory_dir / filename + if not filepath.exists(): + return False + + filepath.unlink() + self._remove_from_index(filename) + return True + + def list_all(self) -> list[dict]: + """List all memories with metadata (no body content).""" + results = [] + if not self.memory_dir.exists(): + return results + + for filepath in sorted(self.memory_dir.glob("*.md")): + if filepath.name == "MEMORY.md": + continue + + content = filepath.read_text(encoding="utf-8") + metadata, _ = _parse_frontmatter(content) + + results.append( + { + "filename": filepath.name, + "name": metadata.get("name", ""), + "description": metadata.get("description", ""), + "type": metadata.get("type", ""), + "created": metadata.get("created", ""), + } + ) + + return results + + def get_context_prompt(self) -> str: + """Build a context string from all memories for the system prompt.""" + memories = self.search() + if not memories: + return "" + + sections = {"user": [], "feedback": [], "project": [], "reference": []} + + for mem in memories: + mem_type = mem.get("type", "") + if mem_type in sections: + sections[mem_type].append(mem) + + lines = ["\n## Memory Context (from previous conversations)\n"] + + type_labels = { + "user": "User Profile", + "feedback": "User Feedback & Preferences", + "project": "Project Context", + "reference": "External References", + } + + for mem_type, label in type_labels.items(): + if sections[mem_type]: + lines.append(f"\n### {label}") + for mem in sections[mem_type]: + lines.append(f"- **{mem['name']}**: {mem['content'][:200]}") + + return "\n".join(lines) + + def _update_index(self, filename: str, description: str, mem_type: str): + """Update the MEMORY.md index file.""" + _ensure_memory_dir() + + # Read existing index + existing = "" + if self.index_file.exists(): + existing = self.index_file.read_text(encoding="utf-8") + + # Check if entry already exists + if filename in existing: + # Update the line + lines = existing.split("\n") + new_lines = [] + for line in lines: + if filename in line: + new_lines.append(f"- [{filename}]({filename}) — [{mem_type}] {description}") + else: + new_lines.append(line) + self.index_file.write_text("\n".join(new_lines), encoding="utf-8") + else: + # Append new entry + if not existing: + existing = "# DevOrch Memory Index\n\n" + entry = f"- [{filename}]({filename}) — [{mem_type}] {description}\n" + self.index_file.write_text(existing + entry, encoding="utf-8") + + def _remove_from_index(self, filename: str): + """Remove an entry from the MEMORY.md index.""" + if not self.index_file.exists(): + return + + existing = self.index_file.read_text(encoding="utf-8") + lines = existing.split("\n") + new_lines = [line for line in lines if filename not in line] + self.index_file.write_text("\n".join(new_lines), encoding="utf-8") + + +# ── Memory Tool for LLM use ───────────────────────────────────────────────── + + +class MemorySchema(BaseModel): + action: str = Field( + ..., + description=( + "Action to perform. One of: " + "'save' — save a new memory; " + "'search' — search existing memories; " + "'list' — list all saved memories; " + "'load' — load a specific memory by filename; " + "'delete' — delete a memory by filename." + ), + ) + name: str | None = Field( + None, + description="Name/title for the memory. Required for 'save'.", + ) + description: str | None = Field( + None, + description="One-line description of the memory. Required for 'save'.", + ) + memory_type: str | None = Field( + None, + description=( + "Type of memory. Required for 'save'. One of: " + "'user' — user profile/preferences; " + "'feedback' — user corrections/guidance; " + "'project' — project context/decisions; " + "'reference' — pointers to external resources." + ), + ) + content: str | None = Field( + None, + description="Memory content to save. Required for 'save'.", + ) + query: str | None = Field( + None, + description="Search query for 'search' action. Searches name, description, and content.", + ) + filename: str | None = Field( + None, + description="Filename for 'load' or 'delete' actions.", + ) + + +class MemoryTool(Tool): + name = "memory" + description = """\ +Persistent memory system for storing and retrieving information across conversations. + +Actions: +- **save** — Save information to persistent memory (requires name, description, memory_type, content) +- **search** — Search existing memories by keyword or type +- **list** — List all saved memories with their metadata +- **load** — Load a specific memory by filename +- **delete** — Delete a memory by filename + +Memory Types: +- **user** — User profile, preferences, role, expertise +- **feedback** — User corrections, guidance on how to behave +- **project** — Project decisions, context, ongoing work +- **reference** — Links to external resources, documentation + +Use this to remember important context about the user and project across conversations. +For feedback memories, structure as: rule, then Why: and How to apply: lines.""" + args_schema = MemorySchema + + def __init__(self): + self._manager = MemoryManager() + + def run(self, arguments: dict[str, Any]) -> Any: + action = (arguments.get("action") or "").lower().strip() + + if action == "save": + name = arguments.get("name") + description = arguments.get("description") + mem_type = arguments.get("memory_type") + content = arguments.get("content") + + if not all([name, description, mem_type, content]): + return "Error: 'save' requires name, description, memory_type, and content." + + if mem_type not in MEMORY_TYPES: + return f"Error: Invalid memory_type '{mem_type}'. Must be one of: {', '.join(MEMORY_TYPES)}" + + try: + filepath = self._manager.save(name, description, mem_type, content) + return f"Memory saved: {name}\nFile: {filepath}" + except Exception as e: + return f"Error saving memory: {e}" + + elif action == "search": + query = arguments.get("query", "") + mem_type = arguments.get("memory_type", "") + results = self._manager.search(query=query, mem_type=mem_type) + + if not results: + return "No memories found matching your search." + + lines = [f"Found {len(results)} memory(ies):\n"] + for mem in results: + preview = mem["content"][:100].replace("\n", " ") + lines.append( + f" [{mem['type']}] {mem['name']}\n" + f" File: {mem['filename']}\n" + f" {preview}...\n" + ) + return "\n".join(lines) + + elif action == "list": + memories = self._manager.list_all() + if not memories: + return "No memories saved yet." + + lines = ["Saved memories:\n"] + for mem in memories: + lines.append( + f" [{mem['type']}] {mem['name']}\n" + f" {mem['description']}\n" + f" File: {mem['filename']} Created: {mem['created']}\n" + ) + return "\n".join(lines) + + elif action == "load": + filename = arguments.get("filename") + if not filename: + return "Error: 'load' requires filename." + + mem = self._manager.load(filename) + if not mem: + return f"Error: Memory '{filename}' not found." + + return ( + f"Name: {mem['name']}\n" + f"Type: {mem['type']}\n" + f"Description: {mem['description']}\n" + f"Created: {mem['created']}\n\n" + f"{mem['content']}" + ) + + elif action == "delete": + filename = arguments.get("filename") + if not filename: + return "Error: 'delete' requires filename." + + if self._manager.delete(filename): + return f"Memory '{filename}' deleted." + else: + return f"Error: Memory '{filename}' not found." + + else: + return ( + f"Error: Unknown action '{action}'. " + f"Valid actions: save, search, list, load, delete." + ) diff --git a/core/skills.py b/core/skills.py new file mode 100644 index 0000000..ca32b3e --- /dev/null +++ b/core/skills.py @@ -0,0 +1,226 @@ +""" +Skills system for DevOrch — loadable, reusable prompt templates and workflows +that can be invoked via slash commands. + +Skills are defined as YAML files under ~/.devorch/skills/: + + # ~/.devorch/skills/commit.yaml + name: commit + description: Create a git commit with a descriptive message + prompt: | + Look at the current git diff (staged and unstaged changes) and create + a well-formatted git commit. Follow conventional commit format. + First stage relevant files, then commit with a descriptive message. + + # ~/.devorch/skills/review.yaml + name: review + description: Review code changes for bugs and improvements + prompt: | + Review the current git diff for: + 1. Bugs or logic errors + 2. Security issues + 3. Performance problems + 4. Code style issues + Provide specific, actionable feedback. + +Built-in skills are also provided for common workflows. +""" + +import os +from pathlib import Path + +try: + import yaml + + YAML_AVAILABLE = True +except ImportError: + YAML_AVAILABLE = False + +SKILLS_DIR = Path.home() / ".devorch" / "skills" + +# ── Built-in skills ────────────────────────────────────────────────────────── + +BUILTIN_SKILLS: dict[str, dict] = { + "commit": { + "name": "commit", + "description": "Create a git commit with a well-formatted message", + "prompt": ( + "Look at the current git status and diff (both staged and unstaged changes). " + "Stage the relevant changed files (prefer specific files over 'git add -A'). " + "Then create a git commit with a clear, descriptive message following conventional " + "commit format (e.g., 'feat:', 'fix:', 'refactor:', 'docs:', 'chore:'). " + "Summarize what changed and why. Show me the commit result." + ), + }, + "review": { + "name": "review", + "description": "Review current code changes for issues", + "prompt": ( + "Review the current git diff (run 'git diff' and 'git diff --staged') for:\n" + "1. Bugs or logic errors\n" + "2. Security vulnerabilities\n" + "3. Performance issues\n" + "4. Code style and readability\n" + "5. Missing error handling\n\n" + "Provide specific, actionable feedback with file:line references." + ), + }, + "test": { + "name": "test", + "description": "Run project tests and analyze results", + "prompt": ( + "Detect the project's test framework and run the tests. " + "If tests fail, analyze the failures and suggest fixes. " + "Common test commands to try: pytest, npm test, cargo test, go test ./..., " + "python -m unittest discover. Check package.json, pyproject.toml, Cargo.toml, " + "or Makefile for the correct test command." + ), + }, + "explain": { + "name": "explain", + "description": "Explain the current project structure", + "prompt": ( + "Analyze the current project directory structure. Identify:\n" + "1. What type of project this is (language, framework)\n" + "2. Key entry points and main files\n" + "3. Directory structure and organization\n" + "4. Dependencies and build system\n" + "5. How to run/build/test the project\n\n" + "Give a concise overview suitable for someone new to the project." + ), + }, + "fix": { + "name": "fix", + "description": "Fix the last error or failing test", + "prompt": ( + "Look at the most recent error output (check shell history, test results, " + "or build logs). Diagnose the root cause and fix it. " + "After fixing, re-run the command to verify the fix works." + ), + }, + "simplify": { + "name": "simplify", + "description": "Review and simplify recent code changes", + "prompt": ( + "Look at the recent code changes (git diff HEAD~1 or staged changes). " + "Review for:\n" + "1. Code that can be simplified or made more readable\n" + "2. Unnecessary complexity or over-engineering\n" + "3. Duplicated logic that could be consolidated\n" + "4. Better naming opportunities\n\n" + "Make the improvements directly." + ), + }, +} + + +# ── Skill Manager ──────────────────────────────────────────────────────────── + + +class SkillManager: + """Manages built-in and user-defined skills.""" + + def __init__(self, skills_dir: Path | None = None): + self.skills_dir = skills_dir or SKILLS_DIR + self._skills: dict[str, dict] = {} + self._load_skills() + + def _load_skills(self): + """Load built-in skills and user-defined skills from disk.""" + # Load built-in skills first + self._skills = dict(BUILTIN_SKILLS) + + # Load user skills (these override built-ins with the same name) + if YAML_AVAILABLE and self.skills_dir.exists(): + for filepath in self.skills_dir.glob("*.yaml"): + try: + with open(filepath) as f: + skill_data = yaml.safe_load(f) + + if isinstance(skill_data, dict) and "name" in skill_data: + self._skills[skill_data["name"]] = { + "name": skill_data["name"], + "description": skill_data.get("description", ""), + "prompt": skill_data.get("prompt", ""), + "source": str(filepath), + } + except Exception: + continue + + for filepath in self.skills_dir.glob("*.yml"): + try: + with open(filepath) as f: + skill_data = yaml.safe_load(f) + + if isinstance(skill_data, dict) and "name" in skill_data: + self._skills[skill_data["name"]] = { + "name": skill_data["name"], + "description": skill_data.get("description", ""), + "prompt": skill_data.get("prompt", ""), + "source": str(filepath), + } + except Exception: + continue + + def get(self, name: str) -> dict | None: + """Get a skill by name.""" + return self._skills.get(name) + + def list_skills(self) -> list[dict]: + """List all available skills.""" + result = [] + for _name, skill in sorted(self._skills.items()): + result.append( + { + "name": skill["name"], + "description": skill.get("description", ""), + "source": skill.get("source", "built-in"), + } + ) + return result + + def create_skill(self, name: str, description: str, prompt: str) -> str: + """Create a new user skill and save to disk.""" + if not YAML_AVAILABLE: + return "Error: PyYAML is required to save skills." + + self.skills_dir.mkdir(parents=True, exist_ok=True) + + skill_data = { + "name": name, + "description": description, + "prompt": prompt, + } + + filepath = self.skills_dir / f"{name}.yaml" + with open(filepath, "w") as f: + yaml.safe_dump(skill_data, f, default_flow_style=False) + + self._skills[name] = { + "name": name, + "description": description, + "prompt": prompt, + "source": str(filepath), + } + + return str(filepath) + + def delete_skill(self, name: str) -> bool: + """Delete a user-defined skill.""" + if name in BUILTIN_SKILLS and name not in self._skills: + return False + + skill = self._skills.get(name) + if not skill: + return False + + source = skill.get("source", "") + if source and source != "built-in" and os.path.exists(source): + os.unlink(source) + + del self._skills[name] + return True + + def reload(self): + """Reload skills from disk.""" + self._load_skills() diff --git a/pyproject.toml b/pyproject.toml index ef9bfd3..db87630 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "devorch" -version = "0.1.3" +version = "0.2.1" description = "Multi-provider AI coding assistant CLI with 13+ providers" readme = "README.md" requires-python = ">=3.10" @@ -21,6 +21,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Software Development", "Topic :: Utilities", ] diff --git a/tools/__init__.py b/tools/__init__.py index cb1ace8..9d1712b 100644 --- a/tools/__init__.py +++ b/tools/__init__.py @@ -3,12 +3,12 @@ Available tools: - ShellTool: Execute shell commands -- OpenTerminalTool: Run a command in a new terminal window (servers, scaffolds, long-running processes) -- TerminalSessionTool: Managed background sessions with read/send/stop/list +- TerminalSessionTool: Managed terminal sessions with gui option, read/send/stop/list (persistent) - FilesystemTool: Read, write, list files with line-specific control - SearchTool: Find files by glob patterns - GrepTool: Search file contents with regex - EditTool: Make targeted edits to files +- MemoryTool: Persistent memory across conversations """ from tools.edit import EditTool @@ -16,12 +16,10 @@ from tools.grep import GrepTool from tools.search import SearchTool from tools.shell import ShellTool -from tools.terminal import OpenTerminalTool from tools.terminal_session import TerminalSessionTool __all__ = [ "ShellTool", - "OpenTerminalTool", "TerminalSessionTool", "FilesystemTool", "SearchTool", diff --git a/tools/terminal.py b/tools/terminal.py deleted file mode 100644 index 9da886c..0000000 --- a/tools/terminal.py +++ /dev/null @@ -1,123 +0,0 @@ -import os -import subprocess -import sys -from typing import Any - -from pydantic import BaseModel, Field - -from tools.base import Tool - - -class OpenTerminalSchema(BaseModel): - command: str = Field( - ..., - description=( - "The command to run in a new terminal window. " - "Use this for long-running servers (npm run dev, vite, uvicorn, flask run, etc.), " - "interactive scaffold tools (npm create, npx create-*, ng new, etc.), " - "or anything else that would block the main session if run normally." - ), - ) - - -class OpenTerminalTool(Tool): - name = "open_terminal" - description = """\ -Opens a new terminal window and runs the given command inside it. - -Use this tool whenever you need to: -- Start a development server or daemon (e.g. `npm run dev`, `vite`, `uvicorn app:main`, `flask run`, `next dev`) -- Run an interactive scaffold that prompts the user (e.g. `npm create vite@latest`, `npx create-next-app`, `ng new myapp`) -- Run any long-running process that should NOT block the current session - -The main DevOrch session remains fully interactive while the command runs in its own window. -After calling this tool, continue the conversation normally — do NOT wait for the command to finish.""" - args_schema = OpenTerminalSchema - - def run(self, arguments: dict[str, Any]) -> Any: - command = arguments.get("command", "").strip() - if not command: - return "Error: No command provided." - - working_dir = os.getcwd() - system = sys.platform - - try: - if system == "win32": - subprocess.Popen( - ["cmd", "/c", "start", "cmd", "/k", command], - cwd=working_dir, - ) - return ( - f"✓ Opened new terminal window\n\n" - f"Command: {command}\n\n" - f"The command is now running in a separate window. " - f"You can continue the conversation here." - ) - - elif system == "darwin": - escaped_command = command.replace('"', '\\"') - escaped_dir = working_dir.replace('"', '\\"') - subprocess.Popen( - [ - "osascript", - "-e", - f'tell app "Terminal" to do script "cd \\"{escaped_dir}\\" && {escaped_command}"', - ] - ) - return ( - f"✓ Opened new Terminal window\n\n" - f"Command: {command}\n\n" - f"The command is now running in a separate window. " - f"You can continue the conversation here." - ) - - else: - # Linux: try common terminal emulators in order - terminals = [ - [ - "gnome-terminal", - "--working-directory", - working_dir, - "--", - "bash", - "-c", - f"{command}; exec bash", - ], - ["konsole", "--workdir", working_dir, "-e", f"bash -c '{command}; exec bash'"], - ["xterm", "-e", f"bash -c 'cd \"{working_dir}\" && {command}; exec bash'"], - [ - "x-terminal-emulator", - "-e", - f"bash -c 'cd \"{working_dir}\" && {command}; exec bash'", - ], - ] - for term_cmd in terminals: - try: - subprocess.Popen(term_cmd) - return ( - f"✓ Opened new terminal window\n\n" - f"Command: {command}\n\n" - f"The command is now running in a separate window. " - f"You can continue the conversation here." - ) - except FileNotFoundError: - continue - - # Fallback: background process - process = subprocess.Popen( - command, - shell=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True, - cwd=working_dir, - ) - return ( - f"✓ Started in background (PID: {process.pid})\n\n" - f"Command: {command}\n\n" - f"Note: No GUI terminal emulator found — process is running in the background." - ) - - except Exception as e: - return f"Error opening terminal: {str(e)}\n\nRun manually: {command}" diff --git a/tools/terminal_session.py b/tools/terminal_session.py index 59cb85b..7ac67ef 100644 --- a/tools/terminal_session.py +++ b/tools/terminal_session.py @@ -2,26 +2,130 @@ Managed terminal session tool — lets the LLM start long-running processes, read their output, send input, and stop them, all within the conversation. -Session data is stored as a simple in-process dict (sessions survive for the -lifetime of one DevOrch run). Each session gets a temp log file so the LLM -can poll output without blocking. +Sessions are tracked both in-memory AND persisted to a JSON registry file +so that DevOrch can reconnect to orphaned processes across restarts. +Each session gets a unique name (auto-generated if not provided) and a +persistent log file under ~/.devorch/sessions/. """ +import json import os +import random +import signal +import string import subprocess -import tempfile import threading +import time +from pathlib import Path from typing import Any from pydantic import BaseModel, Field from tools.base import Tool +# ── Paths ──────────────────────────────────────────────────────────────────── +SESSIONS_DIR = Path.home() / ".devorch" / "sessions" +REGISTRY_FILE = SESSIONS_DIR / "registry.json" + # ── In-memory session registry ────────────────────────────────────────────── -# { session_id: { "process": Popen, "log_file": str, "command": str } } +# { session_id: { "process": Popen | None, "log_path": str, "command": str, "pid": int, "cwd": str } } _SESSIONS: dict[str, dict] = {} _LOCK = threading.Lock() +# Adjectives and nouns for human-readable unique names +_ADJECTIVES = [ + "swift", + "bright", + "calm", + "dark", + "eager", + "fast", + "green", + "happy", + "iron", + "keen", + "light", + "merry", + "noble", + "proud", + "quick", + "red", + "sharp", + "tall", + "vivid", + "warm", + "bold", + "cool", + "deep", + "fine", +] +_NOUNS = [ + "fox", + "hawk", + "lion", + "wolf", + "bear", + "deer", + "dove", + "eagle", + "frog", + "goat", + "hare", + "kite", + "lark", + "mole", + "newt", + "owl", + "pike", + "ram", + "seal", + "toad", + "wren", + "crow", + "lynx", + "orca", +] + + +def _generate_unique_name() -> str: + """Generate a human-readable unique session name like 'swift-fox-a3f2'.""" + adj = random.choice(_ADJECTIVES) + noun = random.choice(_NOUNS) + suffix = "".join(random.choices(string.hexdigits[:16], k=4)) + return f"{adj}-{noun}-{suffix}" + + +def _ensure_sessions_dir(): + """Create the sessions directory if needed.""" + SESSIONS_DIR.mkdir(parents=True, exist_ok=True) + + +def _load_registry() -> dict[str, dict]: + """Load the persistent session registry from disk.""" + if not REGISTRY_FILE.exists(): + return {} + try: + with open(REGISTRY_FILE) as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + return {} + + +def _save_registry(registry: dict[str, dict]): + """Save the session registry to disk.""" + _ensure_sessions_dir() + with open(REGISTRY_FILE, "w") as f: + json.dump(registry, f, indent=2) + + +def _pid_alive(pid: int) -> bool: + """Check if a process with the given PID is still running.""" + try: + os.kill(pid, 0) + return True + except (OSError, ProcessLookupError): + return False + def _stream_to_file(process: subprocess.Popen, log_path: str) -> None: """Background thread: read stdout+stderr and append to log file.""" @@ -34,6 +138,45 @@ def _stream_to_file(process: subprocess.Popen, log_path: str) -> None: pass +def _reconnect_orphaned_sessions(): + """On startup, check the registry for processes that are still alive + and re-attach them to the in-memory registry.""" + registry = _load_registry() + cleaned = {} + + for sid, info in registry.items(): + pid = info.get("pid", 0) + log_path = info.get("log_path", "") + + if pid and _pid_alive(pid): + # Process still running — register it (without a Popen handle, + # we can still read its log and send signals) + with _LOCK: + if sid not in _SESSIONS: + _SESSIONS[sid] = { + "process": None, # No Popen handle for orphans + "pid": pid, + "log_path": log_path, + "command": info.get("command", ""), + "cwd": info.get("cwd", ""), + "started_at": info.get("started_at", ""), + } + cleaned[sid] = info + else: + # Dead process — clean up log file + if log_path and os.path.exists(log_path): + try: + os.unlink(log_path) + except OSError: + pass + + _save_registry(cleaned) + + +# Run reconnection on module import +_reconnect_orphaned_sessions() + + # ── Schema ─────────────────────────────────────────────────────────────────── @@ -46,14 +189,16 @@ class TerminalSessionSchema(BaseModel): "'read' — get recent output from a running session; " "'send' — send a line of text/input to a running session's stdin; " "'stop' — terminate a session; " - "'list' — list all active sessions." + "'list' — list all active sessions; " + "'reconnect' — reconnect to orphaned sessions from previous DevOrch runs." ), ) session_id: str | None = Field( None, description=( - "Identifier for the session. Required for 'start', 'read', 'send', 'stop'. " - "Choose a short, descriptive name, e.g. 'frontend', 'api-server', 'worker'." + "Identifier for the session. Required for 'read', 'send', 'stop'. " + "For 'start', if omitted a unique name is auto-generated. " + "You can also provide a short descriptive name like 'frontend' or 'api-server'." ), ) command: str | None = Field( @@ -71,6 +216,13 @@ class TerminalSessionSchema(BaseModel): 50, description="Number of recent output lines to return for 'read'. Default 50.", ) + gui: bool = Field( + False, + description=( + "If true, also opens a visible GUI terminal window alongside the background session. " + "Use when the user wants to SEE the terminal. The AI can still read/send via session_id." + ), + ) # ── Tool ────────────────────────────────────────────────────────────────────── @@ -79,25 +231,29 @@ class TerminalSessionSchema(BaseModel): class TerminalSessionTool(Tool): name = "terminal_session" description = """\ -Manages long-running background terminal sessions so you can interact with them -across multiple conversation turns. +Manages long-running terminal sessions that YOU (the AI) can see and interact with. +This is the PRIMARY tool for all terminal/process needs. Sessions persist across DevOrch restarts. Actions: -- **start** — Start a command in a named background session (e.g. `npm run dev`). - The process runs in the background; use `read` to see its output. +- **start** — Start a command in a managed session. Defaults to 'bash' if no command given. + Set gui=true to also open a visible terminal window for the user. + A unique name is auto-generated if you don't provide session_id. - **read** — Read recent stdout/stderr output from a running session. -- **send** — Send a line of input to the process stdin (e.g. to restart nodemon with `rs\\n`). -- **stop** — Kill a running session. -- **list** — Show all currently active sessions and their status. +- **send** — Send a line of input to the process stdin (e.g. `rs\\n` to restart nodemon). +- **stop** — Terminate a session. +- **list** — List all active sessions and their status. +- **reconnect** — Reconnect to orphaned sessions from previous DevOrch runs. -Example workflow: -1. `start` session_id="server" command="npm run dev" -2. `read` session_id="server" # check startup logs -3. `send` session_id="server" input="rs\\n" # restart nodemon -4. `stop` session_id="server" +Key features: +- gui=true on start opens a visible terminal window AND captures output for you to read. +- Sessions get unique names (e.g. 'swift-fox-a3f2') and logs persist in ~/.devorch/sessions/. +- Use for dev servers, scaffolds, or any process you need to monitor or interact with. -Use `open_terminal` instead if you want an interactive visible terminal window. -Use this tool when you need programmatic access to a process (check output, send input).""" +Example workflow: +1. `start` command="npm run dev" gui=true # visible window + AI can read +2. `read` session_id="swift-fox-a3f2" # check startup logs +3. `send` session_id="swift-fox-a3f2" input="rs\\n" # send input +4. `stop` session_id="swift-fox-a3f2" # terminate""" args_schema = TerminalSessionSchema # ── helpers ────────────────────────────────────────────────────────────── @@ -107,15 +263,36 @@ def _get_session(self, session_id: str) -> dict | None: return _SESSIONS.get(session_id) def _session_alive(self, session: dict) -> bool: - return session["process"].poll() is None + proc = session.get("process") + if proc is not None: + return proc.poll() is None + # Orphaned session — check PID directly + pid = session.get("pid", 0) + return _pid_alive(pid) if pid else False + + def _get_return_code(self, session: dict) -> int | None: + proc = session.get("process") + if proc is not None: + return proc.returncode + return None # ── actions ────────────────────────────────────────────────────────────── - def _start(self, session_id: str, command: str) -> str: + def _start(self, session_id: str | None, command: str, gui: bool = False) -> str: + import sys as _sys + + # Auto-generate unique name if not provided + if not session_id: + session_id = _generate_unique_name() + # Ensure uniqueness + with _LOCK: + while session_id in _SESSIONS: + session_id = _generate_unique_name() + with _LOCK: if session_id in _SESSIONS: existing = _SESSIONS[session_id] - if existing["process"].poll() is None: + if self._session_alive(existing): return ( f"Error: Session '{session_id}' is already running. " f"Use stop first or choose a different session_id." @@ -123,10 +300,99 @@ def _start(self, session_id: str, command: str) -> str: # Dead session — clean up and restart del _SESSIONS[session_id] - # Create a temp log file for stdout+stderr - log_fd, log_path = tempfile.mkstemp(prefix=f"devorch_{session_id}_", suffix=".log") - os.close(log_fd) + _ensure_sessions_dir() + + # Create a persistent log file (not temp — survives restarts) + log_path = str(SESSIONS_DIR / f"{session_id}.log") + cwd = os.getcwd() + + if gui: + # GUI mode: run the command inside a real visible terminal + # using `script` to capture output to the log file + # The user gets a fully interactive terminal AND we capture output + script_cmd = f"script -q -f {log_path} -c '{command}'" + + gui_pid = None + try: + if _sys.platform == "win32": + proc = subprocess.Popen( + ["cmd", "/c", "start", "cmd", "/k", command], + cwd=cwd, + ) + gui_pid = proc.pid + elif _sys.platform == "darwin": + escaped_cmd = script_cmd.replace('"', '\\"') + escaped_dir = cwd.replace('"', '\\"') + subprocess.Popen( + [ + "osascript", + "-e", + f'tell app "Terminal" to do script "cd \\"{escaped_dir}\\" && {escaped_cmd}"', + ] + ) + else: + # Linux — try common terminals + for term_cmd in [ + [ + "gnome-terminal", + "--working-directory", + cwd, + "--", + "bash", + "-c", + f"{script_cmd}; exec bash", + ], + ["konsole", "--workdir", cwd, "-e", f"bash -c '{script_cmd}; exec bash'"], + ["xterm", "-e", f"bash -c 'cd \"{cwd}\" && {script_cmd}; exec bash'"], + ]: + try: + proc = subprocess.Popen(term_cmd, cwd=cwd) + gui_pid = proc.pid + break + except FileNotFoundError: + continue + except Exception as e: + return f"Error opening GUI terminal: {e}" + + if gui_pid is None: + # Couldn't find a terminal emulator, try to get PID from script + gui_pid = 0 + + started_at = time.strftime("%Y-%m-%d %H:%M:%S") + + with _LOCK: + _SESSIONS[session_id] = { + "process": None, # GUI process — we don't own the subprocess + "pid": gui_pid, + "log_path": log_path, + "command": command, + "cwd": cwd, + "started_at": started_at, + "gui": True, + } + + # Persist to registry + registry = _load_registry() + registry[session_id] = { + "pid": gui_pid, + "log_path": log_path, + "command": command, + "cwd": cwd, + "started_at": started_at, + "gui": True, + } + _save_registry(registry) + return ( + f"Session '{session_id}' started in visible terminal\n\n" + f"Command: {command}\n" + f"Working dir: {cwd}\n" + f"The user has a fully interactive terminal window.\n" + f"Output is captured — use read with session_id='{session_id}' to see it.\n" + f"NOTE: You cannot send input to GUI sessions. The user types directly in the window." + ) + + # Headless mode: run in background, capture output try: process = subprocess.Popen( command, @@ -134,11 +400,11 @@ def _start(self, session_id: str, command: str) -> str: stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - cwd=os.getcwd(), + cwd=cwd, bufsize=0, # unbuffered for real-time output + preexec_fn=os.setsid, # new process group for clean kill ) except Exception as e: - os.unlink(log_path) return f"Error starting session '{session_id}': {e}" # Background thread streams process output to the log file @@ -150,17 +416,35 @@ def _start(self, session_id: str, command: str) -> str: ) t.start() + started_at = time.strftime("%Y-%m-%d %H:%M:%S") + with _LOCK: _SESSIONS[session_id] = { "process": process, + "pid": process.pid, "log_path": log_path, "command": command, + "cwd": cwd, + "started_at": started_at, } + # Persist to registry + registry = _load_registry() + registry[session_id] = { + "pid": process.pid, + "log_path": log_path, + "command": command, + "cwd": cwd, + "started_at": started_at, + } + _save_registry(registry) + return ( - f"✓ Session '{session_id}' started (PID {process.pid})\n\n" - f"Command: {command}\n\n" - f"Use read action to see output. Use send to send input. Use stop to terminate." + f"Session '{session_id}' started (PID {process.pid})\n\n" + f"Command: {command}\n" + f"Working dir: {cwd}\n\n" + f"Use read action with session_id='{session_id}' to see output.\n" + f"Use send to send input. Use stop to terminate." ) def _read(self, session_id: str, lines: int) -> str: @@ -170,9 +454,13 @@ def _read(self, session_id: str, lines: int) -> str: log_path = session["log_path"] alive = self._session_alive(session) - status = "running" if alive else f"exited (code {session['process'].returncode})" + rc = self._get_return_code(session) + status = "running" if alive else f"exited (code {rc})" if rc is not None else "exited" try: + if not os.path.exists(log_path): + return f"[Session '{session_id}' — {status}]\nNo output yet (log file not found)." + with open(log_path, "rb") as f: content = f.read() @@ -199,7 +487,13 @@ def _send(self, session_id: str, input_text: str) -> str: if not self._session_alive(session): return f"Error: Session '{session_id}' is no longer running." - process = session["process"] + process = session.get("process") + if process is None: + return ( + f"Error: Session '{session_id}' is an orphaned process (PID {session.get('pid')}). " + f"Cannot send input to orphaned processes — only read and stop are available." + ) + if not process.stdin: return f"Error: Session '{session_id}' does not have an open stdin pipe." @@ -207,7 +501,7 @@ def _send(self, session_id: str, input_text: str) -> str: encoded = input_text.encode("utf-8") process.stdin.write(encoded) process.stdin.flush() - return f"✓ Sent to '{session_id}': {repr(input_text)}" + return f"Sent to '{session_id}': {repr(input_text)}" except Exception as e: return f"Error sending input to '{session_id}': {e}" @@ -216,37 +510,53 @@ def _stop(self, session_id: str) -> str: if not session: return f"Error: No session named '{session_id}'." - process = session["process"] if not self._session_alive(session): with _LOCK: _SESSIONS.pop(session_id, None) + # Remove from registry + registry = _load_registry() + registry.pop(session_id, None) + _save_registry(registry) return f"Session '{session_id}' was already stopped." - try: - process.terminate() - try: - process.wait(timeout=5) - except subprocess.TimeoutExpired: - process.kill() - process.wait() + process = session.get("process") + pid = session.get("pid", 0) - # Clean up log file - log_path = session.get("log_path", "") - try: - if log_path and os.path.exists(log_path): - os.unlink(log_path) - except Exception: - pass + try: + if process is not None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + elif pid: + # Orphaned process — send signal directly + try: + os.killpg(os.getpgid(pid), signal.SIGTERM) + except (OSError, ProcessLookupError): + try: + os.kill(pid, signal.SIGTERM) + except (OSError, ProcessLookupError): + pass with _LOCK: _SESSIONS.pop(session_id, None) - return f"✓ Session '{session_id}' stopped." + # Remove from registry (but keep log file for reference) + registry = _load_registry() + registry.pop(session_id, None) + _save_registry(registry) + + return f"Session '{session_id}' stopped." except Exception as e: return f"Error stopping session '{session_id}': {e}" def _list(self) -> str: + # Also check for any orphaned sessions we haven't loaded yet + _reconnect_orphaned_sessions() + with _LOCK: sessions = dict(_SESSIONS) @@ -255,17 +565,41 @@ def _list(self) -> str: lines = ["Active sessions:\n"] for sid, s in sessions.items(): - alive = s["process"].poll() is None - status = "● running" if alive else f"✗ exited ({s['process'].returncode})" - lines.append(f" {sid:20s} {status:20s} {s['command']}") + alive = self._session_alive(s) + rc = self._get_return_code(s) + status = "running" if alive else f"exited ({rc})" if rc is not None else "exited" + icon = "●" if alive else "✗" + orphan = " (orphaned)" if s.get("process") is None and alive else "" + started = s.get("started_at", "") + lines.append( + f" {sid:25s} {icon} {status:15s} {orphan}" + f"\n cmd: {s['command']}" + f"\n pid: {s.get('pid', '?')} started: {started}" + f"\n cwd: {s.get('cwd', '?')}\n" + ) return "\n".join(lines) + def _reconnect(self) -> str: + """Force reconnection to orphaned sessions.""" + _reconnect_orphaned_sessions() + with _LOCK: + sessions = dict(_SESSIONS) + + if not sessions: + return "No sessions found (active or orphaned)." + + alive_count = sum(1 for s in sessions.values() if self._session_alive(s)) + return ( + f"Reconnected. Found {len(sessions)} session(s), {alive_count} still running.\n" + f"Use 'list' to see details." + ) + # ── dispatch ───────────────────────────────────────────────────────────── def run(self, arguments: dict[str, Any]) -> Any: action = (arguments.get("action") or "").lower().strip() - session_id = (arguments.get("session_id") or "").strip() + session_id = (arguments.get("session_id") or "").strip() or None command = arguments.get("command", "") input_text = arguments.get("input", "") lines = int(arguments.get("lines") or 50) @@ -273,15 +607,19 @@ def run(self, arguments: dict[str, Any]) -> Any: if action == "list": return self._list() - if not session_id: - return "Error: session_id is required for this action." + if action == "reconnect": + return self._reconnect() if action == "start": if not command: - return "Error: command is required for 'start'." - return self._start(session_id, command) + command = "bash" + gui = bool(arguments.get("gui", False)) + return self._start(session_id, command, gui=gui) + + if not session_id: + return "Error: session_id is required for this action." - elif action == "read": + if action == "read": return self._read(session_id, lines) elif action == "send": @@ -294,5 +632,6 @@ def run(self, arguments: dict[str, Any]) -> Any: else: return ( - f"Error: Unknown action '{action}'. Valid actions: start, read, send, stop, list." + f"Error: Unknown action '{action}'. " + f"Valid actions: start, read, send, stop, list, reconnect." ) diff --git a/utils/logger.py b/utils/logger.py index aa4048d..31f7325 100644 --- a/utils/logger.py +++ b/utils/logger.py @@ -1,6 +1,7 @@ import logging from rich.console import Console +from rich.markdown import Markdown from rich.panel import Panel # Global rich console instance @@ -21,22 +22,41 @@ def setup_logger(name: str) -> logging.Logger: def print_error(msg: str): """Prints an error message in bold red.""" - console.print(f"[bold red]Error: {msg}[/bold red]") + console.print(f"[bold red] Error: {msg}[/bold red]") def print_warning(msg: str): """Prints a warning message in bold yellow.""" - console.print(f"[bold yellow]Warning: {msg}[/bold yellow]") + console.print(f"[bold yellow] Warning: {msg}[/bold yellow]") def print_success(msg: str): """Prints a success message in bold green.""" - console.print(f"[bold green]{msg}[/bold green]") + console.print(f"[bold green] {msg}[/bold green]") def print_info(msg: str): """Prints an info message in blue.""" - console.print(f"[blue]{msg}[/blue]") + console.print(f"[blue] {msg}[/blue]") + + +def print_response(content: str): + """Print an AI response with a subtle grey background, no border.""" + console.print() + try: + md = Markdown(content) + console.print( + Panel( + md, + style="on #252540", + border_style="#252540", # invisible border, blends with background + padding=(1, 2), + expand=True, + ) + ) + except Exception: + console.print(f" {content}") + console.print() def print_panel(content, title: str = "", border_style: str = "blue", fit: bool = False):