diff --git a/README.md b/README.md index af2848371..aa4683031 100644 --- a/README.md +++ b/README.md @@ -1,724 +1,488 @@ -# Hermes Agent +# Hermes Agent 🦋 -An AI agent with advanced tool-calling capabilities, featuring a flexible toolsets system for organizing and managing tools. +An AI agent with advanced tool-calling capabilities, featuring a flexible toolsets system, messaging integrations, and scheduled tasks. + +## Quick Install + +**Linux/macOS:** +```bash +curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +``` + +**Windows (PowerShell):** +```powershell +irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex +``` + +The installer will: +- Clone to `~/.hermes-agent` +- Create a virtual environment +- Install all dependencies +- Run the interactive setup wizard +- Add `hermes` to your PATH + +After installation, reload your shell and run: +```bash +hermes setup # Configure API keys (if you skipped during install) +hermes # Start chatting! +``` + +--- + +## Configuration + +All your settings are stored in `~/.hermes/` for easy access: + +``` +~/.hermes/ +├── config.yaml # Settings (model, terminal, compression, etc.) +├── .env # API keys and secrets +├── cron/ # Scheduled jobs +├── sessions/ # Gateway sessions +└── logs/ # Logs +``` + +### Managing Configuration + +```bash +hermes config # View current configuration +hermes config edit # Open config.yaml in your editor +hermes config set KEY VAL # Set a specific value + +# Examples: +hermes config set model anthropic/claude-opus-4 +hermes config set terminal.backend docker +hermes config set OPENROUTER_API_KEY sk-or-... # Saves to .env +``` + +### Required API Keys + +You need at least one LLM provider: + +| Provider | Get Key | Env Variable | +|----------|---------|--------------| +| **OpenRouter** (recommended) | [openrouter.ai/keys](https://openrouter.ai/keys) | `OPENROUTER_API_KEY` | +| Anthropic | [console.anthropic.com](https://console.anthropic.com/) | `ANTHROPIC_API_KEY` | +| OpenAI | [platform.openai.com](https://platform.openai.com/api-keys) | `OPENAI_API_KEY` | + +### Optional API Keys + +| Feature | Provider | Env Variable | +|---------|----------|--------------| +| Web scraping | [Firecrawl](https://firecrawl.dev/) | `FIRECRAWL_API_KEY` | +| Browser automation | [Browserbase](https://browserbase.com/) | `BROWSERBASE_API_KEY`, `BROWSERBASE_PROJECT_ID` | +| Image generation | [FAL](https://fal.ai/) | `FAL_KEY` | +| Messaging | Telegram, Discord | `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN` | + +--- + +## Commands + +```bash +hermes # Interactive chat (default) +hermes chat -q "Hello" # Single query mode +hermes setup # Configure API keys and settings +hermes config # View/edit configuration +hermes status # Show configuration status +hermes doctor # Diagnose issues +hermes update # Update to latest version +hermes gateway # Start messaging gateway +hermes cron list # View scheduled jobs +hermes version # Show version info +``` + +### CLI Commands (inside chat) + +| Command | Description | +|---------|-------------| +| `/help` | Show available commands | +| `/tools` | List available tools | +| `/model [name]` | Show or change model | +| `/personality [name]` | Set personality (kawaii, pirate, etc.) | +| `/clear` | Clear screen and reset | +| `/cron` | Manage scheduled tasks | +| `/config` | Show current configuration | +| `/quit` | Exit | + +--- ## Features -- **Interactive CLI**: Beautiful terminal interface with animated feedback, personalities, and session management -- **Web Tools**: Search, extract content, and crawl websites -- **Terminal Tools**: Execute commands via local, Docker, Singularity, Modal, or SSH backends -- **Browser Tools**: Automate web browsers to navigate, click, type, and extract content -- **Vision Tools**: Analyze images from URLs -- **Reasoning Tools**: Advanced multi-model reasoning (Mixture of Agents) -- **Creative Tools**: Generate images from text prompts -- **Skills Tools**: On-demand knowledge documents with progressive disclosure -- **Toolsets System**: Organize tools into logical groups for different scenarios -- **Batch Processing**: Process datasets in parallel with checkpointing and statistics tracking -- **Ephemeral System Prompts**: Guide model behavior without polluting training datasets +### 🛠️ Tools & Toolsets -## Quick Start (CLI) +Tools are organized into logical **toolsets**: ```bash -# After setup (see below), just run: -./hermes +# Use specific toolsets +hermes --toolsets "web,terminal" -# Or with options: -./hermes --model "anthropic/claude-sonnet-4" --toolsets "web,terminal" +# List all toolsets +hermes --list-tools ``` -The CLI provides: -- Animated spinners during thinking and tool execution -- Kawaii-style feedback messages -- `/commands` for configuration, history, and session management -- Customizable personalities (`/personality kawaii`, `/personality pirate`, etc.) -- Persistent configuration via `cli-config.yaml` +**Available toolsets:** `web`, `terminal`, `browser`, `vision`, `creative`, `reasoning`, `skills`, `cronjob`, and more. -## Setup +### 🖥️ Terminal Backend -### 1. Clone the Repository -```bash -# Clone with submodules (recommended) -git clone --recurse-submodules https://github.com/NousResearch/Hermes-Agent.git -cd Hermes-Agent +The terminal tool can execute commands in different environments: -# Or if already cloned without submodules: -git submodule update --init --recursive +| Backend | Description | Use Case | +|---------|-------------|----------| +| `local` | Run on your machine (default) | Development, trusted tasks | +| `docker` | Isolated containers | Security, reproducibility | +| `ssh` | Remote server | Sandboxing, keep agent away from its own code | +| `singularity` | HPC containers | Cluster computing, rootless | +| `modal` | Cloud execution | Serverless, scale | + +**Configure in `~/.hermes/config.yaml`:** +```yaml +terminal: + backend: local # or: docker, ssh, singularity, modal + cwd: "." # Working directory ("." = current dir) + timeout: 180 # Command timeout in seconds ``` -### 2. Install Dependencies -```bash -# Create and activate virtual environment (recommended) -python3 -m venv venv -source venv/bin/activate # On Windows: venv\Scripts\activate - -# Install Python packages -pip install -r requirements.txt - -# Install mini-swe-agent for terminal tools -pip install -e ./mini-swe-agent - -# Install Node.js dependencies for browser tools (requires Node.js) -npm install +**Docker Backend:** +```yaml +terminal: + backend: docker + docker_image: python:3.11-slim ``` -### 3. Configure Environment Variables -```bash -# Copy the example environment file -cp .env.example .env - -# Edit .env and add your API keys -nano .env # or use your preferred editor +**SSH Backend** (recommended for security - agent can't modify its own code): +```yaml +terminal: + backend: ssh ``` - -**Required API Keys:** -- `OPENROUTER_API_KEY` - LLM access via OpenRouter (get at: https://openrouter.ai/keys) -- `FIRECRAWL_API_KEY` - Web tools (get at: https://firecrawl.dev/) -- `NOUS_API_KEY` - Vision & reasoning tools (get at: https://inference-api.nousresearch.com/) -- `FAL_KEY` - Image generation (get at: https://fal.ai/) - -**Optional API Keys (for specific features):** -- `BROWSERBASE_API_KEY` - Browser automation (get at: https://browserbase.com/) -- `BROWSERBASE_PROJECT_ID` - From Browserbase dashboard -- `MORPH_API_KEY` - For legacy Hecate terminal backend (get at: https://morph.so/) - -### 4. Configure Terminal Backend - -The terminal tool uses **mini-swe-agent** environments. Configure in `.env` or `cli-config.yaml`: - ```bash -# Backend: "local", "docker", "singularity", "modal", or "ssh" -TERMINAL_ENV=local # Default: runs on host machine (no isolation) -TERMINAL_ENV=ssh # Remote execution via SSH (agent code stays local) -TERMINAL_ENV=singularity # Recommended for HPC: Apptainer/Singularity containers -TERMINAL_ENV=docker # Isolated Docker containers -TERMINAL_ENV=modal # Cloud execution via Modal - -# Container image (for docker/singularity/modal backends) -TERMINAL_DOCKER_IMAGE=python:3.11-slim -TERMINAL_SINGULARITY_IMAGE=docker://python:3.11-slim -TERMINAL_TIMEOUT=60 - -# SSH backend (for ssh) +# Set credentials in ~/.hermes/.env TERMINAL_SSH_HOST=my-server.example.com TERMINAL_SSH_USER=myuser -TERMINAL_SSH_KEY=~/.ssh/id_rsa # Optional, uses ssh-agent if not set +TERMINAL_SSH_KEY=~/.ssh/id_rsa ``` -**Backend Requirements:** -- **local**: No extra setup (runs directly on your machine, no isolation) -- **ssh**: SSH access to remote machine (great for sandboxing - agent can't touch its own code) -- **singularity**: Requires Apptainer or Singularity installed (common on HPC clusters, no root needed) -- **docker**: Requires Docker installed and user in `docker` group -- **modal**: Requires Modal account (see setup below) - -### Singularity/Apptainer Setup (Recommended for HPC) - -Singularity/Apptainer provides rootless container execution, ideal for HPC clusters: - +**Singularity/Apptainer** (for HPC clusters): ```bash -# 1. Verify Apptainer is installed -apptainer --version # or: singularity --version +# Pre-build SIF for parallel workers +apptainer build ~/python.sif docker://python:3.11-slim -# 2. Set up cache directories (important for parallel workers) -# Use /scratch if available (HPC), otherwise /tmp -export APPTAINER_CACHEDIR=/scratch/$USER/.apptainer -export APPTAINER_TMPDIR=/scratch/$USER/.apptainer/tmp -mkdir -p "$APPTAINER_CACHEDIR" "$APPTAINER_TMPDIR" - -# 3. Pre-build SIF image (recommended for parallel batch processing) -# This avoids race conditions when multiple workers start simultaneously -apptainer build $APPTAINER_CACHEDIR/python-nodejs.sif docker://nikolaik/python-nodejs:python3.11-nodejs20 - -# 4. Configure .env to use the local SIF -TERMINAL_ENV=singularity -TERMINAL_SINGULARITY_IMAGE=/scratch/$USER/.apptainer/python-nodejs.sif +# Configure +hermes config set terminal.backend singularity +hermes config set terminal.singularity_image ~/python.sif ``` -**Tip:** The batch scripts in `configs/` automatically handle SIF pre-building if `/scratch` is available. - -### Modal Cloud Backend Setup - -[Modal](https://modal.com) provides serverless cloud compute for running sandboxed environments at scale. - +**Modal** (serverless cloud): ```bash -# 1. Install Modal and dependencies pip install modal boto3 - -# 2. Authenticate with Modal (opens browser) -modal setup - -# 3. Set terminal backend to modal in .env -TERMINAL_ENV=modal +modal setup # Authenticate +hermes config set terminal.backend modal ``` -Modal uses CLI-based authentication (stored in `~/.modal/`), so no API key is needed in `.env`. After running `modal setup`, commands will automatically execute in Modal's cloud sandboxes. +**Sudo Support:** If a command needs sudo, you'll be prompted for your password (cached for the session). Or set `SUDO_PASSWORD` in `~/.hermes/.env`. -### Browser Tools Setup +### 📱 Messaging Gateway -Browser tools enable the agent to navigate websites, fill forms, click buttons, and extract content. They use [agent-browser](https://github.com/vercel-labs/agent-browser) CLI with [Browserbase](https://browserbase.com) cloud execution. +Chat with Hermes from Telegram, Discord, or WhatsApp: ```bash -# 1. Install Node.js (if not already installed) -# Use nvm (recommended) or your package manager +# Configure your bot token +hermes config set TELEGRAM_BOT_TOKEN "your_token" -# 2. Install agent-browser CLI (choose one option): -npm install -g agent-browser # Option A: Global install (recommended) -npm install # Option B: Local install (uses npx fallback) +# Start the gateway +hermes gateway -# 3. Get Browserbase credentials -# Sign up at https://browserbase.com/ and get your: -# - API Key (from Settings → API Keys) -# - Project ID (from your project dashboard) - -# 4. Add to your .env file: -BROWSERBASE_API_KEY=your_api_key_here -BROWSERBASE_PROJECT_ID=your_project_id_here +# Or install as a service +hermes gateway install +hermes gateway start ``` -**Available Browser Tools:** +See [docs/messaging.md](docs/messaging.md) for full setup. -| Tool | Description | -|------|-------------| -| `browser_navigate` | Navigate to a URL | -| `browser_snapshot` | Get text-based page snapshot with element refs | -| `browser_click` | Click an element by ref (e.g., `@e5`) | -| `browser_type` | Type text into an input field | -| `browser_scroll` | Scroll up or down | -| `browser_back` | Go back in browser history | -| `browser_press` | Press a keyboard key (Enter, Tab, etc.) | -| `browser_close` | Close the browser session | -| `browser_get_images` | Get list of images on the page | +### ⏰ Scheduled Tasks (Cron) + +Schedule tasks to run automatically: -**Example Usage:** ```bash -# Use browser tools with web search and vision -python run_agent.py \ - --query "Go to amazon.com and find the price of the latest Kindle" \ - --enabled_toolsets=browser,web,vision - -# Use browser-focused distribution -python batch_runner.py \ - --dataset_file=browser_tasks.jsonl \ - --distribution=browser_use \ - --run_name=browser_run +# In the CLI +/cron add 30m "Remind me to check the build" +/cron add "every 2h" "Check server status" +/cron add "0 9 * * *" "Morning briefing" +/cron list +/cron remove ``` -See `.env.example` for all available configuration options including debug settings. +The agent can also self-schedule using `schedule_cronjob` tool. -### Skills Tools - -Skills are on-demand knowledge documents the agent can load when needed. They follow a **progressive disclosure** pattern to minimize token usage: - -``` -skills/ -├── mlops/ # Category folder -│ ├── axolotl/ # Skill folder -│ │ ├── SKILL.md # Main instructions (required) -│ │ ├── references/ # Additional docs, API specs -│ │ └── templates/ # Output formats, configs -│ └── vllm/ -│ └── SKILL.md -``` - -**Available Skills Tools:** - -| Tool | Description | -|------|-------------| -| `skills_categories` | List available skill categories (~50 tokens) | -| `skills_list` | List skills with name + description (~3k tokens for 40 skills) | -| `skill_view` | Load full skill content, tags, and linked files | - -**Example Usage:** +**Run the scheduler:** ```bash -# Use skills tools -python run_agent.py \ - --query "What skills do you have for fine-tuning? Show me the axolotl skill." \ - --enabled_toolsets=skills +hermes cron daemon # Built-in daemon +# Or add to system cron for reliability +``` + +### 🗜️ Context Compression + +Long conversations are automatically summarized when approaching context limits: + +```yaml +# In ~/.hermes/config.yaml +compression: + enabled: true + threshold: 0.85 # Compress at 85% of limit +``` + +### 📝 Session Logging + +Every conversation is logged to `~/.hermes-agent/logs/` for debugging: + +``` +logs/ +├── session_20260201_143052_a1b2c3.json +└── ... +``` + +### 🌐 Browser Automation + +Browser tools let the agent navigate websites, fill forms, click buttons, and extract content using [Browserbase](https://browserbase.com/). + +**Setup:** +```bash +# 1. Get credentials from browserbase.com +hermes config set BROWSERBASE_API_KEY your_api_key +hermes config set BROWSERBASE_PROJECT_ID your_project_id + +# 2. Install Node.js dependencies (if not already) +cd ~/.hermes-agent && npm install +``` + +**Available tools:** `browser_navigate`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_scroll`, `browser_back`, `browser_press`, `browser_close`, `browser_get_images` + +**Example:** +```bash +hermes --toolsets browser -q "Go to amazon.com and find the price of the latest Kindle" +``` + +### 📚 Skills System + +Skills are on-demand knowledge documents the agent can load when needed. They follow a **progressive disclosure** pattern to minimize token usage. + +**Using Skills:** +```bash +hermes --toolsets skills -q "What skills do you have?" +hermes --toolsets skills -q "Show me the axolotl skill" ``` **Creating Skills:** -Skills use YAML frontmatter for metadata: -```yaml +Create `skills/category/skill-name/SKILL.md`: +```markdown --- name: my-skill description: Brief description shown in skills_list -tags: [tag1, tag2] -related_skills: [other-skill] +tags: [python, automation] version: 1.0.0 --- + # Skill Content Instructions, examples, and guidelines here... ``` -Skills can include: -- `references/` - Additional documentation, API specs, examples -- `templates/` - Output formats, config files, boilerplate code -- `scripts/` - Executable helpers (Python, shell scripts) - -## Session Logging - -Every conversation is automatically logged to `logs/` for debugging and inspection: - +**Skill Structure:** ``` -logs/ -├── session_20260201_143052_a1b2c3.json -├── session_20260201_150217_d4e5f6.json -└── ... +skills/ +├── mlops/ +│ ├── axolotl/ +│ │ ├── SKILL.md # Main instructions (required) +│ │ ├── references/ # Additional docs +│ │ └── templates/ # Output formats +│ └── vllm/ +│ └── SKILL.md ``` -**Log Format:** -```json -{ - "session_id": "20260201_143052_a1b2c3", - "model": "anthropic/claude-sonnet-4", - "session_start": "2026-02-01T14:30:52.123456", - "last_updated": "2026-02-01T14:35:12.789012", - "message_count": 8, - "conversations": [ - {"from": "system", "value": "..."}, - {"from": "human", "value": "..."}, - {"from": "gpt", "value": "..."}, - {"from": "tool", "value": "..."} - ] -} -``` +--- -- **Automatic**: Logs are created and updated automatically after each conversation turn -- **Session ID in Banner**: The CLI displays the session ID in the welcome banner -- **Trajectory Format**: Uses the same format as batch processing for consistency -- **Git Ignored**: `logs/` is in `.gitignore` so logs aren't committed +## Manual Installation -## Context Compression - -Long conversations can exceed the model's context limit. Hermes Agent automatically compresses context when approaching the limit: - -**How it works:** -1. Tracks actual token usage from API responses (`usage.prompt_tokens`) -2. When tokens reach 85% of model's context limit, triggers compression -3. Protects first 3 turns (system prompt, initial request, first response) -4. Protects last 4 turns (recent context is most relevant) -5. Summarizes middle turns using a fast/cheap model (Gemini Flash) -6. Inserts summary as a user message, conversation continues seamlessly - -**Configuration (`cli-config.yaml`):** -```yaml -compression: - enabled: true # Enable auto-compression (default) - threshold: 0.85 # Compress at 85% of context limit - summary_model: "google/gemini-2.0-flash-001" -``` - -**Or via environment variables:** -```bash -CONTEXT_COMPRESSION_ENABLED=true -CONTEXT_COMPRESSION_THRESHOLD=0.85 -CONTEXT_COMPRESSION_MODEL=google/gemini-2.0-flash-001 -``` - -**When compression triggers, you'll see:** -``` -📦 Context compression triggered (170,000 tokens ≥ 170,000 threshold) - 📊 Model context limit: 200,000 tokens (85% = 170,000) - 🗜️ Summarizing turns 4-15 (12 turns) - ✅ Compressed: 20 → 9 messages (~45,000 tokens saved) -``` - -## Scheduled Tasks (Cron Jobs) - -Hermes Agent can schedule automated tasks to run in the future - either one-time reminders or recurring jobs. - -### CLI Commands +If you prefer not to use the installer: ```bash -# List scheduled jobs -/cron +# Clone the repository +git clone --recurse-submodules https://github.com/NousResearch/hermes-agent.git +cd hermes-agent -# Add a one-shot reminder (runs once in 30 minutes) -/cron add 30m Remind me to check the build status +# Run setup script +./setup-hermes.sh -# Add a recurring job (every 2 hours) -/cron add "every 2h" Check server status at 192.168.1.100 and report any issues - -# Add a cron expression (daily at 9am) -/cron add "0 9 * * *" Generate a morning briefing summarizing GitHub notifications - -# Remove a job -/cron remove abc123def456 +# Or manually: +python3 -m venv venv +source venv/bin/activate +pip install -e ".[all]" +hermes setup ``` -### Agent Self-Scheduling - -The agent can also schedule its own follow-up tasks using tools: - -```python -# Available when using hermes-cli toolset (default for CLI) -schedule_cronjob(prompt="...", schedule="30m", repeat=1) # One-shot -schedule_cronjob(prompt="...", schedule="every 2h") # Recurring -list_cronjobs() # View all jobs -remove_cronjob(job_id="...") # Cancel a job -``` - -**⚠️ Important:** Cronjobs run in **isolated sessions with NO prior context**. The prompt must be completely self-contained with all necessary information (file paths, URLs, server addresses, etc.). The future agent will not remember anything from the current conversation. - -### Schedule Formats - -| Format | Example | Description | -|--------|---------|-------------| -| Duration | `30m`, `2h`, `1d` | One-shot delay from now | -| Interval | `every 30m`, `every 2h` | Recurring at fixed intervals | -| Cron | `0 9 * * *` | Cron expression (requires `croniter`) | -| Timestamp | `2026-02-03T14:00` | One-shot at specific time | - -### Repeat Options - -| repeat | Behavior | -|--------|----------| -| (omitted) | One-shot schedules run once; intervals/cron run forever | -| `1` | Run once then auto-delete | -| `N` | Run N times then auto-delete | - -### Running the Cron Daemon - -Jobs are stored in `~/.hermes/cron/jobs.json` and executed by a scheduler: - -```bash -# Option 1: Built-in daemon (checks every 60 seconds) -python cli.py --cron-daemon - -# Option 2: System cron integration (run once per minute) -# Add to crontab: crontab -e -*/1 * * * * cd ~/hermes-agent && python cli.py --cron-tick-once >> ~/.hermes/cron/cron.log 2>&1 -``` - -### Job Output - -Job outputs are saved to `~/.hermes/cron/output/{job_id}/{timestamp}.md` for review. - -## Interactive CLI - -The CLI provides a rich interactive experience for working with the agent. - -### Running the CLI - -```bash -# Basic usage -./hermes - -# With specific model -./hermes --model "anthropic/claude-sonnet-4" - -# With specific toolsets -./hermes --toolsets "web,terminal,skills" -``` - -### CLI Commands - -| Command | Description | -|---------|-------------| -| `/help` | Show available commands | -| `/tools` | List available tools by toolset | -| `/toolsets` | List available toolsets | -| `/model [name]` | Show or change the current model | -| `/prompt [text]` | View/set custom system prompt | -| `/personality [name]` | Set a predefined personality | -| `/clear` | Clear screen and reset conversation | -| `/reset` | Reset conversation only | -| `/history` | Show conversation history | -| `/save` | Save current conversation to file | -| `/config` | Show current configuration | -| `/cron` | Manage scheduled tasks (list, add, remove) | -| `/quit` | Exit the CLI | - -### Configuration - -Copy `cli-config.yaml.example` to `cli-config.yaml` and customize: - -```yaml -# Model settings -model: - default: "anthropic/claude-sonnet-4" - -# Terminal backend (local, docker, singularity, modal, or ssh) -terminal: - env_type: "local" - cwd: "." # Use current directory - -# Or use SSH for remote execution (keeps agent code isolated) -# terminal: -# env_type: "ssh" -# ssh_host: "my-server.example.com" -# ssh_user: "myuser" -# ssh_key: "~/.ssh/id_rsa" -# cwd: "/home/myuser/project" - -# Enable specific toolsets -toolsets: - - all # or: web, terminal, browser, vision, etc. - -# Custom personalities (use with /personality command) -agent: - personalities: - helpful: "You are a helpful assistant." - kawaii: "You are a kawaii assistant! Use cute expressions..." -``` - -### Personalities - -Built-in personalities available via `/personality`: -- `helpful`, `concise`, `technical`, `creative`, `teacher` -- `kawaii`, `catgirl`, `pirate`, `shakespeare`, `surfer` -- `noir`, `uwu`, `philosopher`, `hype` - -## Toolsets System - -The agent uses a toolsets system for organizing and managing tools. All tools must be part of a toolset to be accessible - individual tool selection is not supported. This ensures consistent and logical grouping of capabilities. - -### Key Concepts - -- **Toolsets**: Logical groups of tools for specific use cases (e.g., "research", "development", "debugging") -- **Composition**: Toolsets can include other toolsets for powerful combinations -- **Custom Toolsets**: Create your own toolsets at runtime or by editing `toolsets.py` -- **Toolset-Only Access**: Tools are only accessible through toolsets, not individually - -### Available Toolsets - -See `toolsets.py` for the complete list of predefined toolsets including: -- Basic toolsets (web, terminal, vision, creative, reasoning) -- Composite toolsets (research, development, analysis, etc.) -- Scenario-specific toolsets (debugging, documentation, API testing, etc.) -- Special toolsets (safe mode without terminal, minimal, offline) - -### Using Toolsets - -```bash -# Use a predefined toolset -python run_agent.py --enabled_toolsets=research --query "Find latest AI papers" - -# Combine multiple toolsets -python run_agent.py --enabled_toolsets=web,vision --query "Analyze this website" - -# Enable all toolsets explicitly (same as omitting the flag) -python run_agent.py --enabled_toolsets=all --query "Do web research and run commands if helpful" - -# Safe mode (no terminal access) -python run_agent.py --enabled_toolsets=safe --query "Help without running commands" - -# List all available toolsets and tools -python run_agent.py --list_tools -``` - -See `toolsets.py` for the complete list of available toolsets and how to create custom ones. - -## Basic Usage - -### Default (all tools enabled) -```bash -# Uses OpenRouter by default - just set OPENROUTER_API_KEY in .env -python run_agent.py \ - --query "search up the latest docs on jit in python 3.13 and write me basic example that's not in their docs. profile its perf" \ - --max_turns 20 \ - --model anthropic/claude-sonnet-4-20250514 -``` - -### With specific toolset -```bash -python run_agent.py \ - --query "Debug this Python error" \ - --enabled_toolsets=debugging \ - --model anthropic/claude-sonnet-4-20250514 -``` - -### Python API -```python -from run_agent import AIAgent - -# Uses OpenRouter by default (reads OPENROUTER_API_KEY from .env) -agent = AIAgent( - model="anthropic/claude-sonnet-4-20250514", - enabled_toolsets=["research"] -) -response = agent.chat("Find information about quantum computing") - -# Create custom toolset at runtime -from toolsets import create_custom_toolset - -create_custom_toolset( - name="my_tools", - description="My custom toolkit", - tools=["web_search"], - includes=["terminal", "vision"] -) - -agent = AIAgent(enabled_toolsets=["my_tools"]) -``` +--- ## Batch Processing -Process multiple prompts from a dataset in parallel with automatic checkpointing and statistics tracking: +Process multiple prompts in parallel with automatic checkpointing: ```bash -# Basic batch processing python batch_runner.py \ --dataset_file=prompts.jsonl \ --batch_size=20 \ - --run_name=my_run - -# With specific distribution -python batch_runner.py \ - --dataset_file=prompts.jsonl \ - --batch_size=20 \ - --run_name=image_run \ - --distribution=image_gen \ - --num_workers=4 + --run_name=my_run \ + --num_workers=4 \ + --distribution=default ``` -**Key Features:** -- Parallel processing with configurable workers -- Toolset distributions for varied data generation -- Automatic checkpointing and resume capability -- Combined output in `data//trajectories.jsonl` -- Tool usage statistics and success rates +**Key Options:** +| Flag | Description | +|------|-------------| +| `--dataset_file` | JSONL file with prompts | +| `--batch_size` | Prompts per batch | +| `--run_name` | Name for output/checkpoints | +| `--num_workers` | Parallel workers (default: 4) | +| `--distribution` | Toolset distribution | +| `--resume` | Resume from checkpoint | +| `--ephemeral_system_prompt` | Guide behavior without saving to trajectories | +| `--list_distributions` | Show available distributions | -Use `--list_distributions` to see available toolset distributions for varied data generation. +**Output:** `data//trajectories.jsonl` ### Trajectory Compression -Post-process trajectories to fit within token budgets for training: +Compress trajectories to fit token budgets for training: ```bash -# Compress a directory of JSONL files +# Compress a directory python trajectory_compressor.py --input=data/my_run -# Compress a single JSONL file -python trajectory_compressor.py --input=data/trajectories.jsonl +# Compress with sampling +python trajectory_compressor.py --input=data/my_run --sample_percent=15 -# Compress a 15% sample (useful for creating smaller training sets) -python trajectory_compressor.py --input=data/trajectories.jsonl --sample_percent=15 - -# Custom output and token target -python trajectory_compressor.py \ - --input=data/trajectories.jsonl \ - --output=data/compressed.jsonl \ - --target_max_tokens=16000 +# Custom token target +python trajectory_compressor.py --input=data/my_run --target_max_tokens=16000 ``` -**Features:** -- Protects first turns (system, human, first GPT response, first tool call) -- Protects last N turns (configurable) -- Summarizes middle turns using LLM to fit target token budget -- Supports both directory and single file input -- Optional random sampling with `--sample_percent` +Features: +- Protects first/last turns +- Summarizes middle turns via LLM - Configurable via `configs/trajectory_compression.yaml` -### Ephemeral System Prompts +--- -The ephemeral system prompt feature allows you to guide the model's behavior during batch processing **without** saving that prompt to the training dataset trajectories. This is useful for: +## Python API -- Guiding model behavior during data collection -- Adding task-specific instructions -- Keeping saved trajectories clean and focused on tool-calling format +```python +from run_agent import AIAgent -**Example:** -```bash -python batch_runner.py \ - --dataset_file=prompts.jsonl \ - --batch_size=10 \ - --run_name=my_run \ - --ephemeral_system_prompt="You are a helpful assistant focused on image generation." +agent = AIAgent( + model="anthropic/claude-sonnet-4", + enabled_toolsets=["web", "terminal"] +) + +result = agent.run_conversation("Search for the latest Python news") +print(result["final_response"]) ``` -The ephemeral prompt will influence the model's behavior during execution, but **only the standard tool-calling system prompt** will be saved in the trajectory files. +--- -The ephemeral prompt influences model behavior during execution, but **only the standard tool-calling system prompt** is saved in trajectory files. +## Environment Variables Reference -## Command Line Arguments +All variables go in `~/.hermes/.env`. Run `hermes config set VAR value` to set them. -**Single Agent (`run_agent.py`):** -- `--query`: The question or task for the agent -- `--model`: Model to use (default: claude-opus-4-20250514) -- `--api_key`: API key for authentication -- `--base_url`: API endpoint URL -- `--max_turns`: Maximum number of tool-calling iterations -- `--enabled_toolsets`: Comma-separated list of toolsets to enable. Use `all` (or `*`) to enable everything. If omitted, all toolsets are enabled by default. -- `--disabled_toolsets`: Comma-separated list of toolsets to disable -- `--list_tools`: List all available toolsets and tools -- `--save_trajectories`: Save conversation trajectories to JSONL files +**LLM Providers:** +| Variable | Description | +|----------|-------------| +| `OPENROUTER_API_KEY` | OpenRouter API key (recommended) | +| `ANTHROPIC_API_KEY` | Direct Anthropic access | +| `OPENAI_API_KEY` | Direct OpenAI access | -**Batch Processing (`batch_runner.py`):** -- `--dataset_file`: Path to JSONL file with prompts -- `--batch_size`: Number of prompts per batch -- `--run_name`: Name for this run (for output/checkpointing) -- `--distribution`: Toolset distribution to use (default: "default") -- `--num_workers`: Number of parallel workers (default: 4) -- `--resume`: Resume from checkpoint if interrupted -- `--ephemeral_system_prompt`: System prompt used during execution but NOT saved to trajectories -- `--list_distributions`: List available toolset distributions +**Tool APIs:** +| Variable | Description | +|----------|-------------| +| `FIRECRAWL_API_KEY` | Web scraping (firecrawl.dev) | +| `BROWSERBASE_API_KEY` | Browser automation | +| `BROWSERBASE_PROJECT_ID` | Browserbase project | +| `FAL_KEY` | Image generation (fal.ai) | -## Environment Variables +**Terminal Backend:** +| Variable | Description | +|----------|-------------| +| `TERMINAL_ENV` | Backend: `local`, `docker`, `ssh`, `singularity`, `modal` | +| `TERMINAL_DOCKER_IMAGE` | Docker image (default: `python:3.11-slim`) | +| `TERMINAL_SINGULARITY_IMAGE` | Singularity image or `.sif` path | +| `TERMINAL_TIMEOUT` | Command timeout in seconds | +| `TERMINAL_CWD` | Working directory | +| `SUDO_PASSWORD` | Enable sudo (stored plaintext - be careful!) | -All environment variables can be configured in the `.env` file (copy from `.env.example`). +**SSH Backend:** +| Variable | Description | +|----------|-------------| +| `TERMINAL_SSH_HOST` | Remote server hostname | +| `TERMINAL_SSH_USER` | SSH username | +| `TERMINAL_SSH_PORT` | SSH port (default: 22) | +| `TERMINAL_SSH_KEY` | Path to private key | -**LLM Provider (OpenRouter):** -- `OPENROUTER_API_KEY`: Primary LLM access via OpenRouter (supports Claude, GPT-4, Gemini, etc.) -- `LLM_MODEL`: Default model (e.g., `anthropic/claude-sonnet-4`, `openai/gpt-4o`) +**Messaging:** +| Variable | Description | +|----------|-------------| +| `TELEGRAM_BOT_TOKEN` | Telegram bot token (@BotFather) | +| `TELEGRAM_HOME_CHANNEL` | Default channel for cron delivery | +| `DISCORD_BOT_TOKEN` | Discord bot token | +| `DISCORD_HOME_CHANNEL` | Default channel for cron delivery | -**Tool API Keys:** -- `FIRECRAWL_API_KEY`: Web tools (search, extract, crawl) -- `NOUS_API_KEY`: Vision and reasoning tools -- `FAL_KEY`: Image generation tools +**Context Compression:** +| Variable | Description | +|----------|-------------| +| `CONTEXT_COMPRESSION_ENABLED` | Enable auto-compression (default: true) | +| `CONTEXT_COMPRESSION_THRESHOLD` | Trigger at this % of limit (default: 0.85) | +| `CONTEXT_COMPRESSION_MODEL` | Model for summaries | -**Terminal Tool Configuration (mini-swe-agent backend):** -- `TERMINAL_ENV`: Backend type - `local`, `docker`, `singularity`, `modal`, or `ssh` (default: `local`) -- `TERMINAL_DOCKER_IMAGE`: Docker image for docker backend (default: `python:3.11-slim`) -- `TERMINAL_SINGULARITY_IMAGE`: Singularity/Apptainer image (can be `docker://...` URL or local `.sif` path) -- `TERMINAL_TIMEOUT`: Command timeout in seconds (default: `60`) -- `TERMINAL_LIFETIME_SECONDS`: Cleanup inactive environments after this time (default: `300`) -- `TERMINAL_CWD`: Working directory inside containers (default: `/tmp`) -- `TERMINAL_SCRATCH_DIR`: Custom scratch directory for sandbox storage (optional, auto-detects `/scratch`) -- `SUDO_PASSWORD`: Enable sudo commands by piping password via `sudo -S` (works with all backends) - - If unset in CLI mode, you'll be prompted interactively when sudo is needed (45s timeout) +--- -**SSH Backend Configuration (for remote execution):** -- `TERMINAL_SSH_HOST`: Remote server hostname or IP -- `TERMINAL_SSH_USER`: SSH username -- `TERMINAL_SSH_PORT`: SSH port (default: `22`) -- `TERMINAL_SSH_KEY`: Path to SSH private key (optional, uses ssh-agent if not set) +## File Structure -**Context Compression (auto-shrinks long conversations):** -- `CONTEXT_COMPRESSION_ENABLED`: Enable auto-compression (default: `true`) -- `CONTEXT_COMPRESSION_THRESHOLD`: Compress at this % of context limit (default: `0.85`) -- `CONTEXT_COMPRESSION_MODEL`: Model for generating summaries (default: `google/gemini-2.0-flash-001`) +| Path | Description | +|------|-------------| +| `~/.hermes/config.yaml` | Your settings | +| `~/.hermes/.env` | API keys and secrets | +| `~/.hermes/cron/` | Scheduled jobs data | +| `~/.hermes/sessions/` | Gateway session data | +| `~/.hermes-agent/` | Installation directory | +| `~/.hermes-agent/logs/` | Session logs | +| `hermes_cli/` | CLI implementation | +| `tools/` | Tool implementations | +| `skills/` | Knowledge documents | +| `gateway/` | Messaging platform adapters | +| `cron/` | Scheduler implementation | -**Browser Tool Configuration (agent-browser + Browserbase):** -- `BROWSERBASE_API_KEY`: Browserbase API key for cloud browser execution -- `BROWSERBASE_PROJECT_ID`: Browserbase project ID -- `BROWSER_SESSION_TIMEOUT`: Session timeout in seconds (default: `300`) +--- -**Legacy Hecate Terminal Backend (optional):** -- `MORPH_API_KEY`: For Hecate/MorphCloud terminal backend -- `HECATE_VM_LIFETIME_SECONDS`: VM lifetime (default: 300) -- `HECATE_DEFAULT_SNAPSHOT_ID`: Default snapshot (default: snapshot_p5294qxt) +## Troubleshooting -**Debug Options:** -- `WEB_TOOLS_DEBUG`, `VISION_TOOLS_DEBUG`, `MOA_TOOLS_DEBUG`, `IMAGE_TOOLS_DEBUG`: Enable debug logging +```bash +hermes doctor # Run diagnostics +hermes status # Check configuration +hermes config # View current settings +``` -## Key Files +Common issues: +- **"API key not set"**: Run `hermes setup` or `hermes config set OPENROUTER_API_KEY your_key` +- **"hermes: command not found"**: Reload your shell (`source ~/.bashrc`) or check PATH +- **Gateway won't start**: Check `hermes gateway status` and logs -| File | Purpose | -|------|---------| -| `hermes` | CLI launcher script (run with `./hermes`) | -| `cli.py` | Interactive CLI implementation | -| `cli-config.yaml` | CLI configuration (copy from `.example`) | -| `run_agent.py` | Main agent runner - single query execution | -| `batch_runner.py` | Parallel batch processing with checkpointing | -| `model_tools.py` | Core tool definitions and handlers | -| `toolsets.py` | Toolset definitions and composition | -| `toolset_distributions.py` | Probability distributions for data generation | -| `trajectory_compressor.py` | Post-process trajectories for training | -| `tools/` | Individual tool implementations | -| `tools/skills_tool.py` | Skills system with progressive disclosure | -| `skills/` | On-demand knowledge documents | -| `docs/` | Documentation | -| `configs/` | Example batch run scripts | +--- + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Submit a pull request + +--- + +## License + +MIT License - see [LICENSE](LICENSE) for details. diff --git a/TODO.md b/TODO.md index 7a68afade..e25eed631 100644 --- a/TODO.md +++ b/TODO.md @@ -441,7 +441,7 @@ These items need to be addressed ASAP: --- -## 10. Messaging Platform Integrations 💬 +## 10. Messaging Platform Integrations 💬 ✅ COMPLETE **Problem:** Agent currently only works via `cli.py` which requires direct terminal access. Users may want to interact via messaging apps from their phone or other devices. @@ -462,26 +462,37 @@ These items need to be addressed ASAP: ``` **Platform support (each user sets up their own credentials):** -- [ ] **Telegram** - via `python-telegram-bot` or `grammy` equivalent +- [x] **Telegram** - via `python-telegram-bot` - Bot token from @BotFather - Easiest to set up, good for personal use -- [ ] **Discord** - via `discord.py` +- [x] **Discord** - via `discord.py` - Bot token from Discord Developer Portal - Can work in servers (group sessions) or DMs -- [ ] **WhatsApp** - via `baileys` (WhatsApp Web protocol) - - QR code scan to authenticate +- [x] **WhatsApp** - via Node.js bridge (whatsapp-web.js/baileys) + - Requires Node.js bridge setup - More complex, but reaches most people **Session management:** -- [ ] **Session store** - JSONL persistence per session key - - `~/.hermes/sessions/{session_key}.jsonl` - - Session keys: `telegram:dm:{user_id}`, `discord:channel:{id}`, etc. -- [ ] **Session expiry** - Configurable reset policies - - Daily reset (default 4am) OR idle timeout (e.g., 2 hours) +- [x] **Session store** - JSONL persistence per session key + - `~/.hermes/sessions/{session_id}.jsonl` + - Session keys: `agent:main:telegram:dm`, `agent:main:discord:group:123`, etc. +- [x] **Session expiry** - Configurable reset policies + - Daily reset (default 4am) OR idle timeout (default 2 hours) - Manual reset via `/reset` or `/new` command in chat -- [ ] **Session continuity** - Conversations persist across messages until reset + - Per-platform and per-type overrides +- [x] **Session continuity** - Conversations persist across messages until reset -**Files to create:** `monitors/telegram_monitor.py`, `monitors/discord_monitor.py`, `monitors/session_store.py` +**Files created:** `gateway/`, `gateway/platforms/`, `gateway/config.py`, `gateway/session.py`, `gateway/delivery.py`, `gateway/run.py` + +**Configuration:** +- Environment variables: `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN`, etc. +- Config file: `~/.hermes/gateway.json` +- CLI commands: `/platforms` to check status, `--gateway` to start + +**Dynamic context injection:** +- Agent knows its source platform and chat +- Agent knows connected platforms and home channels +- Agent can deliver cron outputs to specific platforms --- diff --git a/cli.py b/cli.py index 210e069df..bd06439fa 100755 --- a/cli.py +++ b/cli.py @@ -46,12 +46,30 @@ if env_path.exists(): def load_cli_config() -> Dict[str, Any]: """ - Load CLI configuration from cli-config.yaml. + Load CLI configuration from config files. + + Config lookup order: + 1. ~/.hermes/config.yaml (user config - preferred) + 2. ./cli-config.yaml (project config - fallback) Environment variables take precedence over config file values. - Returns default values if config file doesn't exist. + Returns default values if no config file exists. """ - config_path = Path(__file__).parent / 'cli-config.yaml' + # Check user config first (~/.hermes/config.yaml) + user_config_path = Path.home() / '.hermes' / 'config.yaml' + project_config_path = Path(__file__).parent / 'cli-config.yaml' + + # Use user config if it exists, otherwise project config + if user_config_path.exists(): + config_path = user_config_path + else: + config_path = project_config_path + + # Also load .env from ~/.hermes/.env if it exists + user_env_path = Path.home() / '.hermes' / '.env' + if user_env_path.exists(): + from dotenv import load_dotenv + load_dotenv(dotenv_path=user_env_path, override=True) # Default configuration defaults = { @@ -406,6 +424,7 @@ COMMANDS = { "/save": "Save the current conversation", "/config": "Show current configuration", "/cron": "Manage scheduled tasks (list, add, remove)", + "/platforms": "Show gateway/messaging platform status", "/quit": "Exit the CLI (also: /exit, /q)", } @@ -1018,6 +1037,63 @@ class HermesCLI: print(f"(._.) Unknown cron command: {subcommand}") print(" Available: list, add, remove") + def _show_gateway_status(self): + """Show status of the gateway and connected messaging platforms.""" + from gateway.config import load_gateway_config, Platform + + print() + print("+" + "-" * 60 + "+") + print("|" + " " * 15 + "(✿◠‿◠) Gateway Status" + " " * 17 + "|") + print("+" + "-" * 60 + "+") + print() + + try: + config = load_gateway_config() + connected = config.get_connected_platforms() + + print(" Messaging Platform Configuration:") + print(" " + "-" * 55) + + platform_status = { + Platform.TELEGRAM: ("Telegram", "TELEGRAM_BOT_TOKEN"), + Platform.DISCORD: ("Discord", "DISCORD_BOT_TOKEN"), + Platform.WHATSAPP: ("WhatsApp", "WHATSAPP_ENABLED"), + } + + for platform, (name, env_var) in platform_status.items(): + pconfig = config.platforms.get(platform) + if pconfig and pconfig.enabled: + home = config.get_home_channel(platform) + home_str = f" → {home.name}" if home else "" + print(f" ✓ {name:<12} Enabled{home_str}") + else: + print(f" ○ {name:<12} Not configured ({env_var})") + + print() + print(" Session Reset Policy:") + print(" " + "-" * 55) + policy = config.default_reset_policy + print(f" Mode: {policy.mode}") + print(f" Daily reset at: {policy.at_hour}:00") + print(f" Idle timeout: {policy.idle_minutes} minutes") + + print() + print(" To start the gateway:") + print(" python cli.py --gateway") + print() + print(" Configuration file: ~/.hermes/gateway.json") + print() + + except Exception as e: + print(f" Error loading gateway config: {e}") + print() + print(" To configure the gateway:") + print(" 1. Set environment variables:") + print(" TELEGRAM_BOT_TOKEN=your_token") + print(" DISCORD_BOT_TOKEN=your_token") + print(" 2. Or create ~/.hermes/gateway.json") + print() + def process_command(self, command: str) -> bool: """ Process a slash command. @@ -1075,6 +1151,8 @@ class HermesCLI: self.save_conversation() elif cmd.startswith("/cron"): self._handle_cron_command(command) # Use original command for proper parsing + elif cmd == "/platforms" or cmd == "/gateway": + self._show_gateway_status() else: self.console.print(f"[bold red]Unknown command: {cmd}[/]") self.console.print("[dim #B8860B]Type /help for available commands[/]") @@ -1216,6 +1294,7 @@ def main( list_toolsets: bool = False, cron_daemon: bool = False, cron_tick_once: bool = False, + gateway: bool = False, ): """ Hermes Agent CLI - Interactive AI Assistant @@ -1262,6 +1341,14 @@ def main( print(f"Executed {jobs_run} job(s)") return + # Handle gateway mode (messaging platforms) + if gateway: + import asyncio + from gateway.run import start_gateway + print("Starting Hermes Gateway (messaging platforms)...") + asyncio.run(start_gateway()) + return + # Handle query shorthand query = query or q diff --git a/cron/jobs.py b/cron/jobs.py index 9f7ff47c0..eb8f56b3d 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -208,7 +208,9 @@ def create_job( prompt: str, schedule: str, name: Optional[str] = None, - repeat: Optional[int] = None + repeat: Optional[int] = None, + deliver: Optional[str] = None, + origin: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """ Create a new cron job. @@ -218,6 +220,8 @@ def create_job( schedule: Schedule string (see parse_schedule) name: Optional friendly name repeat: How many times to run (None = forever, 1 = once) + deliver: Where to deliver output ("origin", "local", "telegram", etc.) + origin: Source info where job was created (for "origin" delivery) Returns: The created job dict @@ -228,6 +232,10 @@ def create_job( if parsed_schedule["kind"] == "once" and repeat is None: repeat = 1 + # Default delivery to origin if available, otherwise local + if deliver is None: + deliver = "origin" if origin else "local" + job_id = uuid.uuid4().hex[:12] now = datetime.now().isoformat() @@ -246,7 +254,10 @@ def create_job( "next_run_at": compute_next_run(parsed_schedule), "last_run_at": None, "last_status": None, - "last_error": None + "last_error": None, + # Delivery configuration + "deliver": deliver, + "origin": origin, # Tracks where job was created for "origin" delivery } jobs = load_jobs() diff --git a/docs/messaging.md b/docs/messaging.md new file mode 100644 index 000000000..5059401c2 --- /dev/null +++ b/docs/messaging.md @@ -0,0 +1,461 @@ +# Messaging Platform Integrations (Gateway) + +Hermes Agent can connect to messaging platforms like Telegram, Discord, and WhatsApp to serve as a conversational AI assistant. + +## Quick Start + +```bash +# 1. Set your bot token(s) in .env file +echo 'TELEGRAM_BOT_TOKEN="your_telegram_bot_token"' >> .env +echo 'DISCORD_BOT_TOKEN="your_discord_bot_token"' >> .env + +# 2. Test the gateway (foreground) +./scripts/hermes-gateway run + +# 3. Install as a system service (runs in background) +./scripts/hermes-gateway install + +# 4. Manage the service +./scripts/hermes-gateway start +./scripts/hermes-gateway stop +./scripts/hermes-gateway restart +./scripts/hermes-gateway status +``` + +**Quick test (without service install):** +```bash +python cli.py --gateway # Runs in foreground, useful for debugging +``` + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Hermes Gateway │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Telegram │ │ Discord │ │ WhatsApp │ │ +│ │ Adapter │ │ Adapter │ │ Adapter │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ +│ │ │ │ │ +│ └─────────────────┼─────────────────┘ │ +│ │ │ +│ ┌────────▼────────┐ │ +│ │ Session Store │ │ +│ │ (per-chat) │ │ +│ └────────┬────────┘ │ +│ │ │ +│ ┌────────▼────────┐ │ +│ │ AIAgent │ │ +│ │ (run_agent) │ │ +│ └─────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Session Management + +### Session Persistence + +Sessions persist across messages until they reset. The agent remembers your conversation context. + +### Reset Policies + +Sessions reset based on configurable policies: + +| Policy | Default | Description | +|--------|---------|-------------| +| Daily | 4:00 AM | Reset at a specific hour each day | +| Idle | 120 min | Reset after N minutes of inactivity | +| Both | (combined) | Whichever triggers first | + +### Manual Reset + +Send `/new` or `/reset` as a message to start fresh. + +### Per-Platform Overrides + +Configure different reset policies per platform: + +```json +{ + "reset_by_platform": { + "telegram": { "mode": "idle", "idle_minutes": 240 }, + "discord": { "mode": "idle", "idle_minutes": 60 } + } +} +``` + +## Platform Setup + +### Telegram + +1. **Create a bot** via [@BotFather](https://t.me/BotFather) +2. **Get your token** (looks like `123456789:ABCdefGHIjklMNOpqrsTUVwxyz`) +3. **Set environment variable:** + ```bash + export TELEGRAM_BOT_TOKEN="your_token_here" + ``` +4. **Optional: Set home channel** for cron job delivery: + ```bash + export TELEGRAM_HOME_CHANNEL="-1001234567890" + export TELEGRAM_HOME_CHANNEL_NAME="My Notes" + ``` + +**Requirements:** +```bash +pip install python-telegram-bot>=20.0 +``` + +### Discord + +1. **Create an application** at [Discord Developer Portal](https://discord.com/developers/applications) +2. **Create a bot** under your application +3. **Get the bot token** +4. **Enable required intents:** + - Message Content Intent + - Server Members Intent (optional) +5. **Invite to your server** using OAuth2 URL generator (scopes: `bot`, `applications.commands`) +6. **Set environment variable:** + ```bash + export DISCORD_BOT_TOKEN="your_token_here" + ``` +7. **Optional: Set home channel:** + ```bash + export DISCORD_HOME_CHANNEL="123456789012345678" + export DISCORD_HOME_CHANNEL_NAME="#bot-updates" + ``` + +**Requirements:** +```bash +pip install discord.py>=2.0 +``` + +### WhatsApp + +WhatsApp integration is more complex due to the lack of a simple bot API. + +**Options:** +1. **WhatsApp Business API** (requires Meta verification) +2. **whatsapp-web.js** via Node.js bridge (for personal accounts) + +**Bridge Setup:** +1. Install Node.js +2. Set up the bridge script (see `scripts/whatsapp-bridge/` for reference) +3. Configure in gateway: + ```json + { + "platforms": { + "whatsapp": { + "enabled": true, + "extra": { + "bridge_script": "/path/to/bridge.js", + "bridge_port": 3000 + } + } + } + } + ``` + +## Configuration + +There are **three ways** to configure the gateway (in order of precedence): + +### 1. Environment Variables (`.env` file) - Recommended for Quick Setup + +Add to your `.env` file in the project root: + +```bash +# ============================================================================= +# MESSAGING PLATFORM TOKENS +# ============================================================================= + +# Telegram - get from @BotFather on Telegram +TELEGRAM_BOT_TOKEN=your_telegram_bot_token + +# Optional: Default channel for cron job delivery +TELEGRAM_HOME_CHANNEL=-1001234567890 +TELEGRAM_HOME_CHANNEL_NAME="My Notes" + +# Discord - get from Discord Developer Portal +DISCORD_BOT_TOKEN=your_discord_bot_token + +# Optional: Default channel for cron job delivery +DISCORD_HOME_CHANNEL=123456789012345678 +DISCORD_HOME_CHANNEL_NAME="#bot-updates" + +# WhatsApp - requires Node.js bridge setup +WHATSAPP_ENABLED=true + +# ============================================================================= +# SESSION SETTINGS +# ============================================================================= + +# Reset sessions after N minutes of inactivity (default: 120) +SESSION_IDLE_MINUTES=120 + +# Daily reset hour in 24h format (default: 4 = 4am) +SESSION_RESET_HOUR=4 +``` + +### 2. Gateway Config File (`~/.hermes/gateway.json`) - Full Control + +For advanced configuration, create `~/.hermes/gateway.json`: + +```json +{ + "platforms": { + "telegram": { + "enabled": true, + "token": "your_telegram_token", + "home_channel": { + "platform": "telegram", + "chat_id": "-1001234567890", + "name": "My Notes" + } + }, + "discord": { + "enabled": true, + "token": "your_discord_token", + "home_channel": { + "platform": "discord", + "chat_id": "123456789012345678", + "name": "#bot-updates" + } + } + }, + "default_reset_policy": { + "mode": "both", + "at_hour": 4, + "idle_minutes": 120 + }, + "reset_by_platform": { + "discord": { + "mode": "idle", + "idle_minutes": 60 + } + }, + "always_log_local": true +} +``` + +## Platform-Specific Toolsets + +Each platform has its own toolset for security: + +| Platform | Toolset | Capabilities | +|----------|---------|--------------| +| CLI | `hermes-cli` | Full access (terminal, browser, etc.) | +| Telegram | `hermes-telegram` | Web, vision, skills, cronjobs | +| Discord | `hermes-discord` | Web search, vision, skills, cronjobs | +| WhatsApp | `hermes-whatsapp` | Web, terminal, vision, skills, cronjobs | + +Discord has a more limited toolset because it often runs in public servers. + +## Cron Job Delivery + +When scheduling cron jobs, you can specify where the output should be delivered: + +``` +User: "Remind me to check the server in 30 minutes" + +Agent uses: schedule_cronjob( + prompt="Check server status...", + schedule="30m", + deliver="origin" # Back to this chat +) +``` + +### Delivery Options + +| Option | Description | +|--------|-------------| +| `"origin"` | Back to where the job was created | +| `"local"` | Save to local files only | +| `"telegram"` | Telegram home channel | +| `"discord"` | Discord home channel | +| `"telegram:123456"` | Specific Telegram chat | + +## Dynamic Context Injection + +The agent knows where it is via injected context: + +``` +## Current Session Context + +**Source:** Telegram (group: Dev Team, ID: -1001234567890) +**Connected Platforms:** local, telegram, discord + +**Home Channels:** + - telegram: My Notes (ID: -1001234567890) + - discord: #bot-updates (ID: 123456789012345678) + +**Delivery options for scheduled tasks:** +- "origin" → Back to this chat (Dev Team) +- "local" → Save to local files only +- "telegram" → Home channel (My Notes) +- "discord" → Home channel (#bot-updates) +``` + +## CLI Commands + +| Command | Description | +|---------|-------------| +| `/platforms` | Show gateway configuration and status | +| `--gateway` | Start the gateway (CLI flag) | + +## Troubleshooting + +### "python-telegram-bot not installed" + +```bash +pip install python-telegram-bot>=20.0 +``` + +### "discord.py not installed" + +```bash +pip install discord.py>=2.0 +``` + +### "No platforms connected" + +1. Check your environment variables are set +2. Check your tokens are valid +3. Try `/platforms` to see configuration status + +### Session not persisting + +1. Check `~/.hermes/sessions/` exists +2. Check session policies aren't too aggressive +3. Verify no errors in gateway logs + +## Adding a New Platform + +To add a new messaging platform: + +### 1. Create the adapter + +Create `gateway/platforms/your_platform.py`: + +```python +from gateway.platforms.base import BasePlatformAdapter, MessageEvent, SendResult +from gateway.config import Platform, PlatformConfig + +class YourPlatformAdapter(BasePlatformAdapter): + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.YOUR_PLATFORM) + + async def connect(self) -> bool: + # Connect to the platform + ... + + async def disconnect(self) -> None: + # Disconnect + ... + + async def send(self, chat_id: str, content: str, ...) -> SendResult: + # Send a message + ... + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + # Get chat information + ... +``` + +### 2. Register the platform + +Add to `gateway/config.py`: + +```python +class Platform(Enum): + # ... existing ... + YOUR_PLATFORM = "your_platform" +``` + +### 3. Add to gateway runner + +Update `gateway/run.py` `_create_adapter()`: + +```python +elif platform == Platform.YOUR_PLATFORM: + from gateway.platforms.your_platform import YourPlatformAdapter + return YourPlatformAdapter(config) +``` + +### 4. Create a toolset (optional) + +Add to `toolsets.py`: + +```python +"hermes-your-platform": { + "description": "Your platform toolset", + "tools": [...], + "includes": [] +} +``` + +### 5. Configure + +Add environment variables to `.env`: + +```bash +YOUR_PLATFORM_TOKEN=... +YOUR_PLATFORM_HOME_CHANNEL=... +``` + +## Service Management + +### Linux (systemd) + +```bash +# Install as user service +./scripts/hermes-gateway install + +# Manage +systemctl --user start hermes-gateway +systemctl --user stop hermes-gateway +systemctl --user restart hermes-gateway +systemctl --user status hermes-gateway + +# View logs +journalctl --user -u hermes-gateway -f + +# Enable lingering (keeps running after logout) +sudo loginctl enable-linger $USER +``` + +### macOS (launchd) + +```bash +# Install +./scripts/hermes-gateway install + +# Manage +launchctl start ai.hermes.gateway +launchctl stop ai.hermes.gateway + +# View logs +tail -f ~/.hermes/logs/gateway.log +``` + +### Manual (any platform) + +```bash +# Run in foreground (for testing/debugging) +./scripts/hermes-gateway run + +# Or via CLI (also foreground) +python cli.py --gateway +``` + +## Storage Locations + +| Path | Purpose | +|------|---------| +| `~/.hermes/gateway.json` | Gateway configuration | +| `~/.hermes/sessions/sessions.json` | Session index | +| `~/.hermes/sessions/{id}.jsonl` | Conversation transcripts | +| `~/.hermes/cron/output/` | Cron job outputs | +| `~/.hermes/logs/gateway.log` | Gateway logs (macOS launchd) | diff --git a/gateway/__init__.py b/gateway/__init__.py new file mode 100644 index 000000000..8b6d98893 --- /dev/null +++ b/gateway/__init__.py @@ -0,0 +1,35 @@ +""" +Hermes Gateway - Multi-platform messaging integration. + +This module provides a unified gateway for connecting the Hermes agent +to various messaging platforms (Telegram, Discord, WhatsApp) with: +- Session management (persistent conversations with reset policies) +- Dynamic context injection (agent knows where messages come from) +- Delivery routing (cron job outputs to appropriate channels) +- Platform-specific toolsets (different capabilities per platform) +""" + +from .config import GatewayConfig, PlatformConfig, HomeChannel, load_gateway_config +from .session import ( + SessionContext, + SessionStore, + SessionResetPolicy, + build_session_context_prompt, +) +from .delivery import DeliveryRouter, DeliveryTarget + +__all__ = [ + # Config + "GatewayConfig", + "PlatformConfig", + "HomeChannel", + "load_gateway_config", + # Session + "SessionContext", + "SessionStore", + "SessionResetPolicy", + "build_session_context_prompt", + # Delivery + "DeliveryRouter", + "DeliveryTarget", +] diff --git a/gateway/config.py b/gateway/config.py new file mode 100644 index 000000000..302ba5361 --- /dev/null +++ b/gateway/config.py @@ -0,0 +1,333 @@ +""" +Gateway configuration management. + +Handles loading and validating configuration for: +- Connected platforms (Telegram, Discord, WhatsApp) +- Home channels for each platform +- Session reset policies +- Delivery preferences +""" + +import os +import json +from pathlib import Path +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Any +from enum import Enum + + +class Platform(Enum): + """Supported messaging platforms.""" + LOCAL = "local" + TELEGRAM = "telegram" + DISCORD = "discord" + WHATSAPP = "whatsapp" + + +@dataclass +class HomeChannel: + """ + Default destination for a platform. + + When a cron job specifies deliver="telegram" without a specific chat ID, + messages are sent to this home channel. + """ + platform: Platform + chat_id: str + name: str # Human-readable name for display + + def to_dict(self) -> Dict[str, Any]: + return { + "platform": self.platform.value, + "chat_id": self.chat_id, + "name": self.name, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "HomeChannel": + return cls( + platform=Platform(data["platform"]), + chat_id=str(data["chat_id"]), + name=data.get("name", "Home"), + ) + + +@dataclass +class SessionResetPolicy: + """ + Controls when sessions reset (lose context). + + Modes: + - "daily": Reset at a specific hour each day + - "idle": Reset after N minutes of inactivity + - "both": Whichever triggers first (daily boundary OR idle timeout) + """ + mode: str = "both" # "daily", "idle", or "both" + at_hour: int = 4 # Hour for daily reset (0-23, local time) + idle_minutes: int = 120 # Minutes of inactivity before reset + + def to_dict(self) -> Dict[str, Any]: + return { + "mode": self.mode, + "at_hour": self.at_hour, + "idle_minutes": self.idle_minutes, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "SessionResetPolicy": + return cls( + mode=data.get("mode", "both"), + at_hour=data.get("at_hour", 4), + idle_minutes=data.get("idle_minutes", 120), + ) + + +@dataclass +class PlatformConfig: + """Configuration for a single messaging platform.""" + enabled: bool = False + token: Optional[str] = None # Bot token (Telegram, Discord) + api_key: Optional[str] = None # API key if different from token + home_channel: Optional[HomeChannel] = None + + # Platform-specific settings + extra: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + result = { + "enabled": self.enabled, + "extra": self.extra, + } + if self.token: + result["token"] = self.token + if self.api_key: + result["api_key"] = self.api_key + if self.home_channel: + result["home_channel"] = self.home_channel.to_dict() + return result + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig": + home_channel = None + if "home_channel" in data: + home_channel = HomeChannel.from_dict(data["home_channel"]) + + return cls( + enabled=data.get("enabled", False), + token=data.get("token"), + api_key=data.get("api_key"), + home_channel=home_channel, + extra=data.get("extra", {}), + ) + + +@dataclass +class GatewayConfig: + """ + Main gateway configuration. + + Manages all platform connections, session policies, and delivery settings. + """ + # Platform configurations + platforms: Dict[Platform, PlatformConfig] = field(default_factory=dict) + + # Session reset policies by type + default_reset_policy: SessionResetPolicy = field(default_factory=SessionResetPolicy) + reset_by_type: Dict[str, SessionResetPolicy] = field(default_factory=dict) + reset_by_platform: Dict[Platform, SessionResetPolicy] = field(default_factory=dict) + + # Reset trigger commands + reset_triggers: List[str] = field(default_factory=lambda: ["/new", "/reset"]) + + # Storage paths + sessions_dir: Path = field(default_factory=lambda: Path.home() / ".hermes" / "sessions") + + # Delivery settings + always_log_local: bool = True # Always save cron outputs to local files + + def get_connected_platforms(self) -> List[Platform]: + """Return list of platforms that are enabled and configured.""" + connected = [] + for platform, config in self.platforms.items(): + if config.enabled and (config.token or config.api_key): + connected.append(platform) + return connected + + def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]: + """Get the home channel for a platform.""" + config = self.platforms.get(platform) + if config: + return config.home_channel + return None + + def get_reset_policy( + self, + platform: Optional[Platform] = None, + session_type: Optional[str] = None + ) -> SessionResetPolicy: + """ + Get the appropriate reset policy for a session. + + Priority: platform override > type override > default + """ + # Platform-specific override takes precedence + if platform and platform in self.reset_by_platform: + return self.reset_by_platform[platform] + + # Type-specific override (dm, group, thread) + if session_type and session_type in self.reset_by_type: + return self.reset_by_type[session_type] + + return self.default_reset_policy + + def to_dict(self) -> Dict[str, Any]: + return { + "platforms": { + p.value: c.to_dict() for p, c in self.platforms.items() + }, + "default_reset_policy": self.default_reset_policy.to_dict(), + "reset_by_type": { + k: v.to_dict() for k, v in self.reset_by_type.items() + }, + "reset_by_platform": { + p.value: v.to_dict() for p, v in self.reset_by_platform.items() + }, + "reset_triggers": self.reset_triggers, + "sessions_dir": str(self.sessions_dir), + "always_log_local": self.always_log_local, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig": + platforms = {} + for platform_name, platform_data in data.get("platforms", {}).items(): + try: + platform = Platform(platform_name) + platforms[platform] = PlatformConfig.from_dict(platform_data) + except ValueError: + pass # Skip unknown platforms + + reset_by_type = {} + for type_name, policy_data in data.get("reset_by_type", {}).items(): + reset_by_type[type_name] = SessionResetPolicy.from_dict(policy_data) + + reset_by_platform = {} + for platform_name, policy_data in data.get("reset_by_platform", {}).items(): + try: + platform = Platform(platform_name) + reset_by_platform[platform] = SessionResetPolicy.from_dict(policy_data) + except ValueError: + pass + + default_policy = SessionResetPolicy() + if "default_reset_policy" in data: + default_policy = SessionResetPolicy.from_dict(data["default_reset_policy"]) + + sessions_dir = Path.home() / ".hermes" / "sessions" + if "sessions_dir" in data: + sessions_dir = Path(data["sessions_dir"]) + + return cls( + platforms=platforms, + default_reset_policy=default_policy, + reset_by_type=reset_by_type, + reset_by_platform=reset_by_platform, + reset_triggers=data.get("reset_triggers", ["/new", "/reset"]), + sessions_dir=sessions_dir, + always_log_local=data.get("always_log_local", True), + ) + + +def load_gateway_config() -> GatewayConfig: + """ + Load gateway configuration from multiple sources. + + Priority (highest to lowest): + 1. Environment variables + 2. ~/.hermes/gateway.json + 3. cli-config.yaml gateway section + 4. Defaults + """ + config = GatewayConfig() + + # Try loading from ~/.hermes/gateway.json + gateway_config_path = Path.home() / ".hermes" / "gateway.json" + if gateway_config_path.exists(): + try: + with open(gateway_config_path, "r") as f: + data = json.load(f) + config = GatewayConfig.from_dict(data) + except Exception as e: + print(f"[gateway] Warning: Failed to load {gateway_config_path}: {e}") + + # Override with environment variables + _apply_env_overrides(config) + + return config + + +def _apply_env_overrides(config: GatewayConfig) -> None: + """Apply environment variable overrides to config.""" + + # Telegram + telegram_token = os.getenv("TELEGRAM_BOT_TOKEN") + if telegram_token: + if Platform.TELEGRAM not in config.platforms: + config.platforms[Platform.TELEGRAM] = PlatformConfig() + config.platforms[Platform.TELEGRAM].enabled = True + config.platforms[Platform.TELEGRAM].token = telegram_token + + telegram_home = os.getenv("TELEGRAM_HOME_CHANNEL") + if telegram_home and Platform.TELEGRAM in config.platforms: + config.platforms[Platform.TELEGRAM].home_channel = HomeChannel( + platform=Platform.TELEGRAM, + chat_id=telegram_home, + name=os.getenv("TELEGRAM_HOME_CHANNEL_NAME", "Home"), + ) + + # Discord + discord_token = os.getenv("DISCORD_BOT_TOKEN") + if discord_token: + if Platform.DISCORD not in config.platforms: + config.platforms[Platform.DISCORD] = PlatformConfig() + config.platforms[Platform.DISCORD].enabled = True + config.platforms[Platform.DISCORD].token = discord_token + + discord_home = os.getenv("DISCORD_HOME_CHANNEL") + if discord_home and Platform.DISCORD in config.platforms: + config.platforms[Platform.DISCORD].home_channel = HomeChannel( + platform=Platform.DISCORD, + chat_id=discord_home, + name=os.getenv("DISCORD_HOME_CHANNEL_NAME", "Home"), + ) + + # WhatsApp (typically uses different auth mechanism) + whatsapp_enabled = os.getenv("WHATSAPP_ENABLED", "").lower() in ("true", "1", "yes") + if whatsapp_enabled: + if Platform.WHATSAPP not in config.platforms: + config.platforms[Platform.WHATSAPP] = PlatformConfig() + config.platforms[Platform.WHATSAPP].enabled = True + + # Session settings + idle_minutes = os.getenv("SESSION_IDLE_MINUTES") + if idle_minutes: + try: + config.default_reset_policy.idle_minutes = int(idle_minutes) + except ValueError: + pass + + reset_hour = os.getenv("SESSION_RESET_HOUR") + if reset_hour: + try: + config.default_reset_policy.at_hour = int(reset_hour) + except ValueError: + pass + + +def save_gateway_config(config: GatewayConfig) -> None: + """Save gateway configuration to ~/.hermes/gateway.json.""" + gateway_config_path = Path.home() / ".hermes" / "gateway.json" + gateway_config_path.parent.mkdir(parents=True, exist_ok=True) + + with open(gateway_config_path, "w") as f: + json.dump(config.to_dict(), f, indent=2) diff --git a/gateway/delivery.py b/gateway/delivery.py new file mode 100644 index 000000000..04c55f0ba --- /dev/null +++ b/gateway/delivery.py @@ -0,0 +1,318 @@ +""" +Delivery routing for cron job outputs and agent responses. + +Routes messages to the appropriate destination based on: +- Explicit targets (e.g., "telegram:123456789") +- Platform home channels (e.g., "telegram" → home channel) +- Origin (back to where the job was created) +- Local (always saved to files) +""" + +import json +from pathlib import Path +from datetime import datetime +from dataclasses import dataclass +from typing import Dict, List, Optional, Any, Union +from enum import Enum + +from .config import Platform, GatewayConfig, HomeChannel +from .session import SessionSource + + +@dataclass +class DeliveryTarget: + """ + A single delivery target. + + Represents where a message should be sent: + - "origin" → back to source + - "local" → save to local files + - "telegram" → Telegram home channel + - "telegram:123456" → specific Telegram chat + """ + platform: Platform + chat_id: Optional[str] = None # None means use home channel + is_origin: bool = False + is_explicit: bool = False # True if chat_id was explicitly specified + + @classmethod + def parse(cls, target: str, origin: Optional[SessionSource] = None) -> "DeliveryTarget": + """ + Parse a delivery target string. + + Formats: + - "origin" → back to source + - "local" → local files only + - "telegram" → Telegram home channel + - "telegram:123456" → specific Telegram chat + """ + target = target.strip().lower() + + if target == "origin": + if origin: + return cls( + platform=origin.platform, + chat_id=origin.chat_id, + is_origin=True, + ) + else: + # Fallback to local if no origin + return cls(platform=Platform.LOCAL, is_origin=True) + + if target == "local": + return cls(platform=Platform.LOCAL) + + # Check for platform:chat_id format + if ":" in target: + platform_str, chat_id = target.split(":", 1) + try: + platform = Platform(platform_str) + return cls(platform=platform, chat_id=chat_id, is_explicit=True) + except ValueError: + # Unknown platform, treat as local + return cls(platform=Platform.LOCAL) + + # Just a platform name (use home channel) + try: + platform = Platform(target) + return cls(platform=platform) + except ValueError: + # Unknown platform, treat as local + return cls(platform=Platform.LOCAL) + + def to_string(self) -> str: + """Convert back to string format.""" + if self.is_origin: + return "origin" + if self.platform == Platform.LOCAL: + return "local" + if self.chat_id: + return f"{self.platform.value}:{self.chat_id}" + return self.platform.value + + +class DeliveryRouter: + """ + Routes messages to appropriate destinations. + + Handles the logic of resolving delivery targets and dispatching + messages to the right platform adapters. + """ + + def __init__(self, config: GatewayConfig, adapters: Dict[Platform, Any] = None): + """ + Initialize the delivery router. + + Args: + config: Gateway configuration + adapters: Dict mapping platforms to their adapter instances + """ + self.config = config + self.adapters = adapters or {} + self.output_dir = Path.home() / ".hermes" / "cron" / "output" + + def resolve_targets( + self, + deliver: Union[str, List[str]], + origin: Optional[SessionSource] = None + ) -> List[DeliveryTarget]: + """ + Resolve delivery specification to concrete targets. + + Args: + deliver: Delivery spec - "origin", "telegram", ["local", "discord"], etc. + origin: The source where the request originated (for "origin" target) + + Returns: + List of resolved delivery targets + """ + if isinstance(deliver, str): + deliver = [deliver] + + targets = [] + seen_platforms = set() + + for target_str in deliver: + target = DeliveryTarget.parse(target_str, origin) + + # Resolve home channel if needed + if target.chat_id is None and target.platform != Platform.LOCAL: + home = self.config.get_home_channel(target.platform) + if home: + target.chat_id = home.chat_id + else: + # No home channel configured, skip this platform + continue + + # Deduplicate + key = (target.platform, target.chat_id) + if key not in seen_platforms: + seen_platforms.add(key) + targets.append(target) + + # Always include local if configured + if self.config.always_log_local: + local_key = (Platform.LOCAL, None) + if local_key not in seen_platforms: + targets.append(DeliveryTarget(platform=Platform.LOCAL)) + + return targets + + async def deliver( + self, + content: str, + targets: List[DeliveryTarget], + job_id: Optional[str] = None, + job_name: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """ + Deliver content to all specified targets. + + Args: + content: The message/output to deliver + targets: List of delivery targets + job_id: Optional job ID (for cron jobs) + job_name: Optional job name + metadata: Additional metadata to include + + Returns: + Dict with delivery results per target + """ + results = {} + + for target in targets: + try: + if target.platform == Platform.LOCAL: + result = self._deliver_local(content, job_id, job_name, metadata) + else: + result = await self._deliver_to_platform(target, content, metadata) + + results[target.to_string()] = { + "success": True, + "result": result + } + except Exception as e: + results[target.to_string()] = { + "success": False, + "error": str(e) + } + + return results + + def _deliver_local( + self, + content: str, + job_id: Optional[str], + job_name: Optional[str], + metadata: Optional[Dict[str, Any]] + ) -> Dict[str, Any]: + """Save content to local files.""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + if job_id: + output_path = self.output_dir / job_id / f"{timestamp}.md" + else: + output_path = self.output_dir / "misc" / f"{timestamp}.md" + + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Build the output document + lines = [] + if job_name: + lines.append(f"# {job_name}") + else: + lines.append("# Delivery Output") + + lines.append("") + lines.append(f"**Timestamp:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + + if job_id: + lines.append(f"**Job ID:** {job_id}") + + if metadata: + for key, value in metadata.items(): + lines.append(f"**{key}:** {value}") + + lines.append("") + lines.append("---") + lines.append("") + lines.append(content) + + output_path.write_text("\n".join(lines)) + + return { + "path": str(output_path), + "timestamp": timestamp + } + + async def _deliver_to_platform( + self, + target: DeliveryTarget, + content: str, + metadata: Optional[Dict[str, Any]] + ) -> Dict[str, Any]: + """Deliver content to a messaging platform.""" + adapter = self.adapters.get(target.platform) + + if not adapter: + raise ValueError(f"No adapter configured for {target.platform.value}") + + if not target.chat_id: + raise ValueError(f"No chat ID for {target.platform.value} delivery") + + # Call the adapter's send method + # Adapters should implement: async def send(chat_id: str, content: str) -> Dict + return await adapter.send(target.chat_id, content, metadata=metadata) + + +def parse_deliver_spec( + deliver: Optional[Union[str, List[str]]], + origin: Optional[SessionSource] = None, + default: str = "origin" +) -> Union[str, List[str]]: + """ + Normalize a delivery specification. + + If None or empty, returns the default. + """ + if not deliver: + return default + return deliver + + +def build_delivery_context_for_tool( + config: GatewayConfig, + origin: Optional[SessionSource] = None +) -> Dict[str, Any]: + """ + Build context for the schedule_cronjob tool to understand delivery options. + + This is passed to the tool so it can validate and explain delivery targets. + """ + connected = config.get_connected_platforms() + + options = { + "origin": { + "description": "Back to where this job was created", + "available": origin is not None, + }, + "local": { + "description": "Save to local files only", + "available": True, + } + } + + for platform in connected: + home = config.get_home_channel(platform) + options[platform.value] = { + "description": f"{platform.value.title()} home channel", + "available": True, + "home_channel": home.to_dict() if home else None, + } + + return { + "origin": origin.to_dict() if origin else None, + "options": options, + "always_log_local": config.always_log_local, + } diff --git a/gateway/platforms/__init__.py b/gateway/platforms/__init__.py new file mode 100644 index 000000000..dae74568d --- /dev/null +++ b/gateway/platforms/__init__.py @@ -0,0 +1,17 @@ +""" +Platform adapters for messaging integrations. + +Each adapter handles: +- Receiving messages from a platform +- Sending messages/responses back +- Platform-specific authentication +- Message formatting and media handling +""" + +from .base import BasePlatformAdapter, MessageEvent, SendResult + +__all__ = [ + "BasePlatformAdapter", + "MessageEvent", + "SendResult", +] diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py new file mode 100644 index 000000000..e9a5f828e --- /dev/null +++ b/gateway/platforms/base.py @@ -0,0 +1,274 @@ +""" +Base platform adapter interface. + +All platform adapters (Telegram, Discord, WhatsApp) inherit from this +and implement the required methods. +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from datetime import datetime +from typing import Dict, List, Optional, Any, Callable, Awaitable +from enum import Enum + +import sys +sys.path.insert(0, str(__file__).rsplit("/", 3)[0]) + +from gateway.config import Platform, PlatformConfig +from gateway.session import SessionSource + + +class MessageType(Enum): + """Types of incoming messages.""" + TEXT = "text" + PHOTO = "photo" + VIDEO = "video" + AUDIO = "audio" + VOICE = "voice" + DOCUMENT = "document" + STICKER = "sticker" + COMMAND = "command" # /command style + + +@dataclass +class MessageEvent: + """ + Incoming message from a platform. + + Normalized representation that all adapters produce. + """ + # Message content + text: str + message_type: MessageType = MessageType.TEXT + + # Source information + source: SessionSource = None + + # Original platform data + raw_message: Any = None + message_id: Optional[str] = None + + # Media attachments + media_urls: List[str] = field(default_factory=list) + media_types: List[str] = field(default_factory=list) + + # Reply context + reply_to_message_id: Optional[str] = None + + # Timestamps + timestamp: datetime = field(default_factory=datetime.now) + + def is_command(self) -> bool: + """Check if this is a command message (e.g., /new, /reset).""" + return self.text.startswith("/") + + def get_command(self) -> Optional[str]: + """Extract command name if this is a command message.""" + if not self.is_command(): + return None + # Split on space and get first word, strip the / + parts = self.text.split(maxsplit=1) + return parts[0][1:].lower() if parts else None + + def get_command_args(self) -> str: + """Get the arguments after a command.""" + if not self.is_command(): + return self.text + parts = self.text.split(maxsplit=1) + return parts[1] if len(parts) > 1 else "" + + +@dataclass +class SendResult: + """Result of sending a message.""" + success: bool + message_id: Optional[str] = None + error: Optional[str] = None + raw_response: Any = None + + +# Type for message handlers +MessageHandler = Callable[[MessageEvent], Awaitable[Optional[str]]] + + +class BasePlatformAdapter(ABC): + """ + Base class for platform adapters. + + Subclasses implement platform-specific logic for: + - Connecting and authenticating + - Receiving messages + - Sending messages/responses + - Handling media + """ + + def __init__(self, config: PlatformConfig, platform: Platform): + self.config = config + self.platform = platform + self._message_handler: Optional[MessageHandler] = None + self._running = False + + @property + def name(self) -> str: + """Human-readable name for this adapter.""" + return self.platform.value.title() + + @property + def is_connected(self) -> bool: + """Check if adapter is currently connected.""" + return self._running + + def set_message_handler(self, handler: MessageHandler) -> None: + """ + Set the handler for incoming messages. + + The handler receives a MessageEvent and should return + an optional response string. + """ + self._message_handler = handler + + @abstractmethod + async def connect(self) -> bool: + """ + Connect to the platform and start receiving messages. + + Returns True if connection was successful. + """ + pass + + @abstractmethod + async def disconnect(self) -> None: + """Disconnect from the platform.""" + pass + + @abstractmethod + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None + ) -> SendResult: + """ + Send a message to a chat. + + Args: + chat_id: The chat/channel ID to send to + content: Message content (may be markdown) + reply_to: Optional message ID to reply to + metadata: Additional platform-specific options + + Returns: + SendResult with success status and message ID + """ + pass + + async def send_typing(self, chat_id: str) -> None: + """ + Send a typing indicator. + + Override in subclasses if the platform supports it. + """ + pass + + async def handle_message(self, event: MessageEvent) -> None: + """ + Process an incoming message. + + Calls the registered message handler and sends the response. + """ + if not self._message_handler: + return + + try: + # Send typing indicator + await self.send_typing(event.source.chat_id) + + # Call the handler + response = await self._message_handler(event) + + # Send response if any + if response: + await self.send( + chat_id=event.source.chat_id, + content=response, + reply_to=event.message_id + ) + except Exception as e: + print(f"[{self.name}] Error handling message: {e}") + + def build_source( + self, + chat_id: str, + chat_name: Optional[str] = None, + chat_type: str = "dm", + user_id: Optional[str] = None, + user_name: Optional[str] = None, + thread_id: Optional[str] = None + ) -> SessionSource: + """Helper to build a SessionSource for this platform.""" + return SessionSource( + platform=self.platform, + chat_id=str(chat_id), + chat_name=chat_name, + chat_type=chat_type, + user_id=str(user_id) if user_id else None, + user_name=user_name, + thread_id=str(thread_id) if thread_id else None, + ) + + @abstractmethod + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + """ + Get information about a chat/channel. + + Returns dict with at least: + - name: Chat name + - type: "dm", "group", "channel" + """ + pass + + def format_message(self, content: str) -> str: + """ + Format a message for this platform. + + Override in subclasses to handle platform-specific formatting + (e.g., Telegram MarkdownV2, Discord markdown). + + Default implementation returns content as-is. + """ + return content + + def truncate_message(self, content: str, max_length: int = 4096) -> List[str]: + """ + Split a long message into chunks. + + Args: + content: The full message content + max_length: Maximum length per chunk (platform-specific) + + Returns: + List of message chunks + """ + if len(content) <= max_length: + return [content] + + chunks = [] + while content: + if len(content) <= max_length: + chunks.append(content) + break + + # Try to split at a newline + split_idx = content.rfind("\n", 0, max_length) + if split_idx == -1: + # No newline, split at space + split_idx = content.rfind(" ", 0, max_length) + if split_idx == -1: + # No space either, hard split + split_idx = max_length + + chunks.append(content[:split_idx]) + content = content[split_idx:].lstrip() + + return chunks diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py new file mode 100644 index 000000000..345b19899 --- /dev/null +++ b/gateway/platforms/discord.py @@ -0,0 +1,297 @@ +""" +Discord platform adapter. + +Uses discord.py library for: +- Receiving messages from servers and DMs +- Sending responses back +- Handling threads and channels +""" + +import asyncio +from typing import Dict, List, Optional, Any + +try: + import discord + from discord import Message as DiscordMessage, Intents + from discord.ext import commands + DISCORD_AVAILABLE = True +except ImportError: + DISCORD_AVAILABLE = False + discord = None + DiscordMessage = Any + Intents = Any + commands = None + +import sys +sys.path.insert(0, str(__file__).rsplit("/", 3)[0]) + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, +) + + +def check_discord_requirements() -> bool: + """Check if Discord dependencies are available.""" + return DISCORD_AVAILABLE + + +class DiscordAdapter(BasePlatformAdapter): + """ + Discord bot adapter. + + Handles: + - Receiving messages from servers and DMs + - Sending responses with Discord markdown + - Thread support + - Slash commands (future) + """ + + # Discord message limits + MAX_MESSAGE_LENGTH = 2000 + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.DISCORD) + self._client: Optional[commands.Bot] = None + self._ready_event = asyncio.Event() + + async def connect(self) -> bool: + """Connect to Discord and start receiving events.""" + if not DISCORD_AVAILABLE: + print(f"[{self.name}] discord.py not installed. Run: pip install discord.py") + return False + + if not self.config.token: + print(f"[{self.name}] No bot token configured") + return False + + try: + # Set up intents + intents = Intents.default() + intents.message_content = True + intents.dm_messages = True + intents.guild_messages = True + + # Create bot + self._client = commands.Bot( + command_prefix="!", # Not really used, we handle raw messages + intents=intents, + ) + + # Register event handlers + @self._client.event + async def on_ready(): + print(f"[{self.name}] Connected as {self._client.user}") + self._ready_event.set() + + @self._client.event + async def on_message(message: DiscordMessage): + # Ignore bot's own messages + if message.author == self._client.user: + return + await self._handle_message(message) + + # Start the bot in background + asyncio.create_task(self._client.start(self.config.token)) + + # Wait for ready + await asyncio.wait_for(self._ready_event.wait(), timeout=30) + + self._running = True + return True + + except asyncio.TimeoutError: + print(f"[{self.name}] Timeout waiting for connection") + return False + except Exception as e: + print(f"[{self.name}] Failed to connect: {e}") + return False + + async def disconnect(self) -> None: + """Disconnect from Discord.""" + if self._client: + try: + await self._client.close() + except Exception as e: + print(f"[{self.name}] Error during disconnect: {e}") + + self._running = False + self._client = None + self._ready_event.clear() + print(f"[{self.name}] Disconnected") + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None + ) -> SendResult: + """Send a message to a Discord channel.""" + if not self._client: + return SendResult(success=False, error="Not connected") + + try: + # Get the channel + channel = self._client.get_channel(int(chat_id)) + if not channel: + channel = await self._client.fetch_channel(int(chat_id)) + + if not channel: + return SendResult(success=False, error=f"Channel {chat_id} not found") + + # Format and split message if needed + formatted = self.format_message(content) + chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH) + + message_ids = [] + reference = None + + if reply_to: + try: + ref_msg = await channel.fetch_message(int(reply_to)) + reference = ref_msg + except Exception: + pass # Ignore if we can't find the referenced message + + for i, chunk in enumerate(chunks): + msg = await channel.send( + content=chunk, + reference=reference if i == 0 else None, + ) + message_ids.append(str(msg.id)) + + return SendResult( + success=True, + message_id=message_ids[0] if message_ids else None, + raw_response={"message_ids": message_ids} + ) + + except Exception as e: + return SendResult(success=False, error=str(e)) + + async def send_typing(self, chat_id: str) -> None: + """Send typing indicator.""" + if self._client: + try: + channel = self._client.get_channel(int(chat_id)) + if channel: + await channel.typing() + except Exception: + pass # Ignore typing indicator failures + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + """Get information about a Discord channel.""" + if not self._client: + return {"name": "Unknown", "type": "dm"} + + try: + channel = self._client.get_channel(int(chat_id)) + if not channel: + channel = await self._client.fetch_channel(int(chat_id)) + + if not channel: + return {"name": str(chat_id), "type": "dm"} + + # Determine channel type + if isinstance(channel, discord.DMChannel): + chat_type = "dm" + name = channel.recipient.name if channel.recipient else str(chat_id) + elif isinstance(channel, discord.Thread): + chat_type = "thread" + name = channel.name + elif isinstance(channel, discord.TextChannel): + chat_type = "channel" + name = f"#{channel.name}" + if channel.guild: + name = f"{channel.guild.name} / {name}" + else: + chat_type = "channel" + name = getattr(channel, "name", str(chat_id)) + + return { + "name": name, + "type": chat_type, + "guild_id": str(channel.guild.id) if hasattr(channel, "guild") and channel.guild else None, + "guild_name": channel.guild.name if hasattr(channel, "guild") and channel.guild else None, + } + except Exception as e: + return {"name": str(chat_id), "type": "dm", "error": str(e)} + + def format_message(self, content: str) -> str: + """ + Format message for Discord. + + Discord uses its own markdown variant. + """ + # Discord markdown is fairly standard, no special escaping needed + return content + + async def _handle_message(self, message: DiscordMessage) -> None: + """Handle incoming Discord messages.""" + # Determine message type + msg_type = MessageType.TEXT + if message.content.startswith("/"): + msg_type = MessageType.COMMAND + elif message.attachments: + # Check attachment types + for att in message.attachments: + if att.content_type: + if att.content_type.startswith("image/"): + msg_type = MessageType.PHOTO + elif att.content_type.startswith("video/"): + msg_type = MessageType.VIDEO + elif att.content_type.startswith("audio/"): + msg_type = MessageType.AUDIO + else: + msg_type = MessageType.DOCUMENT + break + + # Determine chat type + if isinstance(message.channel, discord.DMChannel): + chat_type = "dm" + chat_name = message.author.name + elif isinstance(message.channel, discord.Thread): + chat_type = "thread" + chat_name = message.channel.name + else: + chat_type = "group" # Treat server channels as groups + chat_name = getattr(message.channel, "name", str(message.channel.id)) + if hasattr(message.channel, "guild") and message.channel.guild: + chat_name = f"{message.channel.guild.name} / #{chat_name}" + + # Get thread ID if in a thread + thread_id = None + if isinstance(message.channel, discord.Thread): + thread_id = str(message.channel.id) + + # Build source + source = self.build_source( + chat_id=str(message.channel.id), + chat_name=chat_name, + chat_type=chat_type, + user_id=str(message.author.id), + user_name=message.author.display_name, + thread_id=thread_id, + ) + + # Build media URLs + media_urls = [att.url for att in message.attachments] + media_types = [att.content_type or "unknown" for att in message.attachments] + + event = MessageEvent( + text=message.content, + message_type=msg_type, + source=source, + raw_message=message, + message_id=str(message.id), + media_urls=media_urls, + media_types=media_types, + reply_to_message_id=str(message.reference.message_id) if message.reference else None, + timestamp=message.created_at, + ) + + await self.handle_message(event) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py new file mode 100644 index 000000000..ef46351c6 --- /dev/null +++ b/gateway/platforms/telegram.py @@ -0,0 +1,284 @@ +""" +Telegram platform adapter. + +Uses python-telegram-bot library for: +- Receiving messages from users/groups +- Sending responses back +- Handling media and commands +""" + +import asyncio +from typing import Dict, List, Optional, Any + +try: + from telegram import Update, Bot, Message + from telegram.ext import ( + Application, + CommandHandler, + MessageHandler as TelegramMessageHandler, + ContextTypes, + filters, + ) + from telegram.constants import ParseMode, ChatType + TELEGRAM_AVAILABLE = True +except ImportError: + TELEGRAM_AVAILABLE = False + Update = Any + Bot = Any + Message = Any + Application = Any + ContextTypes = Any + +import sys +sys.path.insert(0, str(__file__).rsplit("/", 3)[0]) + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, +) + + +def check_telegram_requirements() -> bool: + """Check if Telegram dependencies are available.""" + return TELEGRAM_AVAILABLE + + +class TelegramAdapter(BasePlatformAdapter): + """ + Telegram bot adapter. + + Handles: + - Receiving messages from users and groups + - Sending responses with Telegram markdown + - Forum topics (thread_id support) + - Media messages + """ + + # Telegram message limits + MAX_MESSAGE_LENGTH = 4096 + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.TELEGRAM) + self._app: Optional[Application] = None + self._bot: Optional[Bot] = None + + async def connect(self) -> bool: + """Connect to Telegram and start polling for updates.""" + if not TELEGRAM_AVAILABLE: + print(f"[{self.name}] python-telegram-bot not installed. Run: pip install python-telegram-bot") + return False + + if not self.config.token: + print(f"[{self.name}] No bot token configured") + return False + + try: + # Build the application + self._app = Application.builder().token(self.config.token).build() + self._bot = self._app.bot + + # Register handlers + self._app.add_handler(TelegramMessageHandler( + filters.TEXT & ~filters.COMMAND, + self._handle_text_message + )) + self._app.add_handler(TelegramMessageHandler( + filters.COMMAND, + self._handle_command + )) + self._app.add_handler(TelegramMessageHandler( + filters.PHOTO | filters.VIDEO | filters.AUDIO | filters.VOICE | filters.Document.ALL, + self._handle_media_message + )) + + # Start polling in background + await self._app.initialize() + await self._app.start() + await self._app.updater.start_polling(allowed_updates=Update.ALL_TYPES) + + self._running = True + print(f"[{self.name}] Connected and polling for updates") + return True + + except Exception as e: + print(f"[{self.name}] Failed to connect: {e}") + return False + + async def disconnect(self) -> None: + """Stop polling and disconnect.""" + if self._app: + try: + await self._app.updater.stop() + await self._app.stop() + await self._app.shutdown() + except Exception as e: + print(f"[{self.name}] Error during disconnect: {e}") + + self._running = False + self._app = None + self._bot = None + print(f"[{self.name}] Disconnected") + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None + ) -> SendResult: + """Send a message to a Telegram chat.""" + if not self._bot: + return SendResult(success=False, error="Not connected") + + try: + # Format and split message if needed + formatted = self.format_message(content) + chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH) + + message_ids = [] + thread_id = metadata.get("thread_id") if metadata else None + + for i, chunk in enumerate(chunks): + msg = await self._bot.send_message( + chat_id=int(chat_id), + text=chunk, + parse_mode=ParseMode.MARKDOWN, + reply_to_message_id=int(reply_to) if reply_to and i == 0 else None, + message_thread_id=int(thread_id) if thread_id else None, + ) + message_ids.append(str(msg.message_id)) + + return SendResult( + success=True, + message_id=message_ids[0] if message_ids else None, + raw_response={"message_ids": message_ids} + ) + + except Exception as e: + return SendResult(success=False, error=str(e)) + + async def send_typing(self, chat_id: str) -> None: + """Send typing indicator.""" + if self._bot: + try: + await self._bot.send_chat_action( + chat_id=int(chat_id), + action="typing" + ) + except Exception: + pass # Ignore typing indicator failures + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + """Get information about a Telegram chat.""" + if not self._bot: + return {"name": "Unknown", "type": "dm"} + + try: + chat = await self._bot.get_chat(int(chat_id)) + + chat_type = "dm" + if chat.type == ChatType.GROUP: + chat_type = "group" + elif chat.type == ChatType.SUPERGROUP: + chat_type = "group" + if chat.is_forum: + chat_type = "forum" + elif chat.type == ChatType.CHANNEL: + chat_type = "channel" + + return { + "name": chat.title or chat.full_name or str(chat_id), + "type": chat_type, + "username": chat.username, + "is_forum": getattr(chat, "is_forum", False), + } + except Exception as e: + return {"name": str(chat_id), "type": "dm", "error": str(e)} + + def format_message(self, content: str) -> str: + """ + Format message for Telegram. + + Telegram uses a subset of markdown. We'll use the simpler + Markdown mode (not MarkdownV2) for compatibility. + """ + # Basic escaping for Telegram Markdown + # In Markdown mode (not V2), only certain characters need escaping + return content + + async def _handle_text_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle incoming text messages.""" + if not update.message or not update.message.text: + return + + event = self._build_message_event(update.message, MessageType.TEXT) + await self.handle_message(event) + + async def _handle_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle incoming command messages.""" + if not update.message or not update.message.text: + return + + event = self._build_message_event(update.message, MessageType.COMMAND) + await self.handle_message(event) + + async def _handle_media_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle incoming media messages.""" + if not update.message: + return + + msg = update.message + + # Determine media type + if msg.photo: + msg_type = MessageType.PHOTO + elif msg.video: + msg_type = MessageType.VIDEO + elif msg.audio: + msg_type = MessageType.AUDIO + elif msg.voice: + msg_type = MessageType.VOICE + else: + msg_type = MessageType.DOCUMENT + + event = self._build_message_event(msg, msg_type) + + # Add caption as text + if msg.caption: + event.text = msg.caption + + await self.handle_message(event) + + def _build_message_event(self, message: Message, msg_type: MessageType) -> MessageEvent: + """Build a MessageEvent from a Telegram message.""" + chat = message.chat + user = message.from_user + + # Determine chat type + chat_type = "dm" + if chat.type in (ChatType.GROUP, ChatType.SUPERGROUP): + chat_type = "group" + elif chat.type == ChatType.CHANNEL: + chat_type = "channel" + + # Build source + source = self.build_source( + chat_id=str(chat.id), + chat_name=chat.title or (chat.full_name if hasattr(chat, "full_name") else None), + chat_type=chat_type, + user_id=str(user.id) if user else None, + user_name=user.full_name if user else None, + thread_id=str(message.message_thread_id) if message.message_thread_id else None, + ) + + return MessageEvent( + text=message.text or "", + message_type=msg_type, + source=source, + raw_message=message, + message_id=str(message.message_id), + timestamp=message.date, + ) diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py new file mode 100644 index 000000000..b737a847d --- /dev/null +++ b/gateway/platforms/whatsapp.py @@ -0,0 +1,327 @@ +""" +WhatsApp platform adapter. + +WhatsApp integration is more complex than Telegram/Discord because: +- No official bot API for personal accounts +- Business API requires Meta Business verification +- Most solutions use web-based automation + +This adapter supports multiple backends: +1. WhatsApp Business API (requires Meta verification) +2. whatsapp-web.js (via Node.js subprocess) - for personal accounts +3. Baileys (via Node.js subprocess) - alternative for personal accounts + +For simplicity, we'll implement a generic interface that can work +with different backends via a bridge pattern. +""" + +import asyncio +import json +import subprocess +from pathlib import Path +from typing import Dict, List, Optional, Any + +import sys +sys.path.insert(0, str(__file__).rsplit("/", 3)[0]) + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, +) + + +def check_whatsapp_requirements() -> bool: + """ + Check if WhatsApp dependencies are available. + + WhatsApp requires a Node.js bridge for most implementations. + """ + # Check for Node.js + try: + result = subprocess.run( + ["node", "--version"], + capture_output=True, + text=True, + timeout=5 + ) + return result.returncode == 0 + except Exception: + return False + + +class WhatsAppAdapter(BasePlatformAdapter): + """ + WhatsApp adapter. + + This implementation uses a simple HTTP bridge pattern where: + 1. A Node.js process runs the WhatsApp Web client + 2. Messages are forwarded via HTTP/IPC to this Python adapter + 3. Responses are sent back through the bridge + + The actual Node.js bridge implementation can vary: + - whatsapp-web.js based + - Baileys based + - Business API based + + Configuration: + - bridge_script: Path to the Node.js bridge script + - bridge_port: Port for HTTP communication (default: 3000) + - session_path: Path to store WhatsApp session data + """ + + # WhatsApp message limits + MAX_MESSAGE_LENGTH = 65536 # WhatsApp allows longer messages + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.WHATSAPP) + self._bridge_process: Optional[subprocess.Popen] = None + self._bridge_port: int = config.extra.get("bridge_port", 3000) + self._bridge_script: Optional[str] = config.extra.get("bridge_script") + self._session_path: Path = Path(config.extra.get( + "session_path", + Path.home() / ".hermes" / "whatsapp" / "session" + )) + self._message_queue: asyncio.Queue = asyncio.Queue() + + async def connect(self) -> bool: + """ + Start the WhatsApp bridge. + + This launches the Node.js bridge process and waits for it to be ready. + """ + if not check_whatsapp_requirements(): + print(f"[{self.name}] Node.js not found. WhatsApp requires Node.js.") + return False + + if not self._bridge_script: + print(f"[{self.name}] No bridge script configured.") + print(f"[{self.name}] Set 'bridge_script' in whatsapp.extra config.") + print(f"[{self.name}] See docs/messaging.md for WhatsApp setup instructions.") + return False + + bridge_path = Path(self._bridge_script) + if not bridge_path.exists(): + print(f"[{self.name}] Bridge script not found: {bridge_path}") + return False + + try: + # Ensure session directory exists + self._session_path.mkdir(parents=True, exist_ok=True) + + # Start the bridge process + self._bridge_process = subprocess.Popen( + [ + "node", + str(bridge_path), + "--port", str(self._bridge_port), + "--session", str(self._session_path), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + # Wait for bridge to be ready (look for ready signal) + # This is a simplified version - real implementation would + # wait for an HTTP health check or specific stdout message + await asyncio.sleep(5) + + if self._bridge_process.poll() is not None: + stderr = self._bridge_process.stderr.read() if self._bridge_process.stderr else "" + print(f"[{self.name}] Bridge process died: {stderr}") + return False + + # Start message polling task + asyncio.create_task(self._poll_messages()) + + self._running = True + print(f"[{self.name}] Bridge started on port {self._bridge_port}") + print(f"[{self.name}] Scan QR code if prompted (check bridge output)") + return True + + except Exception as e: + print(f"[{self.name}] Failed to start bridge: {e}") + return False + + async def disconnect(self) -> None: + """Stop the WhatsApp bridge.""" + if self._bridge_process: + try: + self._bridge_process.terminate() + await asyncio.sleep(1) + if self._bridge_process.poll() is None: + self._bridge_process.kill() + except Exception as e: + print(f"[{self.name}] Error stopping bridge: {e}") + + self._running = False + self._bridge_process = None + print(f"[{self.name}] Disconnected") + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None + ) -> SendResult: + """Send a message via the WhatsApp bridge.""" + if not self._running: + return SendResult(success=False, error="Not connected") + + try: + import aiohttp + + async with aiohttp.ClientSession() as session: + payload = { + "chatId": chat_id, + "message": content, + } + if reply_to: + payload["replyTo"] = reply_to + + async with session.post( + f"http://localhost:{self._bridge_port}/send", + json=payload, + timeout=aiohttp.ClientTimeout(total=30) + ) as resp: + if resp.status == 200: + data = await resp.json() + return SendResult( + success=True, + message_id=data.get("messageId"), + raw_response=data + ) + else: + error = await resp.text() + return SendResult(success=False, error=error) + + except ImportError: + return SendResult( + success=False, + error="aiohttp not installed. Run: pip install aiohttp" + ) + except Exception as e: + return SendResult(success=False, error=str(e)) + + async def send_typing(self, chat_id: str) -> None: + """Send typing indicator via bridge.""" + if not self._running: + return + + try: + import aiohttp + + async with aiohttp.ClientSession() as session: + await session.post( + f"http://localhost:{self._bridge_port}/typing", + json={"chatId": chat_id}, + timeout=aiohttp.ClientTimeout(total=5) + ) + except Exception: + pass # Ignore typing indicator failures + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + """Get information about a WhatsApp chat.""" + if not self._running: + return {"name": "Unknown", "type": "dm"} + + try: + import aiohttp + + async with aiohttp.ClientSession() as session: + async with session.get( + f"http://localhost:{self._bridge_port}/chat/{chat_id}", + timeout=aiohttp.ClientTimeout(total=10) + ) as resp: + if resp.status == 200: + data = await resp.json() + return { + "name": data.get("name", chat_id), + "type": "group" if data.get("isGroup") else "dm", + "participants": data.get("participants", []), + } + except Exception: + pass + + return {"name": chat_id, "type": "dm"} + + async def _poll_messages(self) -> None: + """Poll the bridge for incoming messages.""" + try: + import aiohttp + except ImportError: + print(f"[{self.name}] aiohttp not installed, message polling disabled") + return + + while self._running: + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"http://localhost:{self._bridge_port}/messages", + timeout=aiohttp.ClientTimeout(total=30) + ) as resp: + if resp.status == 200: + messages = await resp.json() + for msg_data in messages: + event = self._build_message_event(msg_data) + if event: + await self.handle_message(event) + except asyncio.CancelledError: + break + except Exception as e: + print(f"[{self.name}] Poll error: {e}") + await asyncio.sleep(5) + + await asyncio.sleep(1) # Poll interval + + def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEvent]: + """Build a MessageEvent from bridge message data.""" + try: + # Determine message type + msg_type = MessageType.TEXT + if data.get("hasMedia"): + media_type = data.get("mediaType", "") + if "image" in media_type: + msg_type = MessageType.PHOTO + elif "video" in media_type: + msg_type = MessageType.VIDEO + elif "audio" in media_type or "ptt" in media_type: # ptt = voice note + msg_type = MessageType.VOICE + else: + msg_type = MessageType.DOCUMENT + + # Determine chat type + is_group = data.get("isGroup", False) + chat_type = "group" if is_group else "dm" + + # Build source + source = self.build_source( + chat_id=data.get("chatId", ""), + chat_name=data.get("chatName"), + chat_type=chat_type, + user_id=data.get("senderId"), + user_name=data.get("senderName"), + ) + + return MessageEvent( + text=data.get("body", ""), + message_type=msg_type, + source=source, + raw_message=data, + message_id=data.get("messageId"), + media_urls=data.get("mediaUrls", []), + ) + except Exception as e: + print(f"[{self.name}] Error building event: {e}") + return None + + +# Note: A reference Node.js bridge script would be provided in scripts/whatsapp-bridge/ +# It would use whatsapp-web.js or Baileys to: +# 1. Handle WhatsApp Web authentication (QR code) +# 2. Listen for incoming messages +# 3. Expose HTTP endpoints for send/receive/status diff --git a/gateway/run.py b/gateway/run.py new file mode 100644 index 000000000..dfa97c457 --- /dev/null +++ b/gateway/run.py @@ -0,0 +1,375 @@ +""" +Gateway runner - entry point for messaging platform integrations. + +This module provides: +- start_gateway(): Start all configured platform adapters +- GatewayRunner: Main class managing the gateway lifecycle + +Usage: + # Start the gateway + python -m gateway.run + + # Or from CLI + python cli.py --gateway +""" + +import asyncio +import os +import sys +import signal +from pathlib import Path +from datetime import datetime +from typing import Dict, Optional, Any, List + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from gateway.config import ( + Platform, + GatewayConfig, + load_gateway_config, +) +from gateway.session import ( + SessionStore, + SessionSource, + SessionContext, + build_session_context, + build_session_context_prompt, +) +from gateway.delivery import DeliveryRouter, DeliveryTarget +from gateway.platforms.base import BasePlatformAdapter, MessageEvent + + +class GatewayRunner: + """ + Main gateway controller. + + Manages the lifecycle of all platform adapters and routes + messages to/from the agent. + """ + + def __init__(self, config: Optional[GatewayConfig] = None): + self.config = config or load_gateway_config() + self.adapters: Dict[Platform, BasePlatformAdapter] = {} + self.session_store = SessionStore(self.config.sessions_dir, self.config) + self.delivery_router = DeliveryRouter(self.config) + self._running = False + self._shutdown_event = asyncio.Event() + + async def start(self) -> bool: + """ + Start the gateway and all configured platform adapters. + + Returns True if at least one adapter connected successfully. + """ + print("[gateway] Starting Hermes Gateway...") + print(f"[gateway] Session storage: {self.config.sessions_dir}") + + connected_count = 0 + + # Initialize and connect each configured platform + for platform, platform_config in self.config.platforms.items(): + if not platform_config.enabled: + continue + + adapter = self._create_adapter(platform, platform_config) + if not adapter: + print(f"[gateway] No adapter available for {platform.value}") + continue + + # Set up message handler + adapter.set_message_handler(self._handle_message) + + # Try to connect + print(f"[gateway] Connecting to {platform.value}...") + try: + success = await adapter.connect() + if success: + self.adapters[platform] = adapter + connected_count += 1 + print(f"[gateway] ✓ {platform.value} connected") + else: + print(f"[gateway] ✗ {platform.value} failed to connect") + except Exception as e: + print(f"[gateway] ✗ {platform.value} error: {e}") + + if connected_count == 0: + print("[gateway] No platforms connected. Check your configuration.") + return False + + # Update delivery router with adapters + self.delivery_router.adapters = self.adapters + + self._running = True + print(f"[gateway] Gateway running with {connected_count} platform(s)") + print("[gateway] Press Ctrl+C to stop") + + return True + + async def stop(self) -> None: + """Stop the gateway and disconnect all adapters.""" + print("[gateway] Stopping gateway...") + self._running = False + + for platform, adapter in self.adapters.items(): + try: + await adapter.disconnect() + print(f"[gateway] ✓ {platform.value} disconnected") + except Exception as e: + print(f"[gateway] ✗ {platform.value} disconnect error: {e}") + + self.adapters.clear() + self._shutdown_event.set() + print("[gateway] Gateway stopped") + + async def wait_for_shutdown(self) -> None: + """Wait for shutdown signal.""" + await self._shutdown_event.wait() + + def _create_adapter( + self, + platform: Platform, + config: Any + ) -> Optional[BasePlatformAdapter]: + """Create the appropriate adapter for a platform.""" + if platform == Platform.TELEGRAM: + from gateway.platforms.telegram import TelegramAdapter, check_telegram_requirements + if not check_telegram_requirements(): + print(f"[gateway] Telegram: python-telegram-bot not installed") + return None + return TelegramAdapter(config) + + elif platform == Platform.DISCORD: + from gateway.platforms.discord import DiscordAdapter, check_discord_requirements + if not check_discord_requirements(): + print(f"[gateway] Discord: discord.py not installed") + return None + return DiscordAdapter(config) + + elif platform == Platform.WHATSAPP: + from gateway.platforms.whatsapp import WhatsAppAdapter, check_whatsapp_requirements + if not check_whatsapp_requirements(): + print(f"[gateway] WhatsApp: Node.js not installed or bridge not configured") + return None + return WhatsAppAdapter(config) + + return None + + async def _handle_message(self, event: MessageEvent) -> Optional[str]: + """ + Handle an incoming message from any platform. + + This is the core message processing pipeline: + 1. Check for commands (/new, /reset, etc.) + 2. Get or create session + 3. Build context for agent + 4. Run agent conversation + 5. Return response + """ + source = event.source + + # Check for reset commands + command = event.get_command() + if command in ["new", "reset"]: + return await self._handle_reset_command(event) + + if command == "status": + return await self._handle_status_command(event) + + # Get or create session + session_entry = self.session_store.get_or_create_session(source) + + # Build session context + context = build_session_context(source, self.config, session_entry) + + # Set environment variables for tools + self._set_session_env(context) + + # Build the context prompt to inject + context_prompt = build_session_context_prompt(context) + + # Load conversation history from transcript + history = self.session_store.load_transcript(session_entry.session_id) + + try: + # Run the agent + response = await self._run_agent( + message=event.text, + context_prompt=context_prompt, + history=history, + source=source, + session_id=session_entry.session_id + ) + + # Append to transcript + self.session_store.append_to_transcript( + session_entry.session_id, + {"role": "user", "content": event.text, "timestamp": datetime.now().isoformat()} + ) + self.session_store.append_to_transcript( + session_entry.session_id, + {"role": "assistant", "content": response, "timestamp": datetime.now().isoformat()} + ) + + # Update session + self.session_store.update_session(session_entry.session_key) + + return response + + except Exception as e: + print(f"[gateway] Agent error: {e}") + return f"Sorry, I encountered an error: {str(e)}" + finally: + # Clear session env + self._clear_session_env() + + async def _handle_reset_command(self, event: MessageEvent) -> str: + """Handle /new or /reset command.""" + source = event.source + + # Get existing session key + session_key = f"agent:main:{source.platform.value}:" + \ + (f"dm" if source.chat_type == "dm" else f"{source.chat_type}:{source.chat_id}") + + # Reset the session + new_entry = self.session_store.reset_session(session_key) + + if new_entry: + return "✨ Session reset! I've started fresh with no memory of our previous conversation." + else: + # No existing session, just create one + self.session_store.get_or_create_session(source, force_new=True) + return "✨ New session started!" + + async def _handle_status_command(self, event: MessageEvent) -> str: + """Handle /status command.""" + source = event.source + session_entry = self.session_store.get_or_create_session(source) + + connected_platforms = [p.value for p in self.adapters.keys()] + + lines = [ + "📊 **Hermes Gateway Status**", + "", + f"**Session ID:** `{session_entry.session_id[:12]}...`", + f"**Created:** {session_entry.created_at.strftime('%Y-%m-%d %H:%M')}", + f"**Last Activity:** {session_entry.updated_at.strftime('%Y-%m-%d %H:%M')}", + f"**Tokens:** {session_entry.total_tokens:,}", + "", + f"**Connected Platforms:** {', '.join(connected_platforms)}", + ] + + return "\n".join(lines) + + def _set_session_env(self, context: SessionContext) -> None: + """Set environment variables for the current session.""" + os.environ["HERMES_SESSION_PLATFORM"] = context.source.platform.value + os.environ["HERMES_SESSION_CHAT_ID"] = context.source.chat_id + if context.source.chat_name: + os.environ["HERMES_SESSION_CHAT_NAME"] = context.source.chat_name + + def _clear_session_env(self) -> None: + """Clear session environment variables.""" + for var in ["HERMES_SESSION_PLATFORM", "HERMES_SESSION_CHAT_ID", "HERMES_SESSION_CHAT_NAME"]: + if var in os.environ: + del os.environ[var] + + async def _run_agent( + self, + message: str, + context_prompt: str, + history: List[Dict[str, Any]], + source: SessionSource, + session_id: str + ) -> str: + """ + Run the agent with the given message and context. + + This is run in a thread pool to not block the event loop. + """ + from run_agent import AIAgent + + # Determine toolset based on platform + toolset_map = { + Platform.LOCAL: "hermes-cli", + Platform.TELEGRAM: "hermes-telegram", + Platform.DISCORD: "hermes-discord", + Platform.WHATSAPP: "hermes-whatsapp", + } + toolset = toolset_map.get(source.platform, "hermes-telegram") + + def run_sync(): + agent = AIAgent( + model=os.getenv("HERMES_MODEL", "anthropic/claude-sonnet-4"), + quiet_mode=True, + enabled_toolsets=[toolset], + ephemeral_system_prompt=context_prompt, + session_id=session_id, + ) + + # If we have history, we need to restore it + # For now, we pass the message directly + # TODO: Implement proper history restoration + + result = agent.run_conversation(message) + return result.get("final_response", "(No response)") + + # Run in thread pool to not block + loop = asyncio.get_event_loop() + response = await loop.run_in_executor(None, run_sync) + + return response + + +async def start_gateway(config: Optional[GatewayConfig] = None) -> None: + """ + Start the gateway and run until interrupted. + + This is the main entry point for running the gateway. + """ + runner = GatewayRunner(config) + + # Set up signal handlers + def signal_handler(): + asyncio.create_task(runner.stop()) + + loop = asyncio.get_event_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(sig, signal_handler) + except NotImplementedError: + # Windows doesn't support add_signal_handler + pass + + # Start the gateway + success = await runner.start() + if not success: + return + + # Wait for shutdown + await runner.wait_for_shutdown() + + +def main(): + """CLI entry point for the gateway.""" + import argparse + + parser = argparse.ArgumentParser(description="Hermes Gateway - Multi-platform messaging") + parser.add_argument("--config", "-c", help="Path to gateway config file") + parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output") + + args = parser.parse_args() + + config = None + if args.config: + import json + with open(args.config) as f: + data = json.load(f) + config = GatewayConfig.from_dict(data) + + # Run the gateway + asyncio.run(start_gateway(config)) + + +if __name__ == "__main__": + main() diff --git a/gateway/session.py b/gateway/session.py new file mode 100644 index 000000000..d6ab462bb --- /dev/null +++ b/gateway/session.py @@ -0,0 +1,522 @@ +""" +Session management for the gateway. + +Handles: +- Session context tracking (where messages come from) +- Session storage (conversations persisted to disk) +- Reset policy evaluation (when to start fresh) +- Dynamic system prompt injection (agent knows its context) +""" + +import os +import json +import uuid +from pathlib import Path +from datetime import datetime, timedelta +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Any + +from .config import ( + Platform, + GatewayConfig, + SessionResetPolicy, + HomeChannel, +) + + +@dataclass +class SessionSource: + """ + Describes where a message originated from. + + This information is used to: + 1. Route responses back to the right place + 2. Inject context into the system prompt + 3. Track origin for cron job delivery + """ + platform: Platform + chat_id: str + chat_name: Optional[str] = None + chat_type: str = "dm" # "dm", "group", "channel", "thread" + user_id: Optional[str] = None + user_name: Optional[str] = None + thread_id: Optional[str] = None # For forum topics, Discord threads, etc. + + @property + def description(self) -> str: + """Human-readable description of the source.""" + if self.platform == Platform.LOCAL: + return "CLI terminal" + + parts = [] + if self.chat_type == "dm": + parts.append(f"DM with {self.user_name or self.user_id or 'user'}") + elif self.chat_type == "group": + parts.append(f"group: {self.chat_name or self.chat_id}") + elif self.chat_type == "channel": + parts.append(f"channel: {self.chat_name or self.chat_id}") + else: + parts.append(self.chat_name or self.chat_id) + + if self.thread_id: + parts.append(f"thread: {self.thread_id}") + + return ", ".join(parts) + + def to_dict(self) -> Dict[str, Any]: + return { + "platform": self.platform.value, + "chat_id": self.chat_id, + "chat_name": self.chat_name, + "chat_type": self.chat_type, + "user_id": self.user_id, + "user_name": self.user_name, + "thread_id": self.thread_id, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "SessionSource": + return cls( + platform=Platform(data["platform"]), + chat_id=str(data["chat_id"]), + chat_name=data.get("chat_name"), + chat_type=data.get("chat_type", "dm"), + user_id=data.get("user_id"), + user_name=data.get("user_name"), + thread_id=data.get("thread_id"), + ) + + @classmethod + def local_cli(cls) -> "SessionSource": + """Create a source representing the local CLI.""" + return cls( + platform=Platform.LOCAL, + chat_id="cli", + chat_name="CLI terminal", + chat_type="dm", + ) + + +@dataclass +class SessionContext: + """ + Full context for a session, used for dynamic system prompt injection. + + The agent receives this information to understand: + - Where messages are coming from + - What platforms are available + - Where it can deliver scheduled task outputs + """ + source: SessionSource + connected_platforms: List[Platform] + home_channels: Dict[Platform, HomeChannel] + + # Session metadata + session_key: str = "" + session_id: str = "" + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + def to_dict(self) -> Dict[str, Any]: + return { + "source": self.source.to_dict(), + "connected_platforms": [p.value for p in self.connected_platforms], + "home_channels": { + p.value: hc.to_dict() for p, hc in self.home_channels.items() + }, + "session_key": self.session_key, + "session_id": self.session_id, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } + + +def build_session_context_prompt(context: SessionContext) -> str: + """ + Build the dynamic system prompt section that tells the agent about its context. + + This is injected into the system prompt so the agent knows: + - Where messages are coming from + - What platforms are connected + - Where it can deliver scheduled task outputs + """ + lines = [ + "## Current Session Context", + "", + ] + + # Source info + platform_name = context.source.platform.value.title() + if context.source.platform == Platform.LOCAL: + lines.append(f"**Source:** {platform_name} (the machine running this agent)") + else: + lines.append(f"**Source:** {platform_name} ({context.source.description})") + + # Connected platforms + platforms_list = ["local (files on this machine)"] + for p in context.connected_platforms: + if p != Platform.LOCAL: + platforms_list.append(f"{p.value}: Connected ✓") + + lines.append(f"**Connected Platforms:** {', '.join(platforms_list)}") + + # Home channels + if context.home_channels: + lines.append("") + lines.append("**Home Channels (default destinations):**") + for platform, home in context.home_channels.items(): + lines.append(f" - {platform.value}: {home.name} (ID: {home.chat_id})") + + # Delivery options for scheduled tasks + lines.append("") + lines.append("**Delivery options for scheduled tasks:**") + + # Origin delivery + if context.source.platform == Platform.LOCAL: + lines.append("- `\"origin\"` → Local output (saved to files)") + else: + lines.append(f"- `\"origin\"` → Back to this chat ({context.source.chat_name or context.source.chat_id})") + + # Local always available + lines.append("- `\"local\"` → Save to local files only (~/.hermes/cron/output/)") + + # Platform home channels + for platform, home in context.home_channels.items(): + lines.append(f"- `\"{platform.value}\"` → Home channel ({home.name})") + + # Note about explicit targeting + lines.append("") + lines.append("*For explicit targeting, use `\"platform:chat_id\"` format if the user provides a specific chat ID.*") + + return "\n".join(lines) + + +@dataclass +class SessionEntry: + """ + Entry in the session store. + + Maps a session key to its current session ID and metadata. + """ + session_key: str + session_id: str + created_at: datetime + updated_at: datetime + + # Origin metadata for delivery routing + origin: Optional[SessionSource] = None + + # Display metadata + display_name: Optional[str] = None + platform: Optional[Platform] = None + chat_type: str = "dm" + + # Token tracking + input_tokens: int = 0 + output_tokens: int = 0 + total_tokens: int = 0 + + def to_dict(self) -> Dict[str, Any]: + result = { + "session_key": self.session_key, + "session_id": self.session_id, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), + "display_name": self.display_name, + "platform": self.platform.value if self.platform else None, + "chat_type": self.chat_type, + "input_tokens": self.input_tokens, + "output_tokens": self.output_tokens, + "total_tokens": self.total_tokens, + } + if self.origin: + result["origin"] = self.origin.to_dict() + return result + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "SessionEntry": + origin = None + if "origin" in data and data["origin"]: + origin = SessionSource.from_dict(data["origin"]) + + platform = None + if data.get("platform"): + try: + platform = Platform(data["platform"]) + except ValueError: + pass + + return cls( + session_key=data["session_key"], + session_id=data["session_id"], + created_at=datetime.fromisoformat(data["created_at"]), + updated_at=datetime.fromisoformat(data["updated_at"]), + origin=origin, + display_name=data.get("display_name"), + platform=platform, + chat_type=data.get("chat_type", "dm"), + input_tokens=data.get("input_tokens", 0), + output_tokens=data.get("output_tokens", 0), + total_tokens=data.get("total_tokens", 0), + ) + + +class SessionStore: + """ + Manages session storage and retrieval. + + Sessions are stored in: + - sessions.json: Index mapping session keys to session IDs + - {session_id}.jsonl: Conversation transcripts + """ + + def __init__(self, sessions_dir: Path, config: GatewayConfig): + self.sessions_dir = sessions_dir + self.config = config + self._entries: Dict[str, SessionEntry] = {} + self._loaded = False + + def _ensure_loaded(self) -> None: + """Load sessions from disk if not already loaded.""" + if self._loaded: + return + + self.sessions_dir.mkdir(parents=True, exist_ok=True) + sessions_file = self.sessions_dir / "sessions.json" + + if sessions_file.exists(): + try: + with open(sessions_file, "r") as f: + data = json.load(f) + for key, entry_data in data.items(): + self._entries[key] = SessionEntry.from_dict(entry_data) + except Exception as e: + print(f"[gateway] Warning: Failed to load sessions: {e}") + + self._loaded = True + + def _save(self) -> None: + """Save sessions index to disk.""" + self.sessions_dir.mkdir(parents=True, exist_ok=True) + sessions_file = self.sessions_dir / "sessions.json" + + data = {key: entry.to_dict() for key, entry in self._entries.items()} + with open(sessions_file, "w") as f: + json.dump(data, f, indent=2) + + def _generate_session_key(self, source: SessionSource) -> str: + """Generate a session key from a source.""" + platform = source.platform.value + + if source.chat_type == "dm": + # DMs share the main session per platform + return f"agent:main:{platform}:dm" + else: + # Groups/channels get their own keys + return f"agent:main:{platform}:{source.chat_type}:{source.chat_id}" + + def _should_reset(self, entry: SessionEntry, source: SessionSource) -> bool: + """ + Check if a session should be reset based on policy. + + Returns True if the session is stale and should start fresh. + """ + policy = self.config.get_reset_policy( + platform=source.platform, + session_type=source.chat_type + ) + + now = datetime.now() + + # Check idle timeout + if policy.mode in ("idle", "both"): + idle_deadline = entry.updated_at + timedelta(minutes=policy.idle_minutes) + if now > idle_deadline: + return True + + # Check daily reset + if policy.mode in ("daily", "both"): + # Find the most recent reset boundary + today_reset = now.replace( + hour=policy.at_hour, + minute=0, + second=0, + microsecond=0 + ) + if now.hour < policy.at_hour: + # Reset boundary was yesterday + today_reset -= timedelta(days=1) + + if entry.updated_at < today_reset: + return True + + return False + + def get_or_create_session( + self, + source: SessionSource, + force_new: bool = False + ) -> SessionEntry: + """ + Get an existing session or create a new one. + + Evaluates reset policy to determine if the existing session is stale. + """ + self._ensure_loaded() + + session_key = self._generate_session_key(source) + now = datetime.now() + + # Check for existing session + if session_key in self._entries and not force_new: + entry = self._entries[session_key] + + # Check if session should be reset + if not self._should_reset(entry, source): + # Update timestamp and return existing + entry.updated_at = now + self._save() + return entry + + # Create new session + session_id = f"{now.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}" + + entry = SessionEntry( + session_key=session_key, + session_id=session_id, + created_at=now, + updated_at=now, + origin=source, + display_name=source.chat_name, + platform=source.platform, + chat_type=source.chat_type, + ) + + self._entries[session_key] = entry + self._save() + + return entry + + def update_session( + self, + session_key: str, + input_tokens: int = 0, + output_tokens: int = 0 + ) -> None: + """Update a session's metadata after an interaction.""" + self._ensure_loaded() + + if session_key in self._entries: + entry = self._entries[session_key] + entry.updated_at = datetime.now() + entry.input_tokens += input_tokens + entry.output_tokens += output_tokens + entry.total_tokens = entry.input_tokens + entry.output_tokens + self._save() + + def reset_session(self, session_key: str) -> Optional[SessionEntry]: + """Force reset a session, creating a new session ID.""" + self._ensure_loaded() + + if session_key not in self._entries: + return None + + old_entry = self._entries[session_key] + now = datetime.now() + session_id = f"{now.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}" + + new_entry = SessionEntry( + session_key=session_key, + session_id=session_id, + created_at=now, + updated_at=now, + origin=old_entry.origin, + display_name=old_entry.display_name, + platform=old_entry.platform, + chat_type=old_entry.chat_type, + ) + + self._entries[session_key] = new_entry + self._save() + + return new_entry + + def list_sessions(self, active_minutes: Optional[int] = None) -> List[SessionEntry]: + """ + List all sessions, optionally filtered by activity. + + Args: + active_minutes: If provided, only return sessions updated within this many minutes + """ + self._ensure_loaded() + + entries = list(self._entries.values()) + + if active_minutes is not None: + cutoff = datetime.now() - timedelta(minutes=active_minutes) + entries = [e for e in entries if e.updated_at >= cutoff] + + # Sort by most recently updated + entries.sort(key=lambda e: e.updated_at, reverse=True) + + return entries + + def get_transcript_path(self, session_id: str) -> Path: + """Get the path to a session's transcript file.""" + return self.sessions_dir / f"{session_id}.jsonl" + + def append_to_transcript(self, session_id: str, message: Dict[str, Any]) -> None: + """Append a message to a session's transcript.""" + transcript_path = self.get_transcript_path(session_id) + + with open(transcript_path, "a") as f: + f.write(json.dumps(message, ensure_ascii=False) + "\n") + + def load_transcript(self, session_id: str) -> List[Dict[str, Any]]: + """Load all messages from a session's transcript.""" + transcript_path = self.get_transcript_path(session_id) + + if not transcript_path.exists(): + return [] + + messages = [] + with open(transcript_path, "r") as f: + for line in f: + line = line.strip() + if line: + messages.append(json.loads(line)) + + return messages + + +def build_session_context( + source: SessionSource, + config: GatewayConfig, + session_entry: Optional[SessionEntry] = None +) -> SessionContext: + """ + Build a full session context from a source and config. + + This is used to inject context into the agent's system prompt. + """ + connected = config.get_connected_platforms() + + home_channels = {} + for platform in connected: + home = config.get_home_channel(platform) + if home: + home_channels[platform] = home + + context = SessionContext( + source=source, + connected_platforms=connected, + home_channels=home_channels, + ) + + if session_entry: + context.session_key = session_entry.session_key + context.session_id = session_entry.session_id + context.created_at = session_entry.created_at + context.updated_at = session_entry.updated_at + + return context diff --git a/hermes_agent.egg-info/PKG-INFO b/hermes_agent.egg-info/PKG-INFO new file mode 100644 index 000000000..159a40692 --- /dev/null +++ b/hermes_agent.egg-info/PKG-INFO @@ -0,0 +1,868 @@ +Metadata-Version: 2.4 +Name: hermes-agent +Version: 0.1.0 +Summary: AI agent with advanced tool-calling and toolsets +Author: Nous Research +License: MIT +Requires-Python: >=3.10 +Description-Content-Type: text/markdown +Requires-Dist: openai +Requires-Dist: python-dotenv +Requires-Dist: fire +Requires-Dist: httpx +Requires-Dist: rich +Requires-Dist: tenacity +Requires-Dist: pyyaml +Requires-Dist: requests +Requires-Dist: jinja2 +Requires-Dist: pydantic>=2.0 +Requires-Dist: firecrawl-py +Requires-Dist: fal-client +Requires-Dist: litellm>=1.75.5 +Requires-Dist: typer +Requires-Dist: platformdirs +Provides-Extra: modal +Requires-Dist: modal; extra == "modal" +Requires-Dist: boto3; extra == "modal" +Provides-Extra: dev +Requires-Dist: pytest; extra == "dev" +Requires-Dist: pytest-asyncio; extra == "dev" +Provides-Extra: messaging +Requires-Dist: python-telegram-bot>=20.0; extra == "messaging" +Requires-Dist: discord.py>=2.0; extra == "messaging" +Provides-Extra: cron +Requires-Dist: croniter; extra == "cron" +Provides-Extra: all +Requires-Dist: croniter; extra == "all" +Requires-Dist: python-telegram-bot>=20.0; extra == "all" +Requires-Dist: discord.py>=2.0; extra == "all" + +# Hermes Agent + +An AI agent with advanced tool-calling capabilities, featuring a flexible toolsets system for organizing and managing tools. + +## Features + +- **Interactive CLI**: Beautiful terminal interface with animated feedback, personalities, and session management +- **Messaging Gateway**: Connect to Telegram, Discord, and WhatsApp for conversational AI anywhere +- **Web Tools**: Search, extract content, and crawl websites +- **Terminal Tools**: Execute commands via local, Docker, Singularity, Modal, or SSH backends +- **Browser Tools**: Automate web browsers to navigate, click, type, and extract content +- **Vision Tools**: Analyze images from URLs +- **Reasoning Tools**: Advanced multi-model reasoning (Mixture of Agents) +- **Creative Tools**: Generate images from text prompts +- **Skills Tools**: On-demand knowledge documents with progressive disclosure +- **Toolsets System**: Organize tools into logical groups for different scenarios +- **Scheduled Tasks**: Cron jobs for automated agent tasks with delivery to platforms +- **Context Compression**: Automatic summarization when approaching context limits +- **Batch Processing**: Process datasets in parallel with checkpointing and statistics tracking +- **Ephemeral System Prompts**: Guide model behavior without polluting training datasets + +## Installation + +### Quick Install (Recommended) + +**Linux/macOS:** +```bash +curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +``` + +**Windows (PowerShell):** +```powershell +irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex +``` + +This installer will: +- Clone the repository to `~/.hermes-agent` +- Create a virtual environment and install dependencies +- Set up the `hermes` command in your PATH +- Run an interactive setup wizard to configure API keys + +### Manual Installation + +If you prefer to install manually: + +```bash +# Clone with submodules +git clone --recurse-submodules https://github.com/NousResearch/Hermes-Agent.git +cd Hermes-Agent + +# Run the setup script +./setup-hermes.sh +``` + +Or step-by-step: + +```bash +# Create and activate virtual environment +python3 -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate + +# Install in editable mode with all extras +pip install -e ".[all]" + +# Or install dependencies manually +pip install -r requirements.txt +pip install -e ./mini-swe-agent + +# Copy and configure environment +cp .env.example .env +# Edit .env with your API keys + +# Run the setup wizard +hermes setup +``` + +## Quick Start + +Once installed, the `hermes` command is your main entry point: + +```bash +hermes # Interactive chat (default) +hermes chat # Same as above +hermes chat -q "Hello" # Single query, then exit +hermes setup # Configure API keys and settings +hermes status # Show configuration status +hermes doctor # Diagnose issues +hermes gateway # Start messaging gateway (Telegram/Discord/WhatsApp) +hermes cron daemon # Run cron job scheduler +hermes version # Show version info +``` + +**Legacy `./hermes` script:** +```bash +# The old CLI script still works: +./hermes + +# Or with options: +./hermes --model "anthropic/claude-sonnet-4" --toolsets "web,terminal" +``` + +The CLI provides: +- Animated spinners during thinking and tool execution +- Kawaii-style feedback messages +- `/commands` for configuration, history, and session management +- Customizable personalities (`/personality kawaii`, `/personality pirate`, etc.) +- Persistent configuration via `cli-config.yaml` + +## Configuration + +### Environment Variables +```bash +# Copy the example environment file +cp .env.example .env + +# Edit .env and add your API keys +nano .env # or use your preferred editor +``` + +**Required API Keys:** +- `OPENROUTER_API_KEY` - LLM access via OpenRouter (get at: https://openrouter.ai/keys) +- `FIRECRAWL_API_KEY` - Web tools (get at: https://firecrawl.dev/) +- `NOUS_API_KEY` - Vision & reasoning tools (get at: https://inference-api.nousresearch.com/) +- `FAL_KEY` - Image generation (get at: https://fal.ai/) + +**Optional API Keys (for specific features):** +- `BROWSERBASE_API_KEY` - Browser automation (get at: https://browserbase.com/) +- `BROWSERBASE_PROJECT_ID` - From Browserbase dashboard +- `MORPH_API_KEY` - For legacy Hecate terminal backend (get at: https://morph.so/) + +### 4. Configure Terminal Backend + +The terminal tool uses **mini-swe-agent** environments. Configure in `.env` or `cli-config.yaml`: + +```bash +# Backend: "local", "docker", "singularity", "modal", or "ssh" +TERMINAL_ENV=local # Default: runs on host machine (no isolation) +TERMINAL_ENV=ssh # Remote execution via SSH (agent code stays local) +TERMINAL_ENV=singularity # Recommended for HPC: Apptainer/Singularity containers +TERMINAL_ENV=docker # Isolated Docker containers +TERMINAL_ENV=modal # Cloud execution via Modal + +# Container image (for docker/singularity/modal backends) +TERMINAL_DOCKER_IMAGE=python:3.11-slim +TERMINAL_SINGULARITY_IMAGE=docker://python:3.11-slim +TERMINAL_TIMEOUT=60 + +# SSH backend (for ssh) +TERMINAL_SSH_HOST=my-server.example.com +TERMINAL_SSH_USER=myuser +TERMINAL_SSH_KEY=~/.ssh/id_rsa # Optional, uses ssh-agent if not set +``` + +**Backend Requirements:** +- **local**: No extra setup (runs directly on your machine, no isolation) +- **ssh**: SSH access to remote machine (great for sandboxing - agent can't touch its own code) +- **singularity**: Requires Apptainer or Singularity installed (common on HPC clusters, no root needed) +- **docker**: Requires Docker installed and user in `docker` group +- **modal**: Requires Modal account (see setup below) + +### Singularity/Apptainer Setup (Recommended for HPC) + +Singularity/Apptainer provides rootless container execution, ideal for HPC clusters: + +```bash +# 1. Verify Apptainer is installed +apptainer --version # or: singularity --version + +# 2. Set up cache directories (important for parallel workers) +# Use /scratch if available (HPC), otherwise /tmp +export APPTAINER_CACHEDIR=/scratch/$USER/.apptainer +export APPTAINER_TMPDIR=/scratch/$USER/.apptainer/tmp +mkdir -p "$APPTAINER_CACHEDIR" "$APPTAINER_TMPDIR" + +# 3. Pre-build SIF image (recommended for parallel batch processing) +# This avoids race conditions when multiple workers start simultaneously +apptainer build $APPTAINER_CACHEDIR/python-nodejs.sif docker://nikolaik/python-nodejs:python3.11-nodejs20 + +# 4. Configure .env to use the local SIF +TERMINAL_ENV=singularity +TERMINAL_SINGULARITY_IMAGE=/scratch/$USER/.apptainer/python-nodejs.sif +``` + +**Tip:** The batch scripts in `configs/` automatically handle SIF pre-building if `/scratch` is available. + +### Modal Cloud Backend Setup + +[Modal](https://modal.com) provides serverless cloud compute for running sandboxed environments at scale. + +```bash +# 1. Install Modal and dependencies +pip install modal boto3 + +# 2. Authenticate with Modal (opens browser) +modal setup + +# 3. Set terminal backend to modal in .env +TERMINAL_ENV=modal +``` + +Modal uses CLI-based authentication (stored in `~/.modal/`), so no API key is needed in `.env`. After running `modal setup`, commands will automatically execute in Modal's cloud sandboxes. + +### Browser Tools Setup + +Browser tools enable the agent to navigate websites, fill forms, click buttons, and extract content. They use [agent-browser](https://github.com/vercel-labs/agent-browser) CLI with [Browserbase](https://browserbase.com) cloud execution. + +```bash +# 1. Install Node.js (if not already installed) +# Use nvm (recommended) or your package manager + +# 2. Install agent-browser CLI (choose one option): +npm install -g agent-browser # Option A: Global install (recommended) +npm install # Option B: Local install (uses npx fallback) + +# 3. Get Browserbase credentials +# Sign up at https://browserbase.com/ and get your: +# - API Key (from Settings → API Keys) +# - Project ID (from your project dashboard) + +# 4. Add to your .env file: +BROWSERBASE_API_KEY=your_api_key_here +BROWSERBASE_PROJECT_ID=your_project_id_here +``` + +**Available Browser Tools:** + +| Tool | Description | +|------|-------------| +| `browser_navigate` | Navigate to a URL | +| `browser_snapshot` | Get text-based page snapshot with element refs | +| `browser_click` | Click an element by ref (e.g., `@e5`) | +| `browser_type` | Type text into an input field | +| `browser_scroll` | Scroll up or down | +| `browser_back` | Go back in browser history | +| `browser_press` | Press a keyboard key (Enter, Tab, etc.) | +| `browser_close` | Close the browser session | +| `browser_get_images` | Get list of images on the page | + +**Example Usage:** +```bash +# Use browser tools with web search and vision +python run_agent.py \ + --query "Go to amazon.com and find the price of the latest Kindle" \ + --enabled_toolsets=browser,web,vision + +# Use browser-focused distribution +python batch_runner.py \ + --dataset_file=browser_tasks.jsonl \ + --distribution=browser_use \ + --run_name=browser_run +``` + +See `.env.example` for all available configuration options including debug settings. + +### Skills Tools + +Skills are on-demand knowledge documents the agent can load when needed. They follow a **progressive disclosure** pattern to minimize token usage: + +``` +skills/ +├── mlops/ # Category folder +│ ├── axolotl/ # Skill folder +│ │ ├── SKILL.md # Main instructions (required) +│ │ ├── references/ # Additional docs, API specs +│ │ └── templates/ # Output formats, configs +│ └── vllm/ +│ └── SKILL.md +``` + +**Available Skills Tools:** + +| Tool | Description | +|------|-------------| +| `skills_categories` | List available skill categories (~50 tokens) | +| `skills_list` | List skills with name + description (~3k tokens for 40 skills) | +| `skill_view` | Load full skill content, tags, and linked files | + +**Example Usage:** +```bash +# Use skills tools +python run_agent.py \ + --query "What skills do you have for fine-tuning? Show me the axolotl skill." \ + --enabled_toolsets=skills +``` + +**Creating Skills:** + +Skills use YAML frontmatter for metadata: +```yaml +--- +name: my-skill +description: Brief description shown in skills_list +tags: [tag1, tag2] +related_skills: [other-skill] +version: 1.0.0 +--- +# Skill Content + +Instructions, examples, and guidelines here... +``` + +Skills can include: +- `references/` - Additional documentation, API specs, examples +- `templates/` - Output formats, config files, boilerplate code +- `scripts/` - Executable helpers (Python, shell scripts) + +## Session Logging + +Every conversation is automatically logged to `logs/` for debugging and inspection: + +``` +logs/ +├── session_20260201_143052_a1b2c3.json +├── session_20260201_150217_d4e5f6.json +└── ... +``` + +**Log Format:** +```json +{ + "session_id": "20260201_143052_a1b2c3", + "model": "anthropic/claude-sonnet-4", + "session_start": "2026-02-01T14:30:52.123456", + "last_updated": "2026-02-01T14:35:12.789012", + "message_count": 8, + "conversations": [ + {"from": "system", "value": "..."}, + {"from": "human", "value": "..."}, + {"from": "gpt", "value": "..."}, + {"from": "tool", "value": "..."} + ] +} +``` + +- **Automatic**: Logs are created and updated automatically after each conversation turn +- **Session ID in Banner**: The CLI displays the session ID in the welcome banner +- **Trajectory Format**: Uses the same format as batch processing for consistency +- **Git Ignored**: `logs/` is in `.gitignore` so logs aren't committed + +## Context Compression + +Long conversations can exceed the model's context limit. Hermes Agent automatically compresses context when approaching the limit: + +**How it works:** +1. Tracks actual token usage from API responses (`usage.prompt_tokens`) +2. When tokens reach 85% of model's context limit, triggers compression +3. Protects first 3 turns (system prompt, initial request, first response) +4. Protects last 4 turns (recent context is most relevant) +5. Summarizes middle turns using a fast/cheap model (Gemini Flash) +6. Inserts summary as a user message, conversation continues seamlessly + +**Configuration (`cli-config.yaml`):** +```yaml +compression: + enabled: true # Enable auto-compression (default) + threshold: 0.85 # Compress at 85% of context limit + summary_model: "google/gemini-2.0-flash-001" +``` + +**Or via environment variables:** +```bash +CONTEXT_COMPRESSION_ENABLED=true +CONTEXT_COMPRESSION_THRESHOLD=0.85 +CONTEXT_COMPRESSION_MODEL=google/gemini-2.0-flash-001 +``` + +**When compression triggers, you'll see:** +``` +📦 Context compression triggered (170,000 tokens ≥ 170,000 threshold) + 📊 Model context limit: 200,000 tokens (85% = 170,000) + 🗜️ Summarizing turns 4-15 (12 turns) + ✅ Compressed: 20 → 9 messages (~45,000 tokens saved) +``` + +## Scheduled Tasks (Cron Jobs) + +Hermes Agent can schedule automated tasks to run in the future - either one-time reminders or recurring jobs. + +### CLI Commands + +```bash +# List scheduled jobs +/cron + +# Add a one-shot reminder (runs once in 30 minutes) +/cron add 30m Remind me to check the build status + +# Add a recurring job (every 2 hours) +/cron add "every 2h" Check server status at 192.168.1.100 and report any issues + +# Add a cron expression (daily at 9am) +/cron add "0 9 * * *" Generate a morning briefing summarizing GitHub notifications + +# Remove a job +/cron remove abc123def456 +``` + +### Agent Self-Scheduling + +The agent can also schedule its own follow-up tasks using tools: + +```python +# Available when using hermes-cli toolset (default for CLI) +schedule_cronjob(prompt="...", schedule="30m", repeat=1) # One-shot +schedule_cronjob(prompt="...", schedule="every 2h") # Recurring +list_cronjobs() # View all jobs +remove_cronjob(job_id="...") # Cancel a job +``` + +**⚠️ Important:** Cronjobs run in **isolated sessions with NO prior context**. The prompt must be completely self-contained with all necessary information (file paths, URLs, server addresses, etc.). The future agent will not remember anything from the current conversation. + +### Schedule Formats + +| Format | Example | Description | +|--------|---------|-------------| +| Duration | `30m`, `2h`, `1d` | One-shot delay from now | +| Interval | `every 30m`, `every 2h` | Recurring at fixed intervals | +| Cron | `0 9 * * *` | Cron expression (requires `croniter`) | +| Timestamp | `2026-02-03T14:00` | One-shot at specific time | + +### Repeat Options + +| repeat | Behavior | +|--------|----------| +| (omitted) | One-shot schedules run once; intervals/cron run forever | +| `1` | Run once then auto-delete | +| `N` | Run N times then auto-delete | + +### Running the Cron Daemon + +Jobs are stored in `~/.hermes/cron/jobs.json` and executed by a scheduler: + +```bash +# Option 1: Built-in daemon (checks every 60 seconds) +python cli.py --cron-daemon + +# Option 2: System cron integration (run once per minute) +# Add to crontab: crontab -e +*/1 * * * * cd ~/hermes-agent && python cli.py --cron-tick-once >> ~/.hermes/cron/cron.log 2>&1 +``` + +### Job Output + +Job outputs are saved to `~/.hermes/cron/output/{job_id}/{timestamp}.md` for review. + +## Messaging Gateway (Telegram, Discord, WhatsApp) + +Connect Hermes Agent to messaging platforms so you can chat from anywhere. + +### Quick Start + +```bash +# 1. Add your bot token to .env +echo 'TELEGRAM_BOT_TOKEN="your_token"' >> .env + +# 2. Test the gateway (foreground) +./scripts/hermes-gateway run + +# 3. Install as a background service +./scripts/hermes-gateway install + +# 4. Manage the service +./scripts/hermes-gateway start # Start +./scripts/hermes-gateway stop # Stop +./scripts/hermes-gateway status # Check status +``` + +### Supported Platforms + +| Platform | Setup | Toolset | +|----------|-------|---------| +| Telegram | Bot via @BotFather | `hermes-telegram` | +| Discord | Bot via Developer Portal | `hermes-discord` | +| WhatsApp | Node.js bridge | `hermes-whatsapp` | + +### Session Management + +- Sessions persist across messages (agent remembers context) +- Reset policies: daily (4am), idle (2 hours), or both +- Manual reset: send `/new` or `/reset` + +### Cron Job Delivery + +Schedule tasks that deliver to specific platforms: + +```python +schedule_cronjob( + prompt="Check server status...", + schedule="every 1h", + deliver="telegram" # or "origin", "discord", etc. +) +``` + +### CLI Commands + +| Command | Description | +|---------|-------------| +| `/platforms` | Show gateway configuration status | +| `--gateway` | Start the gateway (CLI flag) | + +See [docs/messaging.md](docs/messaging.md) for full setup instructions. + +## Interactive CLI + +The CLI provides a rich interactive experience for working with the agent. + +### Running the CLI + +```bash +# Basic usage +./hermes + +# With specific model +./hermes --model "anthropic/claude-sonnet-4" + +# With specific toolsets +./hermes --toolsets "web,terminal,skills" +``` + +### CLI Commands + +| Command | Description | +|---------|-------------| +| `/help` | Show available commands | +| `/tools` | List available tools by toolset | +| `/toolsets` | List available toolsets | +| `/model [name]` | Show or change the current model | +| `/prompt [text]` | View/set custom system prompt | +| `/personality [name]` | Set a predefined personality | +| `/clear` | Clear screen and reset conversation | +| `/reset` | Reset conversation only | +| `/history` | Show conversation history | +| `/save` | Save current conversation to file | +| `/config` | Show current configuration | +| `/cron` | Manage scheduled tasks (list, add, remove) | +| `/platforms` | Show gateway/messaging platform status | +| `/quit` | Exit the CLI | + +### Configuration + +Copy `cli-config.yaml.example` to `cli-config.yaml` and customize: + +```yaml +# Model settings +model: + default: "anthropic/claude-sonnet-4" + +# Terminal backend (local, docker, singularity, modal, or ssh) +terminal: + env_type: "local" + cwd: "." # Use current directory + +# Or use SSH for remote execution (keeps agent code isolated) +# terminal: +# env_type: "ssh" +# ssh_host: "my-server.example.com" +# ssh_user: "myuser" +# ssh_key: "~/.ssh/id_rsa" +# cwd: "/home/myuser/project" + +# Enable specific toolsets +toolsets: + - all # or: web, terminal, browser, vision, etc. + +# Custom personalities (use with /personality command) +agent: + personalities: + helpful: "You are a helpful assistant." + kawaii: "You are a kawaii assistant! Use cute expressions..." +``` + +### Personalities + +Built-in personalities available via `/personality`: +- `helpful`, `concise`, `technical`, `creative`, `teacher` +- `kawaii`, `catgirl`, `pirate`, `shakespeare`, `surfer` +- `noir`, `uwu`, `philosopher`, `hype` + +## Toolsets System + +The agent uses a toolsets system for organizing and managing tools. All tools must be part of a toolset to be accessible - individual tool selection is not supported. This ensures consistent and logical grouping of capabilities. + +### Key Concepts + +- **Toolsets**: Logical groups of tools for specific use cases (e.g., "research", "development", "debugging") +- **Composition**: Toolsets can include other toolsets for powerful combinations +- **Custom Toolsets**: Create your own toolsets at runtime or by editing `toolsets.py` +- **Toolset-Only Access**: Tools are only accessible through toolsets, not individually + +### Available Toolsets + +See `toolsets.py` for the complete list of predefined toolsets including: +- Basic toolsets (web, terminal, vision, creative, reasoning) +- Composite toolsets (research, development, analysis, etc.) +- Scenario-specific toolsets (debugging, documentation, API testing, etc.) +- Special toolsets (safe mode without terminal, minimal, offline) + +### Using Toolsets + +```bash +# Use a predefined toolset +python run_agent.py --enabled_toolsets=research --query "Find latest AI papers" + +# Combine multiple toolsets +python run_agent.py --enabled_toolsets=web,vision --query "Analyze this website" + +# Enable all toolsets explicitly (same as omitting the flag) +python run_agent.py --enabled_toolsets=all --query "Do web research and run commands if helpful" + +# Safe mode (no terminal access) +python run_agent.py --enabled_toolsets=safe --query "Help without running commands" + +# List all available toolsets and tools +python run_agent.py --list_tools +``` + +See `toolsets.py` for the complete list of available toolsets and how to create custom ones. + +## Basic Usage + +### Default (all tools enabled) +```bash +# Uses OpenRouter by default - just set OPENROUTER_API_KEY in .env +python run_agent.py \ + --query "search up the latest docs on jit in python 3.13 and write me basic example that's not in their docs. profile its perf" \ + --max_turns 20 \ + --model anthropic/claude-sonnet-4-20250514 +``` + +### With specific toolset +```bash +python run_agent.py \ + --query "Debug this Python error" \ + --enabled_toolsets=debugging \ + --model anthropic/claude-sonnet-4-20250514 +``` + +### Python API +```python +from run_agent import AIAgent + +# Uses OpenRouter by default (reads OPENROUTER_API_KEY from .env) +agent = AIAgent( + model="anthropic/claude-sonnet-4-20250514", + enabled_toolsets=["research"] +) +response = agent.chat("Find information about quantum computing") + +# Create custom toolset at runtime +from toolsets import create_custom_toolset + +create_custom_toolset( + name="my_tools", + description="My custom toolkit", + tools=["web_search"], + includes=["terminal", "vision"] +) + +agent = AIAgent(enabled_toolsets=["my_tools"]) +``` + +## Batch Processing + +Process multiple prompts from a dataset in parallel with automatic checkpointing and statistics tracking: + +```bash +# Basic batch processing +python batch_runner.py \ + --dataset_file=prompts.jsonl \ + --batch_size=20 \ + --run_name=my_run + +# With specific distribution +python batch_runner.py \ + --dataset_file=prompts.jsonl \ + --batch_size=20 \ + --run_name=image_run \ + --distribution=image_gen \ + --num_workers=4 +``` + +**Key Features:** +- Parallel processing with configurable workers +- Toolset distributions for varied data generation +- Automatic checkpointing and resume capability +- Combined output in `data//trajectories.jsonl` +- Tool usage statistics and success rates + +Use `--list_distributions` to see available toolset distributions for varied data generation. + +### Trajectory Compression + +Post-process trajectories to fit within token budgets for training: + +```bash +# Compress a directory of JSONL files +python trajectory_compressor.py --input=data/my_run + +# Compress a single JSONL file +python trajectory_compressor.py --input=data/trajectories.jsonl + +# Compress a 15% sample (useful for creating smaller training sets) +python trajectory_compressor.py --input=data/trajectories.jsonl --sample_percent=15 + +# Custom output and token target +python trajectory_compressor.py \ + --input=data/trajectories.jsonl \ + --output=data/compressed.jsonl \ + --target_max_tokens=16000 +``` + +**Features:** +- Protects first turns (system, human, first GPT response, first tool call) +- Protects last N turns (configurable) +- Summarizes middle turns using LLM to fit target token budget +- Supports both directory and single file input +- Optional random sampling with `--sample_percent` +- Configurable via `configs/trajectory_compression.yaml` + +### Ephemeral System Prompts + +The ephemeral system prompt feature allows you to guide the model's behavior during batch processing **without** saving that prompt to the training dataset trajectories. This is useful for: + +- Guiding model behavior during data collection +- Adding task-specific instructions +- Keeping saved trajectories clean and focused on tool-calling format + +**Example:** +```bash +python batch_runner.py \ + --dataset_file=prompts.jsonl \ + --batch_size=10 \ + --run_name=my_run \ + --ephemeral_system_prompt="You are a helpful assistant focused on image generation." +``` + +The ephemeral prompt will influence the model's behavior during execution, but **only the standard tool-calling system prompt** will be saved in the trajectory files. + +The ephemeral prompt influences model behavior during execution, but **only the standard tool-calling system prompt** is saved in trajectory files. + +## Command Line Arguments + +**Single Agent (`run_agent.py`):** +- `--query`: The question or task for the agent +- `--model`: Model to use (default: claude-opus-4-20250514) +- `--api_key`: API key for authentication +- `--base_url`: API endpoint URL +- `--max_turns`: Maximum number of tool-calling iterations +- `--enabled_toolsets`: Comma-separated list of toolsets to enable. Use `all` (or `*`) to enable everything. If omitted, all toolsets are enabled by default. +- `--disabled_toolsets`: Comma-separated list of toolsets to disable +- `--list_tools`: List all available toolsets and tools +- `--save_trajectories`: Save conversation trajectories to JSONL files + +**Batch Processing (`batch_runner.py`):** +- `--dataset_file`: Path to JSONL file with prompts +- `--batch_size`: Number of prompts per batch +- `--run_name`: Name for this run (for output/checkpointing) +- `--distribution`: Toolset distribution to use (default: "default") +- `--num_workers`: Number of parallel workers (default: 4) +- `--resume`: Resume from checkpoint if interrupted +- `--ephemeral_system_prompt`: System prompt used during execution but NOT saved to trajectories +- `--list_distributions`: List available toolset distributions + +## Environment Variables + +All environment variables can be configured in the `.env` file (copy from `.env.example`). + +**LLM Provider (OpenRouter):** +- `OPENROUTER_API_KEY`: Primary LLM access via OpenRouter (supports Claude, GPT-4, Gemini, etc.) +- `LLM_MODEL`: Default model (e.g., `anthropic/claude-sonnet-4`, `openai/gpt-4o`) + +**Tool API Keys:** +- `FIRECRAWL_API_KEY`: Web tools (search, extract, crawl) +- `NOUS_API_KEY`: Vision and reasoning tools +- `FAL_KEY`: Image generation tools + +**Terminal Tool Configuration (mini-swe-agent backend):** +- `TERMINAL_ENV`: Backend type - `local`, `docker`, `singularity`, `modal`, or `ssh` (default: `local`) +- `TERMINAL_DOCKER_IMAGE`: Docker image for docker backend (default: `python:3.11-slim`) +- `TERMINAL_SINGULARITY_IMAGE`: Singularity/Apptainer image (can be `docker://...` URL or local `.sif` path) +- `TERMINAL_TIMEOUT`: Command timeout in seconds (default: `60`) +- `TERMINAL_LIFETIME_SECONDS`: Cleanup inactive environments after this time (default: `300`) +- `TERMINAL_CWD`: Working directory inside containers (default: `/tmp`) +- `TERMINAL_SCRATCH_DIR`: Custom scratch directory for sandbox storage (optional, auto-detects `/scratch`) +- `SUDO_PASSWORD`: Enable sudo commands by piping password via `sudo -S` (works with all backends) + - If unset in CLI mode, you'll be prompted interactively when sudo is needed (45s timeout) + +**SSH Backend Configuration (for remote execution):** +- `TERMINAL_SSH_HOST`: Remote server hostname or IP +- `TERMINAL_SSH_USER`: SSH username +- `TERMINAL_SSH_PORT`: SSH port (default: `22`) +- `TERMINAL_SSH_KEY`: Path to SSH private key (optional, uses ssh-agent if not set) + +**Context Compression (auto-shrinks long conversations):** +- `CONTEXT_COMPRESSION_ENABLED`: Enable auto-compression (default: `true`) +- `CONTEXT_COMPRESSION_THRESHOLD`: Compress at this % of context limit (default: `0.85`) +- `CONTEXT_COMPRESSION_MODEL`: Model for generating summaries (default: `google/gemini-2.0-flash-001`) + +**Browser Tool Configuration (agent-browser + Browserbase):** +- `BROWSERBASE_API_KEY`: Browserbase API key for cloud browser execution +- `BROWSERBASE_PROJECT_ID`: Browserbase project ID +- `BROWSER_SESSION_TIMEOUT`: Session timeout in seconds (default: `300`) + +**Legacy Hecate Terminal Backend (optional):** +- `MORPH_API_KEY`: For Hecate/MorphCloud terminal backend +- `HECATE_VM_LIFETIME_SECONDS`: VM lifetime (default: 300) +- `HECATE_DEFAULT_SNAPSHOT_ID`: Default snapshot (default: snapshot_p5294qxt) + +**Debug Options:** +- `WEB_TOOLS_DEBUG`, `VISION_TOOLS_DEBUG`, `MOA_TOOLS_DEBUG`, `IMAGE_TOOLS_DEBUG`: Enable debug logging + +## Key Files + +| File | Purpose | +|------|---------| +| `hermes` | CLI launcher script (run with `./hermes`) | +| `cli.py` | Interactive CLI implementation | +| `cli-config.yaml` | CLI configuration (copy from `.example`) | +| `run_agent.py` | Main agent runner - single query execution | +| `batch_runner.py` | Parallel batch processing with checkpointing | +| `model_tools.py` | Core tool definitions and handlers | +| `toolsets.py` | Toolset definitions and composition | +| `toolset_distributions.py` | Probability distributions for data generation | +| `trajectory_compressor.py` | Post-process trajectories for training | +| `tools/` | Individual tool implementations | +| `tools/skills_tool.py` | Skills system with progressive disclosure | +| `skills/` | On-demand knowledge documents | +| `docs/` | Documentation | +| `configs/` | Example batch run scripts | diff --git a/hermes_agent.egg-info/SOURCES.txt b/hermes_agent.egg-info/SOURCES.txt new file mode 100644 index 000000000..d94b105e0 --- /dev/null +++ b/hermes_agent.egg-info/SOURCES.txt @@ -0,0 +1,47 @@ +README.md +batch_runner.py +cli.py +model_tools.py +pyproject.toml +run_agent.py +toolset_distributions.py +toolsets.py +trajectory_compressor.py +cron/__init__.py +cron/jobs.py +cron/scheduler.py +gateway/__init__.py +gateway/config.py +gateway/delivery.py +gateway/run.py +gateway/session.py +hermes_agent.egg-info/PKG-INFO +hermes_agent.egg-info/SOURCES.txt +hermes_agent.egg-info/dependency_links.txt +hermes_agent.egg-info/entry_points.txt +hermes_agent.egg-info/requires.txt +hermes_agent.egg-info/top_level.txt +hermes_cli/__init__.py +hermes_cli/cron.py +hermes_cli/doctor.py +hermes_cli/gateway.py +hermes_cli/main.py +hermes_cli/setup.py +hermes_cli/status.py +tests/test_batch_runner.py +tests/test_checkpoint_resumption.py +tests/test_modal_terminal.py +tests/test_nous_api_limits.py +tests/test_nous_api_pattern.py +tests/test_temperature_fix.py +tests/test_web_tools.py +tools/__init__.py +tools/browser_tool.py +tools/cronjob_tools.py +tools/image_generation_tool.py +tools/mixture_of_agents_tool.py +tools/skills_tool.py +tools/terminal_hecate.py +tools/terminal_tool.py +tools/vision_tools.py +tools/web_tools.py \ No newline at end of file diff --git a/hermes_agent.egg-info/dependency_links.txt b/hermes_agent.egg-info/dependency_links.txt new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/hermes_agent.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/hermes_agent.egg-info/entry_points.txt b/hermes_agent.egg-info/entry_points.txt new file mode 100644 index 000000000..504de51c2 --- /dev/null +++ b/hermes_agent.egg-info/entry_points.txt @@ -0,0 +1,3 @@ +[console_scripts] +hermes = hermes_cli.main:main +hermes-agent = run_agent:main diff --git a/hermes_agent.egg-info/requires.txt b/hermes_agent.egg-info/requires.txt new file mode 100644 index 000000000..91036b7cd --- /dev/null +++ b/hermes_agent.egg-info/requires.txt @@ -0,0 +1,35 @@ +openai +python-dotenv +fire +httpx +rich +tenacity +pyyaml +requests +jinja2 +pydantic>=2.0 +firecrawl-py +fal-client +litellm>=1.75.5 +typer +platformdirs + +[all] +croniter +python-telegram-bot>=20.0 +discord.py>=2.0 + +[cron] +croniter + +[dev] +pytest +pytest-asyncio + +[messaging] +python-telegram-bot>=20.0 +discord.py>=2.0 + +[modal] +modal +boto3 diff --git a/hermes_agent.egg-info/top_level.txt b/hermes_agent.egg-info/top_level.txt new file mode 100644 index 000000000..a80409024 --- /dev/null +++ b/hermes_agent.egg-info/top_level.txt @@ -0,0 +1,11 @@ +batch_runner +cli +cron +gateway +hermes_cli +model_tools +run_agent +tools +toolset_distributions +toolsets +trajectory_compressor diff --git a/hermes_cli/__init__.py b/hermes_cli/__init__.py new file mode 100644 index 000000000..d8c95978c --- /dev/null +++ b/hermes_cli/__init__.py @@ -0,0 +1,14 @@ +""" +Hermes CLI - Unified command-line interface for Hermes Agent. + +Provides subcommands for: +- hermes chat - Interactive chat (same as ./hermes) +- hermes gateway - Run gateway in foreground +- hermes gateway start - Start gateway service +- hermes gateway stop - Stop gateway service +- hermes setup - Interactive setup wizard +- hermes status - Show status of all components +- hermes cron - Manage cron jobs +""" + +__version__ = "0.1.0" diff --git a/hermes_cli/config.py b/hermes_cli/config.py new file mode 100644 index 000000000..210473dbb --- /dev/null +++ b/hermes_cli/config.py @@ -0,0 +1,392 @@ +""" +Configuration management for Hermes Agent. + +Config files are stored in ~/.hermes/ for easy access: +- ~/.hermes/config.yaml - All settings (model, toolsets, terminal, etc.) +- ~/.hermes/.env - API keys and secrets + +This module provides: +- hermes config - Show current configuration +- hermes config edit - Open config in editor +- hermes config set - Set a specific value +- hermes config wizard - Re-run setup wizard +""" + +import os +import sys +import subprocess +from pathlib import Path +from typing import Dict, Any, Optional + +import yaml + +# ANSI colors +class Colors: + RESET = "\033[0m" + BOLD = "\033[1m" + DIM = "\033[2m" + RED = "\033[31m" + GREEN = "\033[32m" + YELLOW = "\033[33m" + BLUE = "\033[34m" + MAGENTA = "\033[35m" + CYAN = "\033[36m" + +def color(text: str, *codes) -> str: + if not sys.stdout.isatty(): + return text + return "".join(codes) + text + Colors.RESET + + +# ============================================================================= +# Config paths +# ============================================================================= + +def get_hermes_home() -> Path: + """Get the Hermes home directory (~/.hermes).""" + return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) + +def get_config_path() -> Path: + """Get the main config file path.""" + return get_hermes_home() / "config.yaml" + +def get_env_path() -> Path: + """Get the .env file path (for API keys).""" + return get_hermes_home() / ".env" + +def get_project_root() -> Path: + """Get the project installation directory.""" + return Path(__file__).parent.parent.resolve() + +def ensure_hermes_home(): + """Ensure ~/.hermes directory structure exists.""" + home = get_hermes_home() + (home / "cron").mkdir(parents=True, exist_ok=True) + (home / "sessions").mkdir(parents=True, exist_ok=True) + (home / "logs").mkdir(parents=True, exist_ok=True) + + +# ============================================================================= +# Config loading/saving +# ============================================================================= + +DEFAULT_CONFIG = { + "model": "anthropic/claude-sonnet-4", + "toolsets": ["hermes-cli"], + "max_turns": 100, + + "terminal": { + "backend": "local", + "cwd": ".", # Use current directory + "timeout": 180, + "docker_image": "python:3.11-slim", + }, + + "browser": { + "inactivity_timeout": 120, + }, + + "compression": { + "enabled": True, + "threshold": 0.85, + "summary_model": "google/gemini-2.0-flash-001", + }, + + "display": { + "compact": False, + "personality": "kawaii", + }, +} + + +def load_config() -> Dict[str, Any]: + """Load configuration from ~/.hermes/config.yaml.""" + config_path = get_config_path() + + config = DEFAULT_CONFIG.copy() + + if config_path.exists(): + try: + with open(config_path) as f: + user_config = yaml.safe_load(f) or {} + + # Deep merge + for key, value in user_config.items(): + if isinstance(value, dict) and key in config and isinstance(config[key], dict): + config[key].update(value) + else: + config[key] = value + except Exception as e: + print(f"Warning: Failed to load config: {e}") + + return config + + +def save_config(config: Dict[str, Any]): + """Save configuration to ~/.hermes/config.yaml.""" + ensure_hermes_home() + config_path = get_config_path() + + with open(config_path, 'w') as f: + yaml.dump(config, f, default_flow_style=False, sort_keys=False) + + +def load_env() -> Dict[str, str]: + """Load environment variables from ~/.hermes/.env.""" + env_path = get_env_path() + env_vars = {} + + if env_path.exists(): + with open(env_path) as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, _, value = line.partition('=') + env_vars[key.strip()] = value.strip().strip('"\'') + + return env_vars + + +def save_env_value(key: str, value: str): + """Save or update a value in ~/.hermes/.env.""" + ensure_hermes_home() + env_path = get_env_path() + + # Load existing + lines = [] + if env_path.exists(): + with open(env_path) as f: + lines = f.readlines() + + # Find and update or append + found = False + for i, line in enumerate(lines): + if line.strip().startswith(f"{key}="): + lines[i] = f"{key}={value}\n" + found = True + break + + if not found: + lines.append(f"{key}={value}\n") + + with open(env_path, 'w') as f: + f.writelines(lines) + + +def get_env_value(key: str) -> Optional[str]: + """Get a value from ~/.hermes/.env or environment.""" + # Check environment first + if key in os.environ: + return os.environ[key] + + # Then check .env file + env_vars = load_env() + return env_vars.get(key) + + +# ============================================================================= +# Config display +# ============================================================================= + +def redact_key(key: str) -> str: + """Redact an API key for display.""" + if not key: + return color("(not set)", Colors.DIM) + if len(key) < 12: + return "***" + return key[:4] + "..." + key[-4:] + + +def show_config(): + """Display current configuration.""" + config = load_config() + env_vars = load_env() + + print() + print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN)) + print(color("│ 🦋 Hermes Configuration │", Colors.CYAN)) + print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN)) + + # Paths + print() + print(color("◆ Paths", Colors.CYAN, Colors.BOLD)) + print(f" Config: {get_config_path()}") + print(f" Secrets: {get_env_path()}") + print(f" Install: {get_project_root()}") + + # API Keys + print() + print(color("◆ API Keys", Colors.CYAN, Colors.BOLD)) + + keys = [ + ("OPENROUTER_API_KEY", "OpenRouter"), + ("ANTHROPIC_API_KEY", "Anthropic"), + ("OPENAI_API_KEY", "OpenAI"), + ("FIRECRAWL_API_KEY", "Firecrawl"), + ("BROWSERBASE_API_KEY", "Browserbase"), + ("FAL_KEY", "FAL"), + ] + + for env_key, name in keys: + value = get_env_value(env_key) + print(f" {name:<14} {redact_key(value)}") + + # Model settings + print() + print(color("◆ Model", Colors.CYAN, Colors.BOLD)) + print(f" Model: {config.get('model', 'not set')}") + print(f" Max turns: {config.get('max_turns', 100)}") + print(f" Toolsets: {', '.join(config.get('toolsets', ['all']))}") + + # Terminal + print() + print(color("◆ Terminal", Colors.CYAN, Colors.BOLD)) + terminal = config.get('terminal', {}) + print(f" Backend: {terminal.get('backend', 'local')}") + print(f" Working dir: {terminal.get('cwd', '.')}") + print(f" Timeout: {terminal.get('timeout', 60)}s") + + if terminal.get('backend') == 'docker': + print(f" Docker image: {terminal.get('docker_image', 'python:3.11-slim')}") + elif terminal.get('backend') == 'ssh': + ssh_host = get_env_value('TERMINAL_SSH_HOST') + ssh_user = get_env_value('TERMINAL_SSH_USER') + print(f" SSH host: {ssh_host or '(not set)'}") + print(f" SSH user: {ssh_user or '(not set)'}") + + # Compression + print() + print(color("◆ Context Compression", Colors.CYAN, Colors.BOLD)) + compression = config.get('compression', {}) + enabled = compression.get('enabled', True) + print(f" Enabled: {'yes' if enabled else 'no'}") + if enabled: + print(f" Threshold: {compression.get('threshold', 0.85) * 100:.0f}%") + print(f" Model: {compression.get('summary_model', 'google/gemini-2.0-flash-001')}") + + # Messaging + print() + print(color("◆ Messaging Platforms", Colors.CYAN, Colors.BOLD)) + + telegram_token = get_env_value('TELEGRAM_BOT_TOKEN') + discord_token = get_env_value('DISCORD_BOT_TOKEN') + + print(f" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}") + print(f" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}") + + print() + print(color("─" * 60, Colors.DIM)) + print(color(" hermes config edit # Edit config file", Colors.DIM)) + print(color(" hermes config set KEY VALUE", Colors.DIM)) + print(color(" hermes setup # Run setup wizard", Colors.DIM)) + print() + + +def edit_config(): + """Open config file in user's editor.""" + config_path = get_config_path() + + # Ensure config exists + if not config_path.exists(): + save_config(DEFAULT_CONFIG) + print(f"Created {config_path}") + + # Find editor + editor = os.getenv('EDITOR') or os.getenv('VISUAL') + + if not editor: + # Try common editors + for cmd in ['nano', 'vim', 'vi', 'code', 'notepad']: + import shutil + if shutil.which(cmd): + editor = cmd + break + + if not editor: + print(f"No editor found. Config file is at:") + print(f" {config_path}") + return + + print(f"Opening {config_path} in {editor}...") + subprocess.run([editor, str(config_path)]) + + +def set_config_value(key: str, value: str): + """Set a configuration value.""" + # Check if it's an API key (goes to .env) + api_keys = [ + 'OPENROUTER_API_KEY', 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', + 'FIRECRAWL_API_KEY', 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', + 'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN', + 'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY', + 'SUDO_PASSWORD' + ] + + if key.upper() in api_keys or key.upper().startswith('TERMINAL_SSH'): + save_env_value(key.upper(), value) + print(f"✓ Set {key} in {get_env_path()}") + return + + # Otherwise it goes to config.yaml + config = load_config() + + # Handle nested keys (e.g., "terminal.backend") + parts = key.split('.') + current = config + + for part in parts[:-1]: + if part not in current: + current[part] = {} + current = current[part] + + # Convert value to appropriate type + if value.lower() in ('true', 'yes', 'on'): + value = True + elif value.lower() in ('false', 'no', 'off'): + value = False + elif value.isdigit(): + value = int(value) + elif value.replace('.', '', 1).isdigit(): + value = float(value) + + current[parts[-1]] = value + save_config(config) + print(f"✓ Set {key} = {value} in {get_config_path()}") + + +# ============================================================================= +# Command handler +# ============================================================================= + +def config_command(args): + """Handle config subcommands.""" + subcmd = getattr(args, 'config_command', None) + + if subcmd is None or subcmd == "show": + show_config() + + elif subcmd == "edit": + edit_config() + + elif subcmd == "set": + key = getattr(args, 'key', None) + value = getattr(args, 'value', None) + if not key or not value: + print("Usage: hermes config set KEY VALUE") + print() + print("Examples:") + print(" hermes config set model anthropic/claude-sonnet-4") + print(" hermes config set terminal.backend docker") + print(" hermes config set OPENROUTER_API_KEY sk-or-...") + sys.exit(1) + set_config_value(key, value) + + elif subcmd == "path": + print(get_config_path()) + + elif subcmd == "env-path": + print(get_env_path()) + + else: + print(f"Unknown config command: {subcmd}") + sys.exit(1) diff --git a/hermes_cli/cron.py b/hermes_cli/cron.py new file mode 100644 index 000000000..dc43ab636 --- /dev/null +++ b/hermes_cli/cron.py @@ -0,0 +1,131 @@ +""" +Cron subcommand for hermes CLI. + +Handles: hermes cron [list|daemon|tick] +""" + +import json +import sys +import time +from pathlib import Path +from datetime import datetime + +PROJECT_ROOT = Path(__file__).parent.parent.resolve() +sys.path.insert(0, str(PROJECT_ROOT)) + +# ANSI colors +class Colors: + RESET = "\033[0m" + BOLD = "\033[1m" + DIM = "\033[2m" + RED = "\033[31m" + GREEN = "\033[32m" + YELLOW = "\033[33m" + CYAN = "\033[36m" + +def color(text: str, *codes) -> str: + if not sys.stdout.isatty(): + return text + return "".join(codes) + text + Colors.RESET + + +def cron_list(show_all: bool = False): + """List all scheduled jobs.""" + from cron.jobs import list_jobs + + jobs = list_jobs(include_disabled=show_all) + + if not jobs: + print(color("No scheduled jobs.", Colors.DIM)) + print(color("Create one with: hermes cron add ", Colors.DIM)) + return + + print() + print(color("┌─────────────────────────────────────────────────────────────────────────┐", Colors.CYAN)) + print(color("│ Scheduled Jobs │", Colors.CYAN)) + print(color("└─────────────────────────────────────────────────────────────────────────┘", Colors.CYAN)) + print() + + for job in jobs: + job_id = job.get("id", "?")[:8] + name = job.get("name", "(unnamed)") + schedule = job.get("schedule_display", job.get("schedule", {}).get("value", "?")) + enabled = job.get("enabled", True) + next_run = job.get("next_run_at", "?") + + # Repeat info + repeat_info = job.get("repeat", {}) + repeat_times = repeat_info.get("times") + repeat_completed = repeat_info.get("completed", 0) + + if repeat_times: + repeat_str = f"{repeat_completed}/{repeat_times}" + else: + repeat_str = "∞" + + # Delivery targets + deliver = job.get("deliver", ["local"]) + if isinstance(deliver, str): + deliver = [deliver] + deliver_str = ", ".join(deliver) + + # Status indicator + if not enabled: + status = color("[disabled]", Colors.RED) + else: + status = color("[active]", Colors.GREEN) + + print(f" {color(job_id, Colors.YELLOW)} {status}") + print(f" Name: {name}") + print(f" Schedule: {schedule}") + print(f" Repeat: {repeat_str}") + print(f" Next run: {next_run}") + print(f" Deliver: {deliver_str}") + print() + + +def cron_daemon(interval: int = 60): + """Run the cron daemon.""" + from cron.scheduler import start_daemon + + print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN)) + print(color("│ 🦋 Hermes Cron Daemon │", Colors.CYAN)) + print(color("├─────────────────────────────────────────────────────────┤", Colors.CYAN)) + print(color("│ Press Ctrl+C to stop │", Colors.CYAN)) + print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN)) + print() + + try: + start_daemon(interval=interval) + except KeyboardInterrupt: + print() + print(color("Cron daemon stopped.", Colors.YELLOW)) + + +def cron_tick(): + """Run due jobs once (for system cron integration).""" + from cron.scheduler import tick + + print(f"[{datetime.now().isoformat()}] Running cron tick...") + tick() + + +def cron_command(args): + """Handle cron subcommands.""" + subcmd = getattr(args, 'cron_command', None) + + if subcmd is None or subcmd == "list": + show_all = getattr(args, 'all', False) + cron_list(show_all) + + elif subcmd == "daemon": + interval = getattr(args, 'interval', 60) + cron_daemon(interval) + + elif subcmd == "tick": + cron_tick() + + else: + print(f"Unknown cron command: {subcmd}") + print("Usage: hermes cron [list|daemon|tick]") + sys.exit(1) diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py new file mode 100644 index 000000000..2b69317bb --- /dev/null +++ b/hermes_cli/doctor.py @@ -0,0 +1,278 @@ +""" +Doctor command for hermes CLI. + +Diagnoses issues with Hermes Agent setup. +""" + +import os +import sys +import subprocess +import shutil +from pathlib import Path + +PROJECT_ROOT = Path(__file__).parent.parent.resolve() + +# ANSI colors +class Colors: + RESET = "\033[0m" + BOLD = "\033[1m" + DIM = "\033[2m" + RED = "\033[31m" + GREEN = "\033[32m" + YELLOW = "\033[33m" + CYAN = "\033[36m" + +def color(text: str, *codes) -> str: + if not sys.stdout.isatty(): + return text + return "".join(codes) + text + Colors.RESET + +def check_ok(text: str, detail: str = ""): + print(f" {color('✓', Colors.GREEN)} {text}" + (f" {color(detail, Colors.DIM)}" if detail else "")) + +def check_warn(text: str, detail: str = ""): + print(f" {color('⚠', Colors.YELLOW)} {text}" + (f" {color(detail, Colors.DIM)}" if detail else "")) + +def check_fail(text: str, detail: str = ""): + print(f" {color('✗', Colors.RED)} {text}" + (f" {color(detail, Colors.DIM)}" if detail else "")) + +def check_info(text: str): + print(f" {color('→', Colors.CYAN)} {text}") + + +def run_doctor(args): + """Run diagnostic checks.""" + should_fix = getattr(args, 'fix', False) + + issues = [] + + print() + print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN)) + print(color("│ 🩺 Hermes Doctor │", Colors.CYAN)) + print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN)) + + # ========================================================================= + # Check: Python version + # ========================================================================= + print() + print(color("◆ Python Environment", Colors.CYAN, Colors.BOLD)) + + py_version = sys.version_info + if py_version >= (3, 10): + check_ok(f"Python {py_version.major}.{py_version.minor}.{py_version.micro}") + elif py_version >= (3, 8): + check_warn(f"Python {py_version.major}.{py_version.minor}.{py_version.micro}", "(3.10+ recommended)") + else: + check_fail(f"Python {py_version.major}.{py_version.minor}.{py_version.micro}", "(3.10+ required)") + issues.append("Upgrade Python to 3.10+") + + # Check if in virtual environment + in_venv = sys.prefix != sys.base_prefix + if in_venv: + check_ok("Virtual environment active") + else: + check_warn("Not in virtual environment", "(recommended)") + + # ========================================================================= + # Check: Required packages + # ========================================================================= + print() + print(color("◆ Required Packages", Colors.CYAN, Colors.BOLD)) + + required_packages = [ + ("openai", "OpenAI SDK"), + ("rich", "Rich (terminal UI)"), + ("dotenv", "python-dotenv"), + ("yaml", "PyYAML"), + ("httpx", "HTTPX"), + ] + + optional_packages = [ + ("croniter", "Croniter (cron expressions)"), + ("browserbase", "Browserbase SDK"), + ("telegram", "python-telegram-bot"), + ("discord", "discord.py"), + ] + + for module, name in required_packages: + try: + __import__(module) + check_ok(name) + except ImportError: + check_fail(name, "(missing)") + issues.append(f"Install {name}: pip install {module}") + + for module, name in optional_packages: + try: + __import__(module) + check_ok(name, "(optional)") + except ImportError: + check_warn(name, "(optional, not installed)") + + # ========================================================================= + # Check: Configuration files + # ========================================================================= + print() + print(color("◆ Configuration Files", Colors.CYAN, Colors.BOLD)) + + env_path = PROJECT_ROOT / '.env' + if env_path.exists(): + check_ok(".env file exists") + + # Check for common issues + content = env_path.read_text() + if "OPENROUTER_API_KEY" in content or "ANTHROPIC_API_KEY" in content: + check_ok("API key configured") + else: + check_warn("No API key found in .env") + issues.append("Run 'hermes setup' to configure API keys") + else: + check_fail(".env file missing") + check_info("Run 'hermes setup' to create one") + issues.append("Run 'hermes setup' to create .env") + + config_path = PROJECT_ROOT / 'cli-config.yaml' + if config_path.exists(): + check_ok("cli-config.yaml exists") + else: + check_warn("cli-config.yaml not found", "(using defaults)") + + # ========================================================================= + # Check: Directory structure + # ========================================================================= + print() + print(color("◆ Directory Structure", Colors.CYAN, Colors.BOLD)) + + hermes_home = Path.home() / ".hermes" + if hermes_home.exists(): + check_ok("~/.hermes directory exists") + else: + check_warn("~/.hermes not found", "(will be created on first use)") + + logs_dir = PROJECT_ROOT / "logs" + if logs_dir.exists(): + check_ok("logs/ directory exists") + else: + check_warn("logs/ not found", "(will be created on first use)") + + # ========================================================================= + # Check: External tools + # ========================================================================= + print() + print(color("◆ External Tools", Colors.CYAN, Colors.BOLD)) + + # Git + if shutil.which("git"): + check_ok("git") + else: + check_warn("git not found", "(optional)") + + # Docker (optional) + terminal_env = os.getenv("TERMINAL_ENV", "local") + if terminal_env == "docker": + if shutil.which("docker"): + # Check if docker daemon is running + result = subprocess.run(["docker", "info"], capture_output=True) + if result.returncode == 0: + check_ok("docker", "(daemon running)") + else: + check_fail("docker daemon not running") + issues.append("Start Docker daemon") + else: + check_fail("docker not found", "(required for TERMINAL_ENV=docker)") + issues.append("Install Docker or change TERMINAL_ENV") + else: + if shutil.which("docker"): + check_ok("docker", "(optional)") + else: + check_warn("docker not found", "(optional)") + + # SSH (if using ssh backend) + if terminal_env == "ssh": + ssh_host = os.getenv("TERMINAL_SSH_HOST") + if ssh_host: + # Try to connect + result = subprocess.run( + ["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", ssh_host, "echo ok"], + capture_output=True, + text=True + ) + if result.returncode == 0: + check_ok(f"SSH connection to {ssh_host}") + else: + check_fail(f"SSH connection to {ssh_host}") + issues.append(f"Check SSH configuration for {ssh_host}") + else: + check_fail("TERMINAL_SSH_HOST not set", "(required for TERMINAL_ENV=ssh)") + issues.append("Set TERMINAL_SSH_HOST in .env") + + # ========================================================================= + # Check: API connectivity + # ========================================================================= + print() + print(color("◆ API Connectivity", Colors.CYAN, Colors.BOLD)) + + openrouter_key = os.getenv("OPENROUTER_API_KEY") + if openrouter_key: + try: + import httpx + response = httpx.get( + "https://openrouter.ai/api/v1/models", + headers={"Authorization": f"Bearer {openrouter_key}"}, + timeout=10 + ) + if response.status_code == 200: + check_ok("OpenRouter API") + elif response.status_code == 401: + check_fail("OpenRouter API", "(invalid API key)") + issues.append("Check OPENROUTER_API_KEY in .env") + else: + check_fail("OpenRouter API", f"(HTTP {response.status_code})") + except Exception as e: + check_fail("OpenRouter API", f"({e})") + issues.append("Check network connectivity") + else: + check_warn("OpenRouter API", "(not configured)") + + anthropic_key = os.getenv("ANTHROPIC_API_KEY") + if anthropic_key: + try: + import httpx + response = httpx.get( + "https://api.anthropic.com/v1/models", + headers={ + "x-api-key": anthropic_key, + "anthropic-version": "2023-06-01" + }, + timeout=10 + ) + if response.status_code == 200: + check_ok("Anthropic API") + elif response.status_code == 401: + check_fail("Anthropic API", "(invalid API key)") + else: + # Note: Anthropic may not have /models endpoint + check_warn("Anthropic API", "(couldn't verify)") + except Exception as e: + check_warn("Anthropic API", f"({e})") + + # ========================================================================= + # Summary + # ========================================================================= + print() + if issues: + print(color("─" * 60, Colors.YELLOW)) + print(color(f" Found {len(issues)} issue(s) to address:", Colors.YELLOW, Colors.BOLD)) + print() + for i, issue in enumerate(issues, 1): + print(f" {i}. {issue}") + print() + + if should_fix: + print(color(" Attempting auto-fix is not yet implemented.", Colors.DIM)) + print(color(" Please resolve issues manually.", Colors.DIM)) + else: + print(color("─" * 60, Colors.GREEN)) + print(color(" All checks passed! 🎉", Colors.GREEN, Colors.BOLD)) + + print() diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py new file mode 100644 index 000000000..678a68927 --- /dev/null +++ b/hermes_cli/gateway.py @@ -0,0 +1,371 @@ +""" +Gateway subcommand for hermes CLI. + +Handles: hermes gateway [run|start|stop|restart|status|install|uninstall] +""" + +import asyncio +import os +import subprocess +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).parent.parent.resolve() + + +def is_linux() -> bool: + return sys.platform.startswith('linux') + +def is_macos() -> bool: + return sys.platform == 'darwin' + +def is_windows() -> bool: + return sys.platform == 'win32' + + +# ============================================================================= +# Service Configuration +# ============================================================================= + +SERVICE_NAME = "hermes-gateway" +SERVICE_DESCRIPTION = "Hermes Agent Gateway - Messaging Platform Integration" + +def get_systemd_unit_path() -> Path: + return Path.home() / ".config" / "systemd" / "user" / f"{SERVICE_NAME}.service" + +def get_launchd_plist_path() -> Path: + return Path.home() / "Library" / "LaunchAgents" / "ai.hermes.gateway.plist" + +def get_python_path() -> str: + venv_python = PROJECT_ROOT / "venv" / "bin" / "python" + if venv_python.exists(): + return str(venv_python) + return sys.executable + +def get_hermes_cli_path() -> str: + """Get the path to the hermes CLI.""" + # Check if installed via pip + import shutil + hermes_bin = shutil.which("hermes") + if hermes_bin: + return hermes_bin + + # Fallback to direct module execution + return f"{get_python_path()} -m hermes_cli.main" + + +# ============================================================================= +# Systemd (Linux) +# ============================================================================= + +def generate_systemd_unit() -> str: + python_path = get_python_path() + working_dir = str(PROJECT_ROOT) + + return f"""[Unit] +Description={SERVICE_DESCRIPTION} +After=network.target + +[Service] +Type=simple +ExecStart={python_path} -m hermes_cli.main gateway run +WorkingDirectory={working_dir} +Restart=on-failure +RestartSec=10 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=default.target +""" + +def systemd_install(force: bool = False): + unit_path = get_systemd_unit_path() + + if unit_path.exists() and not force: + print(f"Service already installed at: {unit_path}") + print("Use --force to reinstall") + return + + unit_path.parent.mkdir(parents=True, exist_ok=True) + print(f"Installing systemd service to: {unit_path}") + unit_path.write_text(generate_systemd_unit()) + + subprocess.run(["systemctl", "--user", "daemon-reload"], check=True) + subprocess.run(["systemctl", "--user", "enable", SERVICE_NAME], check=True) + + print() + print("✓ Service installed and enabled!") + print() + print("Next steps:") + print(f" hermes gateway start # Start the service") + print(f" hermes gateway status # Check status") + print(f" journalctl --user -u {SERVICE_NAME} -f # View logs") + print() + print("To enable lingering (keeps running after logout):") + print(" sudo loginctl enable-linger $USER") + +def systemd_uninstall(): + subprocess.run(["systemctl", "--user", "stop", SERVICE_NAME], check=False) + subprocess.run(["systemctl", "--user", "disable", SERVICE_NAME], check=False) + + unit_path = get_systemd_unit_path() + if unit_path.exists(): + unit_path.unlink() + print(f"✓ Removed {unit_path}") + + subprocess.run(["systemctl", "--user", "daemon-reload"], check=True) + print("✓ Service uninstalled") + +def systemd_start(): + subprocess.run(["systemctl", "--user", "start", SERVICE_NAME], check=True) + print("✓ Service started") + +def systemd_stop(): + subprocess.run(["systemctl", "--user", "stop", SERVICE_NAME], check=True) + print("✓ Service stopped") + +def systemd_restart(): + subprocess.run(["systemctl", "--user", "restart", SERVICE_NAME], check=True) + print("✓ Service restarted") + +def systemd_status(deep: bool = False): + # Check if service unit file exists + unit_path = get_systemd_unit_path() + if not unit_path.exists(): + print("✗ Gateway service is not installed") + print(" Run: hermes gateway install") + return + + # Show detailed status first + subprocess.run( + ["systemctl", "--user", "status", SERVICE_NAME, "--no-pager"], + capture_output=False + ) + + # Check if service is active + result = subprocess.run( + ["systemctl", "--user", "is-active", SERVICE_NAME], + capture_output=True, + text=True + ) + + status = result.stdout.strip() + + if status == "active": + print("✓ Gateway service is running") + else: + print("✗ Gateway service is stopped") + print(" Run: hermes gateway start") + + if deep: + print() + print("Recent logs:") + subprocess.run([ + "journalctl", "--user", "-u", SERVICE_NAME, + "-n", "20", "--no-pager" + ]) + + +# ============================================================================= +# Launchd (macOS) +# ============================================================================= + +def generate_launchd_plist() -> str: + python_path = get_python_path() + working_dir = str(PROJECT_ROOT) + log_dir = Path.home() / ".hermes" / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + + return f""" + + + + Label + ai.hermes.gateway + + ProgramArguments + + {python_path} + -m + hermes_cli.main + gateway + run + + + WorkingDirectory + {working_dir} + + RunAtLoad + + + KeepAlive + + SuccessfulExit + + + + StandardOutPath + {log_dir}/gateway.log + + StandardErrorPath + {log_dir}/gateway.error.log + + +""" + +def launchd_install(force: bool = False): + plist_path = get_launchd_plist_path() + + if plist_path.exists() and not force: + print(f"Service already installed at: {plist_path}") + print("Use --force to reinstall") + return + + plist_path.parent.mkdir(parents=True, exist_ok=True) + print(f"Installing launchd service to: {plist_path}") + plist_path.write_text(generate_launchd_plist()) + + subprocess.run(["launchctl", "load", str(plist_path)], check=True) + + print() + print("✓ Service installed and loaded!") + print() + print("Next steps:") + print(" hermes gateway status # Check status") + print(" tail -f ~/.hermes/logs/gateway.log # View logs") + +def launchd_uninstall(): + plist_path = get_launchd_plist_path() + subprocess.run(["launchctl", "unload", str(plist_path)], check=False) + + if plist_path.exists(): + plist_path.unlink() + print(f"✓ Removed {plist_path}") + + print("✓ Service uninstalled") + +def launchd_start(): + subprocess.run(["launchctl", "start", "ai.hermes.gateway"], check=True) + print("✓ Service started") + +def launchd_stop(): + subprocess.run(["launchctl", "stop", "ai.hermes.gateway"], check=True) + print("✓ Service stopped") + +def launchd_restart(): + launchd_stop() + launchd_start() + +def launchd_status(deep: bool = False): + result = subprocess.run( + ["launchctl", "list", "ai.hermes.gateway"], + capture_output=True, + text=True + ) + + if result.returncode == 0: + print("✓ Gateway service is loaded") + print(result.stdout) + else: + print("✗ Gateway service is not loaded") + + if deep: + log_file = Path.home() / ".hermes" / "logs" / "gateway.log" + if log_file.exists(): + print() + print("Recent logs:") + subprocess.run(["tail", "-20", str(log_file)]) + + +# ============================================================================= +# Gateway Runner +# ============================================================================= + +def run_gateway(verbose: bool = False): + """Run the gateway in foreground.""" + sys.path.insert(0, str(PROJECT_ROOT)) + + from gateway.run import start_gateway + + print("┌─────────────────────────────────────────────────────────┐") + print("│ 🦋 Hermes Gateway Starting... │") + print("├─────────────────────────────────────────────────────────┤") + print("│ Press Ctrl+C to stop │") + print("└─────────────────────────────────────────────────────────┘") + print() + + asyncio.run(start_gateway()) + + +# ============================================================================= +# Main Command Handler +# ============================================================================= + +def gateway_command(args): + """Handle gateway subcommands.""" + subcmd = getattr(args, 'gateway_command', None) + + # Default to run if no subcommand + if subcmd is None or subcmd == "run": + verbose = getattr(args, 'verbose', False) + run_gateway(verbose) + return + + # Service management commands + if subcmd == "install": + force = getattr(args, 'force', False) + if is_linux(): + systemd_install(force) + elif is_macos(): + launchd_install(force) + else: + print("Service installation not supported on this platform.") + print("Run manually: hermes gateway run") + sys.exit(1) + + elif subcmd == "uninstall": + if is_linux(): + systemd_uninstall() + elif is_macos(): + launchd_uninstall() + else: + print("Not supported on this platform.") + sys.exit(1) + + elif subcmd == "start": + if is_linux(): + systemd_start() + elif is_macos(): + launchd_start() + else: + print("Not supported on this platform.") + sys.exit(1) + + elif subcmd == "stop": + if is_linux(): + systemd_stop() + elif is_macos(): + launchd_stop() + else: + print("Not supported on this platform.") + sys.exit(1) + + elif subcmd == "restart": + if is_linux(): + systemd_restart() + elif is_macos(): + launchd_restart() + else: + print("Not supported on this platform.") + sys.exit(1) + + elif subcmd == "status": + deep = getattr(args, 'deep', False) + if is_linux(): + systemd_status(deep) + elif is_macos(): + launchd_status(deep) + else: + print("Not supported on this platform.") + sys.exit(1) diff --git a/hermes_cli/main.py b/hermes_cli/main.py new file mode 100644 index 000000000..a16fd7f1c --- /dev/null +++ b/hermes_cli/main.py @@ -0,0 +1,432 @@ +#!/usr/bin/env python3 +""" +Hermes CLI - Main entry point. + +Usage: + hermes # Interactive chat (default) + hermes chat # Interactive chat + hermes gateway # Run gateway in foreground + hermes gateway start # Start gateway as service + hermes gateway stop # Stop gateway service + hermes gateway status # Show gateway status + hermes gateway install # Install gateway service + hermes gateway uninstall # Uninstall gateway service + hermes setup # Interactive setup wizard + hermes status # Show status of all components + hermes cron # Manage cron jobs + hermes cron list # List cron jobs + hermes cron daemon # Run cron daemon + hermes doctor # Check configuration and dependencies + hermes version # Show version +""" + +import argparse +import os +import sys +from pathlib import Path + +# Add project root to path +PROJECT_ROOT = Path(__file__).parent.parent.resolve() +sys.path.insert(0, str(PROJECT_ROOT)) + +# Load .env file +from dotenv import load_dotenv +env_path = PROJECT_ROOT / '.env' +if env_path.exists(): + load_dotenv(dotenv_path=env_path) + +from hermes_cli import __version__ + + +def cmd_chat(args): + """Run interactive chat CLI.""" + # Import and run the CLI + from cli import main as cli_main + + # Build kwargs from args + kwargs = { + "model": args.model, + "toolsets": args.toolsets, + "verbose": args.verbose, + "query": args.query, + } + # Filter out None values + kwargs = {k: v for k, v in kwargs.items() if v is not None} + + cli_main(**kwargs) + + +def cmd_gateway(args): + """Gateway management commands.""" + from hermes_cli.gateway import gateway_command + gateway_command(args) + + +def cmd_setup(args): + """Interactive setup wizard.""" + from hermes_cli.setup import run_setup_wizard + run_setup_wizard(args) + + +def cmd_status(args): + """Show status of all components.""" + from hermes_cli.status import show_status + show_status(args) + + +def cmd_cron(args): + """Cron job management.""" + from hermes_cli.cron import cron_command + cron_command(args) + + +def cmd_doctor(args): + """Check configuration and dependencies.""" + from hermes_cli.doctor import run_doctor + run_doctor(args) + + +def cmd_config(args): + """Configuration management.""" + from hermes_cli.config import config_command + config_command(args) + + +def cmd_version(args): + """Show version.""" + print(f"Hermes Agent v{__version__}") + print(f"Project: {PROJECT_ROOT}") + + # Show Python version + print(f"Python: {sys.version.split()[0]}") + + # Check for key dependencies + try: + import openai + print(f"OpenAI SDK: {openai.__version__}") + except ImportError: + print("OpenAI SDK: Not installed") + + +def cmd_update(args): + """Update Hermes Agent to the latest version.""" + import subprocess + + print("🦋 Updating Hermes Agent...") + print() + + # Check if we're in a git repo + git_dir = PROJECT_ROOT / '.git' + if not git_dir.exists(): + print("✗ Not a git repository. Please reinstall:") + print(" curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash") + sys.exit(1) + + # Fetch and pull + try: + print("→ Fetching updates...") + subprocess.run(["git", "fetch", "origin"], cwd=PROJECT_ROOT, check=True) + + # Get current branch + result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=True + ) + branch = result.stdout.strip() + + # Check if there are updates + result = subprocess.run( + ["git", "rev-list", f"HEAD..origin/{branch}", "--count"], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=True + ) + commit_count = int(result.stdout.strip()) + + if commit_count == 0: + print("✓ Already up to date!") + return + + print(f"→ Found {commit_count} new commit(s)") + print("→ Pulling updates...") + subprocess.run(["git", "pull", "origin", branch], cwd=PROJECT_ROOT, check=True) + + # Reinstall Python dependencies + print("→ Updating Python dependencies...") + venv_pip = PROJECT_ROOT / "venv" / "bin" / "pip" + if venv_pip.exists(): + subprocess.run([str(venv_pip), "install", "-e", ".", "--quiet"], cwd=PROJECT_ROOT, check=True) + else: + subprocess.run(["pip", "install", "-e", ".", "--quiet"], cwd=PROJECT_ROOT, check=True) + + # Check for Node.js deps + if (PROJECT_ROOT / "package.json").exists(): + import shutil + if shutil.which("npm"): + print("→ Updating Node.js dependencies...") + subprocess.run(["npm", "install", "--silent"], cwd=PROJECT_ROOT, check=False) + + print() + print("✓ Update complete!") + print() + print("Note: If you have the gateway service running, restart it:") + print(" hermes gateway restart") + + except subprocess.CalledProcessError as e: + print(f"✗ Update failed: {e}") + sys.exit(1) + + +def main(): + """Main entry point for hermes CLI.""" + parser = argparse.ArgumentParser( + prog="hermes", + description="Hermes Agent - AI assistant with tool-calling capabilities", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + hermes Start interactive chat + hermes chat -q "Hello" Single query mode + hermes setup Run setup wizard + hermes config View configuration + hermes config edit Edit config in $EDITOR + hermes config set model gpt-4 Set a config value + hermes gateway Run messaging gateway + hermes gateway install Install as system service + hermes update Update to latest version + +For more help on a command: + hermes --help +""" + ) + + parser.add_argument( + "--version", "-V", + action="store_true", + help="Show version and exit" + ) + + subparsers = parser.add_subparsers(dest="command", help="Command to run") + + # ========================================================================= + # chat command + # ========================================================================= + chat_parser = subparsers.add_parser( + "chat", + help="Interactive chat with the agent", + description="Start an interactive chat session with Hermes Agent" + ) + chat_parser.add_argument( + "-q", "--query", + help="Single query (non-interactive mode)" + ) + chat_parser.add_argument( + "-m", "--model", + help="Model to use (e.g., anthropic/claude-sonnet-4)" + ) + chat_parser.add_argument( + "-t", "--toolsets", + help="Comma-separated toolsets to enable" + ) + chat_parser.add_argument( + "-v", "--verbose", + action="store_true", + help="Verbose output" + ) + chat_parser.set_defaults(func=cmd_chat) + + # ========================================================================= + # gateway command + # ========================================================================= + gateway_parser = subparsers.add_parser( + "gateway", + help="Messaging gateway management", + description="Manage the messaging gateway (Telegram, Discord, WhatsApp)" + ) + gateway_subparsers = gateway_parser.add_subparsers(dest="gateway_command") + + # gateway run (default) + gateway_run = gateway_subparsers.add_parser("run", help="Run gateway in foreground") + gateway_run.add_argument("-v", "--verbose", action="store_true") + + # gateway start + gateway_start = gateway_subparsers.add_parser("start", help="Start gateway service") + + # gateway stop + gateway_stop = gateway_subparsers.add_parser("stop", help="Stop gateway service") + + # gateway restart + gateway_restart = gateway_subparsers.add_parser("restart", help="Restart gateway service") + + # gateway status + gateway_status = gateway_subparsers.add_parser("status", help="Show gateway status") + gateway_status.add_argument("--deep", action="store_true", help="Deep status check") + + # gateway install + gateway_install = gateway_subparsers.add_parser("install", help="Install gateway as service") + gateway_install.add_argument("--force", action="store_true", help="Force reinstall") + + # gateway uninstall + gateway_uninstall = gateway_subparsers.add_parser("uninstall", help="Uninstall gateway service") + + gateway_parser.set_defaults(func=cmd_gateway) + + # ========================================================================= + # setup command + # ========================================================================= + setup_parser = subparsers.add_parser( + "setup", + help="Interactive setup wizard", + description="Configure Hermes Agent with an interactive wizard" + ) + setup_parser.add_argument( + "--non-interactive", + action="store_true", + help="Non-interactive mode (use defaults/env vars)" + ) + setup_parser.add_argument( + "--reset", + action="store_true", + help="Reset configuration to defaults" + ) + setup_parser.set_defaults(func=cmd_setup) + + # ========================================================================= + # status command + # ========================================================================= + status_parser = subparsers.add_parser( + "status", + help="Show status of all components", + description="Display status of Hermes Agent components" + ) + status_parser.add_argument( + "--all", + action="store_true", + help="Show all details (redacted for sharing)" + ) + status_parser.add_argument( + "--deep", + action="store_true", + help="Run deep checks (may take longer)" + ) + status_parser.set_defaults(func=cmd_status) + + # ========================================================================= + # cron command + # ========================================================================= + cron_parser = subparsers.add_parser( + "cron", + help="Cron job management", + description="Manage scheduled tasks" + ) + cron_subparsers = cron_parser.add_subparsers(dest="cron_command") + + # cron list + cron_list = cron_subparsers.add_parser("list", help="List scheduled jobs") + cron_list.add_argument("--all", action="store_true", help="Include disabled jobs") + + # cron daemon + cron_daemon = cron_subparsers.add_parser("daemon", help="Run cron daemon") + cron_daemon.add_argument("--interval", type=int, default=60, help="Check interval in seconds") + + # cron tick + cron_tick = cron_subparsers.add_parser("tick", help="Run due jobs once (for system cron)") + + cron_parser.set_defaults(func=cmd_cron) + + # ========================================================================= + # doctor command + # ========================================================================= + doctor_parser = subparsers.add_parser( + "doctor", + help="Check configuration and dependencies", + description="Diagnose issues with Hermes Agent setup" + ) + doctor_parser.add_argument( + "--fix", + action="store_true", + help="Attempt to fix issues automatically" + ) + doctor_parser.set_defaults(func=cmd_doctor) + + # ========================================================================= + # config command + # ========================================================================= + config_parser = subparsers.add_parser( + "config", + help="View and edit configuration", + description="Manage Hermes Agent configuration" + ) + config_subparsers = config_parser.add_subparsers(dest="config_command") + + # config show (default) + config_show = config_subparsers.add_parser("show", help="Show current configuration") + + # config edit + config_edit = config_subparsers.add_parser("edit", help="Open config file in editor") + + # config set + config_set = config_subparsers.add_parser("set", help="Set a configuration value") + config_set.add_argument("key", nargs="?", help="Configuration key (e.g., model, terminal.backend)") + config_set.add_argument("value", nargs="?", help="Value to set") + + # config path + config_path = config_subparsers.add_parser("path", help="Print config file path") + + # config env-path + config_env = config_subparsers.add_parser("env-path", help="Print .env file path") + + config_parser.set_defaults(func=cmd_config) + + # ========================================================================= + # version command + # ========================================================================= + version_parser = subparsers.add_parser( + "version", + help="Show version information" + ) + version_parser.set_defaults(func=cmd_version) + + # ========================================================================= + # update command + # ========================================================================= + update_parser = subparsers.add_parser( + "update", + help="Update Hermes Agent to the latest version", + description="Pull the latest changes from git and reinstall dependencies" + ) + update_parser.set_defaults(func=cmd_update) + + # ========================================================================= + # Parse and execute + # ========================================================================= + args = parser.parse_args() + + # Handle --version flag + if args.version: + cmd_version(args) + return + + # Default to chat if no command specified + if args.command is None: + # No command = run chat + args.query = None + args.model = None + args.toolsets = None + args.verbose = False + cmd_chat(args) + return + + # Execute the command + if hasattr(args, 'func'): + args.func(args) + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py new file mode 100644 index 000000000..c85f778c9 --- /dev/null +++ b/hermes_cli/setup.py @@ -0,0 +1,448 @@ +""" +Interactive setup wizard for Hermes Agent. + +Guides users through: +1. Installation directory confirmation +2. API key configuration +3. Model selection +4. Terminal backend selection +5. Messaging platform setup +6. Optional features + +Config files are stored in ~/.hermes/ for easy access. +""" + +import os +import sys +from pathlib import Path +from typing import Optional, Dict, Any + +PROJECT_ROOT = Path(__file__).parent.parent.resolve() + +# Import config helpers +from hermes_cli.config import ( + get_hermes_home, get_config_path, get_env_path, + load_config, save_config, save_env_value, get_env_value, + ensure_hermes_home, DEFAULT_CONFIG +) + +# ANSI colors +class Colors: + RESET = "\033[0m" + BOLD = "\033[1m" + DIM = "\033[2m" + RED = "\033[31m" + GREEN = "\033[32m" + YELLOW = "\033[33m" + BLUE = "\033[34m" + MAGENTA = "\033[35m" + CYAN = "\033[36m" + +def color(text: str, *codes) -> str: + """Apply color codes to text.""" + if not sys.stdout.isatty(): + return text + return "".join(codes) + text + Colors.RESET + +def print_header(title: str): + """Print a section header.""" + print() + print(color(f"◆ {title}", Colors.CYAN, Colors.BOLD)) + +def print_info(text: str): + """Print info text.""" + print(color(f" {text}", Colors.DIM)) + +def print_success(text: str): + """Print success message.""" + print(color(f"✓ {text}", Colors.GREEN)) + +def print_warning(text: str): + """Print warning message.""" + print(color(f"⚠ {text}", Colors.YELLOW)) + +def print_error(text: str): + """Print error message.""" + print(color(f"✗ {text}", Colors.RED)) + +def prompt(question: str, default: str = None, password: bool = False) -> str: + """Prompt for input with optional default.""" + if default: + display = f"{question} [{default}]: " + else: + display = f"{question}: " + + try: + if password: + import getpass + value = getpass.getpass(color(display, Colors.YELLOW)) + else: + value = input(color(display, Colors.YELLOW)) + + return value.strip() or default or "" + except (KeyboardInterrupt, EOFError): + print() + sys.exit(1) + +def prompt_choice(question: str, choices: list, default: int = 0) -> int: + """Prompt for a choice from a list.""" + print(color(question, Colors.YELLOW)) + + for i, choice in enumerate(choices): + marker = "●" if i == default else "○" + if i == default: + print(color(f" {marker} {choice}", Colors.GREEN)) + else: + print(f" {marker} {choice}") + + while True: + try: + value = input(color(f" Select [1-{len(choices)}] ({default + 1}): ", Colors.DIM)) + if not value: + return default + idx = int(value) - 1 + if 0 <= idx < len(choices): + return idx + print_error(f"Please enter a number between 1 and {len(choices)}") + except ValueError: + print_error("Please enter a number") + except (KeyboardInterrupt, EOFError): + print() + sys.exit(1) + +def prompt_yes_no(question: str, default: bool = True) -> bool: + """Prompt for yes/no.""" + default_str = "Y/n" if default else "y/N" + + while True: + value = input(color(f"{question} [{default_str}]: ", Colors.YELLOW)).strip().lower() + + if not value: + return default + if value in ('y', 'yes'): + return True + if value in ('n', 'no'): + return False + print_error("Please enter 'y' or 'n'") + + +def run_setup_wizard(args): + """Run the interactive setup wizard.""" + ensure_hermes_home() + + config = load_config() + hermes_home = get_hermes_home() + + print() + print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA)) + print(color("│ 🦋 Hermes Agent Setup Wizard │", Colors.MAGENTA)) + print(color("├─────────────────────────────────────────────────────────┤", Colors.MAGENTA)) + print(color("│ Let's configure your Hermes Agent installation. │", Colors.MAGENTA)) + print(color("│ Press Ctrl+C at any time to exit. │", Colors.MAGENTA)) + print(color("└─────────────────────────────────────────────────────────┘", Colors.MAGENTA)) + + # ========================================================================= + # Step 0: Show paths + # ========================================================================= + print_header("Configuration Location") + print_info(f"Config file: {get_config_path()}") + print_info(f"Secrets file: {get_env_path()}") + print_info(f"Data folder: {hermes_home}") + print_info(f"Install dir: {PROJECT_ROOT}") + print() + print_info("You can edit these files directly or use 'hermes config edit'") + + # ========================================================================= + # Step 1: API Keys + # ========================================================================= + print_header("Model/Auth Provider") + + # Check if already configured + existing_or = get_env_value("OPENROUTER_API_KEY") + existing_ant = get_env_value("ANTHROPIC_API_KEY") + + if existing_or or existing_ant: + configured = "OpenRouter" if existing_or else "Anthropic" + print_info(f"Currently configured: {configured}") + if not prompt_yes_no("Reconfigure API provider?", False): + print_info("Keeping existing configuration") + else: + existing_or = None # Force reconfigure + + if not existing_or and not existing_ant: + provider_choices = [ + "OpenRouter (recommended - access to all models)", + "Anthropic API (direct Claude access)", + "OpenAI API", + "Skip for now" + ] + + provider_idx = prompt_choice("Select your primary model provider:", provider_choices, 0) + + if provider_idx == 0: # OpenRouter + print_info("Get your API key at: https://openrouter.ai/keys") + api_key = prompt("OpenRouter API key", password=True) + if api_key: + save_env_value("OPENROUTER_API_KEY", api_key) + print_success("OpenRouter API key saved") + + elif provider_idx == 1: # Anthropic + print_info("Get your API key at: https://console.anthropic.com/") + api_key = prompt("Anthropic API key", password=True) + if api_key: + save_env_value("ANTHROPIC_API_KEY", api_key) + print_success("Anthropic API key saved") + + elif provider_idx == 2: # OpenAI + print_info("Get your API key at: https://platform.openai.com/api-keys") + api_key = prompt("OpenAI API key", password=True) + if api_key: + save_env_value("OPENAI_API_KEY", api_key) + print_success("OpenAI API key saved") + + # ========================================================================= + # Step 2: Model Selection + # ========================================================================= + print_header("Default Model") + + current_model = config.get('model', 'anthropic/claude-sonnet-4') + print_info(f"Current: {current_model}") + + model_choices = [ + "anthropic/claude-sonnet-4 (recommended)", + "anthropic/claude-opus-4", + "openai/gpt-4o", + "google/gemini-2.0-flash", + "Enter custom model", + "Keep current" + ] + + model_idx = prompt_choice("Select default model:", model_choices, 5) # Default: keep current + + if model_idx == 0: + config['model'] = "anthropic/claude-sonnet-4" + elif model_idx == 1: + config['model'] = "anthropic/claude-opus-4" + elif model_idx == 2: + config['model'] = "openai/gpt-4o" + elif model_idx == 3: + config['model'] = "google/gemini-2.0-flash" + elif model_idx == 4: + custom = prompt("Enter model name (e.g., anthropic/claude-sonnet-4)") + if custom: + config['model'] = custom + + # ========================================================================= + # Step 3: Terminal Backend + # ========================================================================= + print_header("Terminal Backend") + print_info("The terminal tool allows the agent to run commands.") + + current_backend = config.get('terminal', {}).get('backend', 'local') + print_info(f"Current: {current_backend}") + + terminal_choices = [ + "Local (run commands on this machine - no isolation)", + "Docker (isolated containers - recommended for security)", + "SSH (run commands on a remote server)", + "Keep current" + ] + + # Default based on current + default_terminal = {'local': 0, 'docker': 1, 'ssh': 2}.get(current_backend, 0) + + terminal_idx = prompt_choice("Select terminal backend:", terminal_choices, 3) # Default: keep + + if terminal_idx == 0: # Local + config.setdefault('terminal', {})['backend'] = 'local' + print_success("Terminal set to local") + + if prompt_yes_no("Enable sudo support? (allows agent to run sudo commands)", False): + print_warning("SECURITY WARNING: Sudo password will be stored in plaintext") + sudo_pass = prompt("Sudo password (leave empty to skip)", password=True) + if sudo_pass: + save_env_value("SUDO_PASSWORD", sudo_pass) + print_success("Sudo password saved") + + elif terminal_idx == 1: # Docker + config.setdefault('terminal', {})['backend'] = 'docker' + docker_image = prompt("Docker image", config.get('terminal', {}).get('docker_image', 'python:3.11-slim')) + config['terminal']['docker_image'] = docker_image + print_success("Terminal set to Docker") + + elif terminal_idx == 2: # SSH + config.setdefault('terminal', {})['backend'] = 'ssh' + + current_host = get_env_value('TERMINAL_SSH_HOST') or '' + current_user = get_env_value('TERMINAL_SSH_USER') or os.getenv("USER", "") + + ssh_host = prompt("SSH host", current_host) + ssh_user = prompt("SSH user", current_user) + ssh_key = prompt("SSH key path", "~/.ssh/id_rsa") + + if ssh_host: + save_env_value("TERMINAL_SSH_HOST", ssh_host) + if ssh_user: + save_env_value("TERMINAL_SSH_USER", ssh_user) + if ssh_key: + save_env_value("TERMINAL_SSH_KEY", ssh_key) + + print_success("Terminal set to SSH") + + # ========================================================================= + # Step 4: Context Compression + # ========================================================================= + print_header("Context Compression") + print_info("Automatically summarize old messages when context gets too long.") + + compression = config.get('compression', {}) + current_enabled = compression.get('enabled', True) + + if prompt_yes_no(f"Enable context compression?", current_enabled): + config.setdefault('compression', {})['enabled'] = True + + current_threshold = compression.get('threshold', 0.85) + threshold_str = prompt(f"Compression threshold (0.5-0.95)", str(current_threshold)) + try: + threshold = float(threshold_str) + if 0.5 <= threshold <= 0.95: + config['compression']['threshold'] = threshold + except ValueError: + pass + + print_success("Context compression enabled") + else: + config.setdefault('compression', {})['enabled'] = False + + # ========================================================================= + # Step 5: Messaging Platforms (Optional) + # ========================================================================= + print_header("Messaging Platforms (Optional)") + print_info("Connect to messaging platforms to chat with Hermes from anywhere.") + + # Telegram + existing_telegram = get_env_value('TELEGRAM_BOT_TOKEN') + if existing_telegram: + print_info("Telegram: already configured") + if prompt_yes_no("Reconfigure Telegram?", False): + existing_telegram = None + + if not existing_telegram and prompt_yes_no("Set up Telegram bot?", False): + print_info("Create a bot via @BotFather on Telegram") + token = prompt("Telegram bot token", password=True) + if token: + save_env_value("TELEGRAM_BOT_TOKEN", token) + print_success("Telegram token saved") + + home_channel = prompt("Home channel ID (optional, for cron delivery)") + if home_channel: + save_env_value("TELEGRAM_HOME_CHANNEL", home_channel) + + # Discord + existing_discord = get_env_value('DISCORD_BOT_TOKEN') + if existing_discord: + print_info("Discord: already configured") + if prompt_yes_no("Reconfigure Discord?", False): + existing_discord = None + + if not existing_discord and prompt_yes_no("Set up Discord bot?", False): + print_info("Create a bot at https://discord.com/developers/applications") + token = prompt("Discord bot token", password=True) + if token: + save_env_value("DISCORD_BOT_TOKEN", token) + print_success("Discord token saved") + + home_channel = prompt("Home channel ID (optional, for cron delivery)") + if home_channel: + save_env_value("DISCORD_HOME_CHANNEL", home_channel) + + # ========================================================================= + # Step 6: Additional Tools (Optional) + # ========================================================================= + print_header("Additional Tools (Optional)") + + # Firecrawl + if not get_env_value('FIRECRAWL_API_KEY'): + if prompt_yes_no("Set up web scraping (Firecrawl)?", False): + print_info("Get your API key at: https://firecrawl.dev/") + api_key = prompt("Firecrawl API key", password=True) + if api_key: + save_env_value("FIRECRAWL_API_KEY", api_key) + print_success("Firecrawl API key saved") + else: + print_info("Firecrawl: already configured") + + # Browserbase + if not get_env_value('BROWSERBASE_API_KEY'): + if prompt_yes_no("Set up browser automation (Browserbase)?", False): + print_info("Get your API key at: https://browserbase.com/") + api_key = prompt("Browserbase API key", password=True) + project_id = prompt("Browserbase project ID") + if api_key: + save_env_value("BROWSERBASE_API_KEY", api_key) + if project_id: + save_env_value("BROWSERBASE_PROJECT_ID", project_id) + print_success("Browserbase configured") + else: + print_info("Browserbase: already configured") + + # FAL + if not get_env_value('FAL_KEY'): + if prompt_yes_no("Set up image generation (FAL)?", False): + print_info("Get your API key at: https://fal.ai/") + api_key = prompt("FAL API key", password=True) + if api_key: + save_env_value("FAL_KEY", api_key) + print_success("FAL API key saved") + else: + print_info("FAL: already configured") + + # ========================================================================= + # Save config + # ========================================================================= + save_config(config) + + # ========================================================================= + # Done! + # ========================================================================= + print() + print(color("┌─────────────────────────────────────────────────────────┐", Colors.GREEN)) + print(color("│ ✓ Setup Complete! │", Colors.GREEN)) + print(color("└─────────────────────────────────────────────────────────┘", Colors.GREEN)) + print() + + # Show file locations prominently + print(color("📁 Your configuration files:", Colors.CYAN, Colors.BOLD)) + print() + print(f" {color('Settings:', Colors.YELLOW)} {get_config_path()}") + print(f" Model, terminal backend, compression, etc.") + print() + print(f" {color('API Keys:', Colors.YELLOW)} {get_env_path()}") + print(f" OpenRouter, Anthropic, Firecrawl, etc.") + print() + print(f" {color('Data:', Colors.YELLOW)} {hermes_home}/") + print(f" Cron jobs, sessions, logs") + print() + + print(color("─" * 60, Colors.DIM)) + print() + print(color("📝 To edit your configuration:", Colors.CYAN, Colors.BOLD)) + print() + print(f" {color('hermes config', Colors.GREEN)} View current settings") + print(f" {color('hermes config edit', Colors.GREEN)} Open config in your editor") + print(f" {color('hermes config set KEY VALUE', Colors.GREEN)}") + print(f" Set a specific value") + print() + print(f" Or edit the files directly:") + print(f" {color(f'nano {get_config_path()}', Colors.DIM)}") + print(f" {color(f'nano {get_env_path()}', Colors.DIM)}") + print() + + print(color("─" * 60, Colors.DIM)) + print() + print(color("🚀 Ready to go!", Colors.CYAN, Colors.BOLD)) + print() + print(f" {color('hermes', Colors.GREEN)} Start chatting") + print(f" {color('hermes gateway', Colors.GREEN)} Start messaging gateway") + print(f" {color('hermes doctor', Colors.GREEN)} Check for issues") + print() diff --git a/hermes_cli/status.py b/hermes_cli/status.py new file mode 100644 index 000000000..2d24bb50a --- /dev/null +++ b/hermes_cli/status.py @@ -0,0 +1,239 @@ +""" +Status command for hermes CLI. + +Shows the status of all Hermes Agent components. +""" + +import os +import sys +import subprocess +from pathlib import Path + +PROJECT_ROOT = Path(__file__).parent.parent.resolve() + +# ANSI colors +class Colors: + RESET = "\033[0m" + BOLD = "\033[1m" + DIM = "\033[2m" + RED = "\033[31m" + GREEN = "\033[32m" + YELLOW = "\033[33m" + CYAN = "\033[36m" + +def color(text: str, *codes) -> str: + if not sys.stdout.isatty(): + return text + return "".join(codes) + text + Colors.RESET + +def check_mark(ok: bool) -> str: + if ok: + return color("✓", Colors.GREEN) + return color("✗", Colors.RED) + +def redact_key(key: str) -> str: + """Redact an API key for display.""" + if not key: + return "(not set)" + if len(key) < 12: + return "***" + return key[:4] + "..." + key[-4:] + + +def show_status(args): + """Show status of all Hermes Agent components.""" + show_all = getattr(args, 'all', False) + deep = getattr(args, 'deep', False) + + print() + print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN)) + print(color("│ 🦋 Hermes Agent Status │", Colors.CYAN)) + print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN)) + + # ========================================================================= + # Environment + # ========================================================================= + print() + print(color("◆ Environment", Colors.CYAN, Colors.BOLD)) + print(f" Project: {PROJECT_ROOT}") + print(f" Python: {sys.version.split()[0]}") + + env_path = PROJECT_ROOT / '.env' + print(f" .env file: {check_mark(env_path.exists())} {'exists' if env_path.exists() else 'not found'}") + + # ========================================================================= + # API Keys + # ========================================================================= + print() + print(color("◆ API Keys", Colors.CYAN, Colors.BOLD)) + + keys = { + "OpenRouter": "OPENROUTER_API_KEY", + "Anthropic": "ANTHROPIC_API_KEY", + "OpenAI": "OPENAI_API_KEY", + "Firecrawl": "FIRECRAWL_API_KEY", + "Browserbase": "BROWSERBASE_API_KEY", + "FAL": "FAL_KEY", + } + + for name, env_var in keys.items(): + value = os.getenv(env_var, "") + has_key = bool(value) + display = redact_key(value) if not show_all else value + print(f" {name:<12} {check_mark(has_key)} {display}") + + # ========================================================================= + # Terminal Configuration + # ========================================================================= + print() + print(color("◆ Terminal Backend", Colors.CYAN, Colors.BOLD)) + + terminal_env = os.getenv("TERMINAL_ENV", "local") + print(f" Backend: {terminal_env}") + + if terminal_env == "ssh": + ssh_host = os.getenv("TERMINAL_SSH_HOST", "") + ssh_user = os.getenv("TERMINAL_SSH_USER", "") + print(f" SSH Host: {ssh_host or '(not set)'}") + print(f" SSH User: {ssh_user or '(not set)'}") + elif terminal_env == "docker": + docker_image = os.getenv("TERMINAL_DOCKER_IMAGE", "python:3.11-slim") + print(f" Docker Image: {docker_image}") + + sudo_password = os.getenv("SUDO_PASSWORD", "") + print(f" Sudo: {check_mark(bool(sudo_password))} {'enabled' if sudo_password else 'disabled'}") + + # ========================================================================= + # Messaging Platforms + # ========================================================================= + print() + print(color("◆ Messaging Platforms", Colors.CYAN, Colors.BOLD)) + + platforms = { + "Telegram": ("TELEGRAM_BOT_TOKEN", "TELEGRAM_HOME_CHANNEL"), + "Discord": ("DISCORD_BOT_TOKEN", "DISCORD_HOME_CHANNEL"), + "WhatsApp": ("WHATSAPP_ENABLED", None), + } + + for name, (token_var, home_var) in platforms.items(): + token = os.getenv(token_var, "") + has_token = bool(token) + + home_channel = "" + if home_var: + home_channel = os.getenv(home_var, "") + + status = "configured" if has_token else "not configured" + if home_channel: + status += f" (home: {home_channel})" + + print(f" {name:<12} {check_mark(has_token)} {status}") + + # ========================================================================= + # Gateway Status + # ========================================================================= + print() + print(color("◆ Gateway Service", Colors.CYAN, Colors.BOLD)) + + if sys.platform.startswith('linux'): + result = subprocess.run( + ["systemctl", "--user", "is-active", "hermes-gateway"], + capture_output=True, + text=True + ) + is_active = result.stdout.strip() == "active" + print(f" Status: {check_mark(is_active)} {'running' if is_active else 'stopped'}") + print(f" Manager: systemd (user)") + + elif sys.platform == 'darwin': + result = subprocess.run( + ["launchctl", "list", "ai.hermes.gateway"], + capture_output=True, + text=True + ) + is_loaded = result.returncode == 0 + print(f" Status: {check_mark(is_loaded)} {'loaded' if is_loaded else 'not loaded'}") + print(f" Manager: launchd") + else: + print(f" Status: {color('N/A', Colors.DIM)}") + print(f" Manager: (not supported on this platform)") + + # ========================================================================= + # Cron Jobs + # ========================================================================= + print() + print(color("◆ Scheduled Jobs", Colors.CYAN, Colors.BOLD)) + + jobs_file = Path.home() / ".hermes" / "cron" / "jobs.json" + if jobs_file.exists(): + import json + try: + with open(jobs_file) as f: + data = json.load(f) + jobs = data.get("jobs", []) + enabled_jobs = [j for j in jobs if j.get("enabled", True)] + print(f" Jobs: {len(enabled_jobs)} active, {len(jobs)} total") + except: + print(f" Jobs: (error reading jobs file)") + else: + print(f" Jobs: 0") + + # ========================================================================= + # Sessions + # ========================================================================= + print() + print(color("◆ Sessions", Colors.CYAN, Colors.BOLD)) + + sessions_file = Path.home() / ".hermes" / "sessions" / "sessions.json" + if sessions_file.exists(): + import json + try: + with open(sessions_file) as f: + data = json.load(f) + print(f" Active: {len(data)} session(s)") + except: + print(f" Active: (error reading sessions file)") + else: + print(f" Active: 0") + + # ========================================================================= + # Deep checks + # ========================================================================= + if deep: + print() + print(color("◆ Deep Checks", Colors.CYAN, Colors.BOLD)) + + # Check OpenRouter connectivity + openrouter_key = os.getenv("OPENROUTER_API_KEY", "") + if openrouter_key: + try: + import httpx + response = httpx.get( + "https://openrouter.ai/api/v1/models", + headers={"Authorization": f"Bearer {openrouter_key}"}, + timeout=10 + ) + ok = response.status_code == 200 + print(f" OpenRouter: {check_mark(ok)} {'reachable' if ok else f'error ({response.status_code})'}") + except Exception as e: + print(f" OpenRouter: {check_mark(False)} error: {e}") + + # Check gateway port + try: + import socket + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(1) + result = sock.connect_ex(('127.0.0.1', 18789)) + sock.close() + # Port in use = gateway likely running + port_in_use = result == 0 + # This is informational, not necessarily bad + print(f" Port 18789: {'in use' if port_in_use else 'available'}") + except: + pass + + print() + print(color("─" * 60, Colors.DIM)) + print(color(" Run 'hermes doctor' for detailed diagnostics", Colors.DIM)) + print(color(" Run 'hermes setup' to configure", Colors.DIM)) + print() diff --git a/pyproject.toml b/pyproject.toml index 10e257f77..99c32f3cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,12 +34,16 @@ dependencies = [ [project.optional-dependencies] modal = ["modal", "boto3"] dev = ["pytest", "pytest-asyncio"] +messaging = ["python-telegram-bot>=20.0", "discord.py>=2.0"] +cron = ["croniter"] +all = ["croniter", "python-telegram-bot>=20.0", "discord.py>=2.0"] [project.scripts] +hermes = "hermes_cli.main:main" hermes-agent = "run_agent:main" [tool.setuptools] -py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions"] +py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli"] [tool.setuptools.packages.find] -include = ["tools"] +include = ["tools", "hermes_cli", "gateway", "cron"] diff --git a/requirements.txt b/requirements.txt index 4bc28b6db..68a31e447 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,4 +31,14 @@ platformdirs # boto3 # Optional: For cron expression parsing (cronjob scheduling) -croniter \ No newline at end of file +croniter + +# Optional: For messaging platform integrations (gateway) +# Telegram: pip install python-telegram-bot +# python-telegram-bot>=20.0 + +# Discord: pip install discord.py +# discord.py>=2.0 + +# WhatsApp: Requires Node.js bridge (see docs/messaging.md) +# aiohttp # For WhatsApp bridge communication \ No newline at end of file diff --git a/scripts/hermes-gateway b/scripts/hermes-gateway new file mode 100755 index 000000000..59fa1056f --- /dev/null +++ b/scripts/hermes-gateway @@ -0,0 +1,414 @@ +#!/usr/bin/env python3 +""" +Hermes Gateway - Standalone messaging platform integration. + +This is the proper entry point for running the gateway as a service. +NOT tied to the CLI - runs independently. + +Usage: + # Run in foreground (for testing) + ./scripts/hermes-gateway + + # Install as systemd service + ./scripts/hermes-gateway install + + # Manage the service + ./scripts/hermes-gateway start + ./scripts/hermes-gateway stop + ./scripts/hermes-gateway restart + ./scripts/hermes-gateway status + + # Uninstall + ./scripts/hermes-gateway uninstall +""" + +import argparse +import asyncio +import os +import subprocess +import sys +from pathlib import Path + +# Add parent directory to path +SCRIPT_DIR = Path(__file__).parent.resolve() +PROJECT_DIR = SCRIPT_DIR.parent +sys.path.insert(0, str(PROJECT_DIR)) + +# Load .env file +from dotenv import load_dotenv +env_path = PROJECT_DIR / '.env' +if env_path.exists(): + load_dotenv(dotenv_path=env_path) + + +# ============================================================================= +# Service Configuration +# ============================================================================= + +SERVICE_NAME = "hermes-gateway" +SERVICE_DESCRIPTION = "Hermes Agent Gateway - Messaging Platform Integration" + +def get_systemd_unit_path() -> Path: + """Get the path for the systemd user service file.""" + return Path.home() / ".config" / "systemd" / "user" / f"{SERVICE_NAME}.service" + +def get_launchd_plist_path() -> Path: + """Get the path for the launchd plist file (macOS).""" + return Path.home() / "Library" / "LaunchAgents" / f"ai.hermes.gateway.plist" + +def get_python_path() -> str: + """Get the path to the Python interpreter.""" + # Prefer the venv if it exists + venv_python = PROJECT_DIR / "venv" / "bin" / "python" + if venv_python.exists(): + return str(venv_python) + return sys.executable + +def get_gateway_script_path() -> str: + """Get the path to this script.""" + return str(Path(__file__).resolve()) + + +# ============================================================================= +# Systemd Service (Linux) +# ============================================================================= + +def generate_systemd_unit() -> str: + """Generate the systemd unit file content.""" + python_path = get_python_path() + script_path = get_gateway_script_path() + working_dir = str(PROJECT_DIR) + + return f"""[Unit] +Description={SERVICE_DESCRIPTION} +After=network.target + +[Service] +Type=simple +ExecStart={python_path} {script_path} run +WorkingDirectory={working_dir} +Restart=on-failure +RestartSec=10 +StandardOutput=journal +StandardError=journal + +# Environment (optional - can also use .env file) +# Environment="TELEGRAM_BOT_TOKEN=your_token" +# Environment="DISCORD_BOT_TOKEN=your_token" + +[Install] +WantedBy=default.target +""" + +def install_systemd(): + """Install the systemd user service.""" + unit_path = get_systemd_unit_path() + unit_path.parent.mkdir(parents=True, exist_ok=True) + + print(f"Installing systemd service to: {unit_path}") + unit_path.write_text(generate_systemd_unit()) + + # Reload systemd + subprocess.run(["systemctl", "--user", "daemon-reload"], check=True) + + # Enable the service (start on boot) + subprocess.run(["systemctl", "--user", "enable", SERVICE_NAME], check=True) + + print(f"✓ Service installed and enabled") + print(f"") + print(f"To start the service:") + print(f" systemctl --user start {SERVICE_NAME}") + print(f"") + print(f"To view logs:") + print(f" journalctl --user -u {SERVICE_NAME} -f") + print(f"") + print(f"To enable lingering (keeps service running after logout):") + print(f" sudo loginctl enable-linger $USER") + +def uninstall_systemd(): + """Uninstall the systemd user service.""" + unit_path = get_systemd_unit_path() + + # Stop and disable first + subprocess.run(["systemctl", "--user", "stop", SERVICE_NAME], check=False) + subprocess.run(["systemctl", "--user", "disable", SERVICE_NAME], check=False) + + # Remove the unit file + if unit_path.exists(): + unit_path.unlink() + print(f"✓ Removed {unit_path}") + + # Reload systemd + subprocess.run(["systemctl", "--user", "daemon-reload"], check=True) + print(f"✓ Service uninstalled") + +def systemd_status(): + """Show systemd service status.""" + subprocess.run(["systemctl", "--user", "status", SERVICE_NAME]) + +def systemd_start(): + """Start the systemd service.""" + subprocess.run(["systemctl", "--user", "start", SERVICE_NAME], check=True) + print(f"✓ Service started") + +def systemd_stop(): + """Stop the systemd service.""" + subprocess.run(["systemctl", "--user", "stop", SERVICE_NAME], check=True) + print(f"✓ Service stopped") + +def systemd_restart(): + """Restart the systemd service.""" + subprocess.run(["systemctl", "--user", "restart", SERVICE_NAME], check=True) + print(f"✓ Service restarted") + + +# ============================================================================= +# Launchd Service (macOS) +# ============================================================================= + +def generate_launchd_plist() -> str: + """Generate the launchd plist file content.""" + python_path = get_python_path() + script_path = get_gateway_script_path() + working_dir = str(PROJECT_DIR) + log_dir = Path.home() / ".hermes" / "logs" + + return f""" + + + + Label + ai.hermes.gateway + + ProgramArguments + + {python_path} + {script_path} + run + + + WorkingDirectory + {working_dir} + + RunAtLoad + + + KeepAlive + + SuccessfulExit + + + + StandardOutPath + {log_dir}/gateway.log + + StandardErrorPath + {log_dir}/gateway.error.log + + EnvironmentVariables + + PATH + /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + + + +""" + +def install_launchd(): + """Install the launchd service (macOS).""" + plist_path = get_launchd_plist_path() + plist_path.parent.mkdir(parents=True, exist_ok=True) + + # Ensure log directory exists + log_dir = Path.home() / ".hermes" / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + + print(f"Installing launchd service to: {plist_path}") + plist_path.write_text(generate_launchd_plist()) + + # Load the service + subprocess.run(["launchctl", "load", str(plist_path)], check=True) + + print(f"✓ Service installed and loaded") + print(f"") + print(f"To view logs:") + print(f" tail -f ~/.hermes/logs/gateway.log") + print(f"") + print(f"To manage the service:") + print(f" launchctl start ai.hermes.gateway") + print(f" launchctl stop ai.hermes.gateway") + +def uninstall_launchd(): + """Uninstall the launchd service (macOS).""" + plist_path = get_launchd_plist_path() + + # Unload first + subprocess.run(["launchctl", "unload", str(plist_path)], check=False) + + # Remove the plist file + if plist_path.exists(): + plist_path.unlink() + print(f"✓ Removed {plist_path}") + + print(f"✓ Service uninstalled") + +def launchd_status(): + """Show launchd service status.""" + subprocess.run(["launchctl", "list", "ai.hermes.gateway"]) + +def launchd_start(): + """Start the launchd service.""" + subprocess.run(["launchctl", "start", "ai.hermes.gateway"], check=True) + print(f"✓ Service started") + +def launchd_stop(): + """Stop the launchd service.""" + subprocess.run(["launchctl", "stop", "ai.hermes.gateway"], check=True) + print(f"✓ Service stopped") + +def launchd_restart(): + """Restart the launchd service.""" + launchd_stop() + launchd_start() + + +# ============================================================================= +# Platform Detection +# ============================================================================= + +def is_linux() -> bool: + return sys.platform.startswith('linux') + +def is_macos() -> bool: + return sys.platform == 'darwin' + +def is_windows() -> bool: + return sys.platform == 'win32' + + +# ============================================================================= +# Gateway Runner +# ============================================================================= + +def run_gateway(): + """Run the gateway in foreground.""" + from gateway.run import start_gateway + print("Starting Hermes Gateway...") + print("Press Ctrl+C to stop.") + print() + asyncio.run(start_gateway()) + + +# ============================================================================= +# Main CLI +# ============================================================================= + +def main(): + parser = argparse.ArgumentParser( + description="Hermes Gateway - Messaging Platform Integration", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Run in foreground (for testing) + ./scripts/hermes-gateway run + + # Install as system service + ./scripts/hermes-gateway install + + # Manage the service + ./scripts/hermes-gateway start + ./scripts/hermes-gateway stop + ./scripts/hermes-gateway restart + ./scripts/hermes-gateway status + + # Uninstall + ./scripts/hermes-gateway uninstall + +Configuration: + Set environment variables in .env file or system environment: + - TELEGRAM_BOT_TOKEN + - DISCORD_BOT_TOKEN + - WHATSAPP_ENABLED + + Or create ~/.hermes/gateway.json for advanced configuration. +""" + ) + + parser.add_argument( + "command", + choices=["run", "install", "uninstall", "start", "stop", "restart", "status"], + nargs="?", + default="run", + help="Command to execute (default: run)" + ) + + parser.add_argument( + "--verbose", "-v", + action="store_true", + help="Verbose output" + ) + + args = parser.parse_args() + + # Detect platform and dispatch command + if args.command == "run": + run_gateway() + + elif args.command == "install": + if is_linux(): + install_systemd() + elif is_macos(): + install_launchd() + else: + print("Service installation not supported on this platform.") + print("Please run manually: ./scripts/hermes-gateway run") + sys.exit(1) + + elif args.command == "uninstall": + if is_linux(): + uninstall_systemd() + elif is_macos(): + uninstall_launchd() + else: + print("Service uninstallation not supported on this platform.") + sys.exit(1) + + elif args.command == "start": + if is_linux(): + systemd_start() + elif is_macos(): + launchd_start() + else: + print("Not supported on this platform.") + sys.exit(1) + + elif args.command == "stop": + if is_linux(): + systemd_stop() + elif is_macos(): + launchd_stop() + else: + print("Not supported on this platform.") + sys.exit(1) + + elif args.command == "restart": + if is_linux(): + systemd_restart() + elif is_macos(): + launchd_restart() + else: + print("Not supported on this platform.") + sys.exit(1) + + elif args.command == "status": + if is_linux(): + systemd_status() + elif is_macos(): + launchd_status() + else: + print("Not supported on this platform.") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 000000000..a6679c934 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,371 @@ +# ============================================================================ +# Hermes Agent Installer for Windows +# ============================================================================ +# Installation script for Windows (PowerShell). +# +# Usage: +# irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex +# +# Or download and run with options: +# .\install.ps1 -NoVenv -SkipSetup +# +# ============================================================================ + +param( + [switch]$NoVenv, + [switch]$SkipSetup, + [string]$Branch = "main", + [string]$InstallDir = "$env:USERPROFILE\.hermes-agent" +) + +$ErrorActionPreference = "Stop" + +# ============================================================================ +# Configuration +# ============================================================================ + +$RepoUrl = "https://github.com/NousResearch/hermes-agent.git" + +# ============================================================================ +# Helper functions +# ============================================================================ + +function Write-Banner { + Write-Host "" + Write-Host "┌─────────────────────────────────────────────────────────┐" -ForegroundColor Magenta + Write-Host "│ 🦋 Hermes Agent Installer │" -ForegroundColor Magenta + Write-Host "├─────────────────────────────────────────────────────────┤" -ForegroundColor Magenta + Write-Host "│ I'm just a butterfly with a lot of tools. │" -ForegroundColor Magenta + Write-Host "└─────────────────────────────────────────────────────────┘" -ForegroundColor Magenta + Write-Host "" +} + +function Write-Info { + param([string]$Message) + Write-Host "→ $Message" -ForegroundColor Cyan +} + +function Write-Success { + param([string]$Message) + Write-Host "✓ $Message" -ForegroundColor Green +} + +function Write-Warning { + param([string]$Message) + Write-Host "⚠ $Message" -ForegroundColor Yellow +} + +function Write-Error { + param([string]$Message) + Write-Host "✗ $Message" -ForegroundColor Red +} + +# ============================================================================ +# Dependency checks +# ============================================================================ + +function Test-Python { + Write-Info "Checking Python..." + + # Try different python commands + $pythonCmds = @("python3", "python", "py -3") + + foreach ($cmd in $pythonCmds) { + try { + $version = & $cmd.Split()[0] $cmd.Split()[1..99] -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>$null + if ($version) { + $major, $minor = $version.Split('.') + if ([int]$major -ge 3 -and [int]$minor -ge 10) { + $script:PythonCmd = $cmd + Write-Success "Python $version found" + return $true + } + } + } catch { + # Try next command + } + } + + Write-Error "Python 3.10+ not found" + Write-Info "Please install Python 3.10 or newer from:" + Write-Info " https://www.python.org/downloads/" + Write-Info "" + Write-Info "Make sure to check 'Add Python to PATH' during installation" + return $false +} + +function Test-Git { + Write-Info "Checking Git..." + + if (Get-Command git -ErrorAction SilentlyContinue) { + $version = git --version + Write-Success "Git found ($version)" + return $true + } + + Write-Error "Git not found" + Write-Info "Please install Git from:" + Write-Info " https://git-scm.com/download/win" + return $false +} + +function Test-Node { + Write-Info "Checking Node.js (optional, for browser tools)..." + + if (Get-Command node -ErrorAction SilentlyContinue) { + $version = node --version + Write-Success "Node.js $version found" + $script:HasNode = $true + return $true + } + + Write-Warning "Node.js not found (browser tools will be limited)" + Write-Info "To install Node.js (optional):" + Write-Info " https://nodejs.org/en/download/" + $script:HasNode = $false + return $true # Don't fail - Node is optional +} + +# ============================================================================ +# Installation +# ============================================================================ + +function Install-Repository { + Write-Info "Installing to $InstallDir..." + + if (Test-Path $InstallDir) { + if (Test-Path "$InstallDir\.git") { + Write-Info "Existing installation found, updating..." + Push-Location $InstallDir + git fetch origin + git checkout $Branch + git pull origin $Branch + Pop-Location + } else { + Write-Error "Directory exists but is not a git repository: $InstallDir" + Write-Info "Remove it or choose a different directory with -InstallDir" + exit 1 + } + } else { + git clone --branch $Branch $RepoUrl $InstallDir + } + + Write-Success "Repository ready" +} + +function Install-Venv { + if ($NoVenv) { + Write-Info "Skipping virtual environment (-NoVenv)" + return + } + + Write-Info "Creating virtual environment..." + + Push-Location $InstallDir + + if (-not (Test-Path "venv")) { + & $PythonCmd -m venv venv + } + + # Activate + & .\venv\Scripts\Activate.ps1 + + # Upgrade pip + pip install --upgrade pip wheel setuptools | Out-Null + + Pop-Location + + Write-Success "Virtual environment ready" +} + +function Install-Dependencies { + Write-Info "Installing dependencies..." + + Push-Location $InstallDir + + if (-not $NoVenv) { + & .\venv\Scripts\Activate.ps1 + } + + try { + pip install -e ".[all]" 2>&1 | Out-Null + } catch { + pip install -e "." | Out-Null + } + + Pop-Location + + Write-Success "Dependencies installed" +} + +function Set-PathVariable { + Write-Info "Setting up PATH..." + + if ($NoVenv) { + $binDir = "$InstallDir" + } else { + $binDir = "$InstallDir\venv\Scripts" + } + + # Add to user PATH + $currentPath = [Environment]::GetEnvironmentVariable("Path", "User") + + if ($currentPath -notlike "*$binDir*") { + [Environment]::SetEnvironmentVariable( + "Path", + "$binDir;$currentPath", + "User" + ) + Write-Success "Added to user PATH" + } else { + Write-Info "PATH already configured" + } + + # Update current session + $env:Path = "$binDir;$env:Path" +} + +function Copy-ConfigTemplates { + Write-Info "Setting up configuration files..." + + Push-Location $InstallDir + + # Create .env from example + if (-not (Test-Path ".env")) { + if (Test-Path ".env.example") { + Copy-Item ".env.example" ".env" + Write-Success "Created .env from template" + } + } else { + Write-Info ".env already exists, keeping it" + } + + # Create cli-config.yaml from example + if (-not (Test-Path "cli-config.yaml")) { + if (Test-Path "cli-config.yaml.example") { + Copy-Item "cli-config.yaml.example" "cli-config.yaml" + Write-Success "Created cli-config.yaml from template" + } + } else { + Write-Info "cli-config.yaml already exists, keeping it" + } + + Pop-Location + + # Create user data directory + $hermesDir = "$env:USERPROFILE\.hermes" + New-Item -ItemType Directory -Force -Path "$hermesDir\cron" | Out-Null + New-Item -ItemType Directory -Force -Path "$hermesDir\sessions" | Out-Null + New-Item -ItemType Directory -Force -Path "$hermesDir\logs" | Out-Null + Write-Success "Created ~/.hermes data directory" +} + +function Install-NodeDeps { + if (-not $HasNode) { + Write-Info "Skipping Node.js dependencies (Node not installed)" + return + } + + Push-Location $InstallDir + + if (Test-Path "package.json") { + Write-Info "Installing Node.js dependencies..." + try { + npm install --silent 2>&1 | Out-Null + Write-Success "Node.js dependencies installed" + } catch { + Write-Warning "npm install failed (browser tools may not work)" + } + } + + Pop-Location +} + +function Invoke-SetupWizard { + if ($SkipSetup) { + Write-Info "Skipping setup wizard (-SkipSetup)" + return + } + + Write-Host "" + Write-Info "Starting setup wizard..." + Write-Host "" + + Push-Location $InstallDir + + if (-not $NoVenv) { + & .\venv\Scripts\Activate.ps1 + } + + python -m hermes_cli.main setup + + Pop-Location +} + +function Write-Completion { + Write-Host "" + Write-Host "┌─────────────────────────────────────────────────────────┐" -ForegroundColor Green + Write-Host "│ ✓ Installation Complete! │" -ForegroundColor Green + Write-Host "└─────────────────────────────────────────────────────────┘" -ForegroundColor Green + Write-Host "" + + # Show file locations + Write-Host "📁 Your files:" -ForegroundColor Cyan + Write-Host "" + Write-Host " Install: " -NoNewline -ForegroundColor Yellow + Write-Host "$InstallDir" + Write-Host " Config: " -NoNewline -ForegroundColor Yellow + Write-Host "$env:USERPROFILE\.hermes\config.yaml" + Write-Host " API Keys: " -NoNewline -ForegroundColor Yellow + Write-Host "$env:USERPROFILE\.hermes\.env" + Write-Host " Data: " -NoNewline -ForegroundColor Yellow + Write-Host "$env:USERPROFILE\.hermes\ (cron, sessions, logs)" + Write-Host "" + + Write-Host "─────────────────────────────────────────────────────────" -ForegroundColor Cyan + Write-Host "" + Write-Host "🚀 Commands:" -ForegroundColor Cyan + Write-Host "" + Write-Host " hermes " -NoNewline -ForegroundColor Green + Write-Host "Start chatting" + Write-Host " hermes setup " -NoNewline -ForegroundColor Green + Write-Host "Configure API keys & settings" + Write-Host " hermes config " -NoNewline -ForegroundColor Green + Write-Host "View/edit configuration" + Write-Host " hermes config edit " -NoNewline -ForegroundColor Green + Write-Host "Open config in editor" + Write-Host " hermes gateway " -NoNewline -ForegroundColor Green + Write-Host "Run messaging gateway" + Write-Host " hermes update " -NoNewline -ForegroundColor Green + Write-Host "Update to latest version" + Write-Host "" + + Write-Host "─────────────────────────────────────────────────────────" -ForegroundColor Cyan + Write-Host "" + Write-Host "⚡ Restart your terminal for PATH changes to take effect" -ForegroundColor Yellow + Write-Host "" +} + +# ============================================================================ +# Main +# ============================================================================ + +function Main { + Write-Banner + + if (-not (Test-Python)) { exit 1 } + if (-not (Test-Git)) { exit 1 } + Test-Node # Optional, doesn't fail + + Install-Repository + Install-Venv + Install-Dependencies + Install-NodeDeps + Set-PathVariable + Copy-ConfigTemplates + Invoke-SetupWizard + + Write-Completion +} + +Main diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 000000000..f038bf831 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,520 @@ +#!/bin/bash +# ============================================================================ +# Hermes Agent Installer +# ============================================================================ +# Installation script for Linux and macOS. +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +# +# Or with options: +# curl -fsSL ... | bash -s -- --no-venv --skip-setup +# +# ============================================================================ + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +MAGENTA='\033[0;35m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color +BOLD='\033[1m' + +# Configuration +REPO_URL="https://github.com/NousResearch/hermes-agent.git" +INSTALL_DIR="${HERMES_INSTALL_DIR:-$HOME/.hermes-agent}" +PYTHON_MIN_VERSION="3.10" + +# Options +USE_VENV=true +RUN_SETUP=true +BRANCH="main" + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + --no-venv) + USE_VENV=false + shift + ;; + --skip-setup) + RUN_SETUP=false + shift + ;; + --branch) + BRANCH="$2" + shift 2 + ;; + --dir) + INSTALL_DIR="$2" + shift 2 + ;; + -h|--help) + echo "Hermes Agent Installer" + echo "" + echo "Usage: install.sh [OPTIONS]" + echo "" + echo "Options:" + echo " --no-venv Don't create virtual environment" + echo " --skip-setup Skip interactive setup wizard" + echo " --branch NAME Git branch to install (default: main)" + echo " --dir PATH Installation directory (default: ~/.hermes-agent)" + echo " -h, --help Show this help" + exit 0 + ;; + *) + echo "Unknown option: $1" + exit 1 + ;; + esac +done + +# ============================================================================ +# Helper functions +# ============================================================================ + +print_banner() { + echo "" + echo -e "${MAGENTA}${BOLD}" + echo "┌─────────────────────────────────────────────────────────┐" + echo "│ 🦋 Hermes Agent Installer │" + echo "├─────────────────────────────────────────────────────────┤" + echo "│ I'm just a butterfly with a lot of tools. │" + echo "└─────────────────────────────────────────────────────────┘" + echo -e "${NC}" +} + +log_info() { + echo -e "${CYAN}→${NC} $1" +} + +log_success() { + echo -e "${GREEN}✓${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}⚠${NC} $1" +} + +log_error() { + echo -e "${RED}✗${NC} $1" +} + +# ============================================================================ +# System detection +# ============================================================================ + +detect_os() { + case "$(uname -s)" in + Linux*) + OS="linux" + if [ -f /etc/os-release ]; then + . /etc/os-release + DISTRO="$ID" + else + DISTRO="unknown" + fi + ;; + Darwin*) + OS="macos" + DISTRO="macos" + ;; + CYGWIN*|MINGW*|MSYS*) + OS="windows" + DISTRO="windows" + log_error "Windows detected. Please use the PowerShell installer:" + log_info " irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex" + exit 1 + ;; + *) + OS="unknown" + DISTRO="unknown" + log_warn "Unknown operating system" + ;; + esac + + log_success "Detected: $OS ($DISTRO)" +} + +# ============================================================================ +# Dependency checks +# ============================================================================ + +check_python() { + log_info "Checking Python..." + + # Try different python commands + for cmd in python3.12 python3.11 python3.10 python3 python; do + if command -v $cmd &> /dev/null; then + PYTHON_CMD=$cmd + PYTHON_VERSION=$($cmd -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') + + # Check version + if python3 -c "import sys; exit(0 if sys.version_info >= (3, 10) else 1)" 2>/dev/null; then + log_success "Python $PYTHON_VERSION found" + return 0 + fi + fi + done + + log_error "Python 3.10+ not found" + log_info "Please install Python 3.10 or newer:" + + case "$OS" in + linux) + case "$DISTRO" in + ubuntu|debian) + log_info " sudo apt update && sudo apt install python3.11 python3.11-venv" + ;; + fedora) + log_info " sudo dnf install python3.11" + ;; + arch) + log_info " sudo pacman -S python" + ;; + *) + log_info " Use your package manager to install Python 3.10+" + ;; + esac + ;; + macos) + log_info " brew install python@3.11" + log_info " Or download from https://www.python.org/downloads/" + ;; + esac + + exit 1 +} + +check_git() { + log_info "Checking Git..." + + if command -v git &> /dev/null; then + GIT_VERSION=$(git --version | awk '{print $3}') + log_success "Git $GIT_VERSION found" + return 0 + fi + + log_error "Git not found" + log_info "Please install Git:" + + case "$OS" in + linux) + case "$DISTRO" in + ubuntu|debian) + log_info " sudo apt update && sudo apt install git" + ;; + fedora) + log_info " sudo dnf install git" + ;; + arch) + log_info " sudo pacman -S git" + ;; + *) + log_info " Use your package manager to install git" + ;; + esac + ;; + macos) + log_info " xcode-select --install" + log_info " Or: brew install git" + ;; + esac + + exit 1 +} + +check_node() { + log_info "Checking Node.js (optional, for browser tools)..." + + if command -v node &> /dev/null; then + NODE_VERSION=$(node --version) + log_success "Node.js $NODE_VERSION found" + HAS_NODE=true + return 0 + fi + + log_warn "Node.js not found (browser tools will be limited)" + log_info "To install Node.js (optional):" + + case "$OS" in + linux) + case "$DISTRO" in + ubuntu|debian) + log_info " curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -" + log_info " sudo apt install -y nodejs" + ;; + fedora) + log_info " sudo dnf install nodejs" + ;; + arch) + log_info " sudo pacman -S nodejs npm" + ;; + *) + log_info " https://nodejs.org/en/download/" + ;; + esac + ;; + macos) + log_info " brew install node" + log_info " Or: https://nodejs.org/en/download/" + ;; + esac + + HAS_NODE=false + # Don't exit - Node is optional +} + +# ============================================================================ +# Installation +# ============================================================================ + +clone_repo() { + log_info "Installing to $INSTALL_DIR..." + + if [ -d "$INSTALL_DIR" ]; then + if [ -d "$INSTALL_DIR/.git" ]; then + log_info "Existing installation found, updating..." + cd "$INSTALL_DIR" + git fetch origin + git checkout "$BRANCH" + git pull origin "$BRANCH" + else + log_error "Directory exists but is not a git repository: $INSTALL_DIR" + log_info "Remove it or choose a different directory with --dir" + exit 1 + fi + else + git clone --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR" + fi + + cd "$INSTALL_DIR" + log_success "Repository ready" +} + +setup_venv() { + if [ "$USE_VENV" = false ]; then + log_info "Skipping virtual environment (--no-venv)" + return 0 + fi + + log_info "Creating virtual environment..." + + if [ -d "venv" ]; then + log_info "Virtual environment already exists" + else + $PYTHON_CMD -m venv venv + fi + + # Activate + source venv/bin/activate + + # Upgrade pip + pip install --upgrade pip wheel setuptools > /dev/null + + log_success "Virtual environment ready" +} + +install_deps() { + log_info "Installing dependencies..." + + if [ "$USE_VENV" = true ]; then + source venv/bin/activate + fi + + # Install the package in editable mode with all extras + pip install -e ".[all]" > /dev/null 2>&1 || pip install -e "." > /dev/null + + log_success "Dependencies installed" +} + +setup_path() { + log_info "Setting up PATH..." + + # Determine the bin directory + if [ "$USE_VENV" = true ]; then + BIN_DIR="$INSTALL_DIR/venv/bin" + else + BIN_DIR="$HOME/.local/bin" + mkdir -p "$BIN_DIR" + + # Create a wrapper script + cat > "$BIN_DIR/hermes" << EOF +#!/bin/bash +cd "$INSTALL_DIR" +exec python -m hermes_cli.main "\$@" +EOF + chmod +x "$BIN_DIR/hermes" + fi + + # Add to PATH in shell config + SHELL_CONFIG="" + if [ -n "$BASH_VERSION" ]; then + if [ -f "$HOME/.bashrc" ]; then + SHELL_CONFIG="$HOME/.bashrc" + elif [ -f "$HOME/.bash_profile" ]; then + SHELL_CONFIG="$HOME/.bash_profile" + fi + elif [ -n "$ZSH_VERSION" ] || [ -f "$HOME/.zshrc" ]; then + SHELL_CONFIG="$HOME/.zshrc" + fi + + PATH_LINE="export PATH=\"$BIN_DIR:\$PATH\"" + + if [ -n "$SHELL_CONFIG" ]; then + if ! grep -q "hermes-agent" "$SHELL_CONFIG" 2>/dev/null; then + echo "" >> "$SHELL_CONFIG" + echo "# Hermes Agent" >> "$SHELL_CONFIG" + echo "$PATH_LINE" >> "$SHELL_CONFIG" + log_success "Added to $SHELL_CONFIG" + else + log_info "PATH already configured in $SHELL_CONFIG" + fi + fi + + # Also export for current session + export PATH="$BIN_DIR:$PATH" + + log_success "PATH configured" +} + +copy_config_templates() { + log_info "Setting up configuration files..." + + # Create .env from example + if [ ! -f "$INSTALL_DIR/.env" ]; then + if [ -f "$INSTALL_DIR/.env.example" ]; then + cp "$INSTALL_DIR/.env.example" "$INSTALL_DIR/.env" + log_success "Created .env from template" + fi + else + log_info ".env already exists, keeping it" + fi + + # Create cli-config.yaml from example + if [ ! -f "$INSTALL_DIR/cli-config.yaml" ]; then + if [ -f "$INSTALL_DIR/cli-config.yaml.example" ]; then + cp "$INSTALL_DIR/cli-config.yaml.example" "$INSTALL_DIR/cli-config.yaml" + log_success "Created cli-config.yaml from template" + fi + else + log_info "cli-config.yaml already exists, keeping it" + fi + + # Create ~/.hermes directory for user data + mkdir -p "$HOME/.hermes/cron" + mkdir -p "$HOME/.hermes/sessions" + mkdir -p "$HOME/.hermes/logs" + log_success "Created ~/.hermes data directory" +} + +install_node_deps() { + if [ "$HAS_NODE" = false ]; then + log_info "Skipping Node.js dependencies (Node not installed)" + return 0 + fi + + if [ -f "$INSTALL_DIR/package.json" ]; then + log_info "Installing Node.js dependencies..." + cd "$INSTALL_DIR" + npm install --silent 2>/dev/null || { + log_warn "npm install failed (browser tools may not work)" + return 0 + } + log_success "Node.js dependencies installed" + fi +} + +run_setup_wizard() { + if [ "$RUN_SETUP" = false ]; then + log_info "Skipping setup wizard (--skip-setup)" + return 0 + fi + + echo "" + log_info "Starting setup wizard..." + echo "" + + if [ "$USE_VENV" = true ]; then + source "$INSTALL_DIR/venv/bin/activate" + fi + + cd "$INSTALL_DIR" + python -m hermes_cli.main setup +} + +print_success() { + echo "" + echo -e "${GREEN}${BOLD}" + echo "┌─────────────────────────────────────────────────────────┐" + echo "│ ✓ Installation Complete! │" + echo "└─────────────────────────────────────────────────────────┘" + echo -e "${NC}" + echo "" + + # Show file locations + echo -e "${CYAN}${BOLD}📁 Your files:${NC}" + echo "" + echo -e " ${YELLOW}Install:${NC} $INSTALL_DIR" + echo -e " ${YELLOW}Config:${NC} ~/.hermes/config.yaml" + echo -e " ${YELLOW}API Keys:${NC} ~/.hermes/.env" + echo -e " ${YELLOW}Data:${NC} ~/.hermes/ (cron, sessions, logs)" + echo "" + + echo -e "${CYAN}─────────────────────────────────────────────────────────${NC}" + echo "" + echo -e "${CYAN}${BOLD}🚀 Commands:${NC}" + echo "" + echo -e " ${GREEN}hermes${NC} Start chatting" + echo -e " ${GREEN}hermes setup${NC} Configure API keys & settings" + echo -e " ${GREEN}hermes config${NC} View/edit configuration" + echo -e " ${GREEN}hermes config edit${NC} Open config in editor" + echo -e " ${GREEN}hermes gateway${NC} Run messaging gateway" + echo -e " ${GREEN}hermes update${NC} Update to latest version" + echo "" + + echo -e "${CYAN}─────────────────────────────────────────────────────────${NC}" + echo "" + echo -e "${YELLOW}⚡ Reload your shell to use 'hermes' command:${NC}" + echo "" + echo " source ~/.bashrc # or ~/.zshrc" + echo "" + + # Show Node.js warning if not installed + if [ "$HAS_NODE" = false ]; then + echo -e "${YELLOW}" + echo "Note: Node.js was not found. Browser automation tools" + echo "will have limited functionality. Install Node.js later" + echo "if you need full browser support." + echo -e "${NC}" + fi +} + +# ============================================================================ +# Main +# ============================================================================ + +main() { + print_banner + + detect_os + check_python + check_git + check_node + + clone_repo + setup_venv + install_deps + install_node_deps + setup_path + copy_config_templates + run_setup_wizard + + print_success +} + +main diff --git a/setup-hermes.sh b/setup-hermes.sh index f5b78f267..4cffdc731 100755 --- a/setup-hermes.sh +++ b/setup-hermes.sh @@ -1,149 +1,156 @@ #!/bin/bash - +# ============================================================================ # Hermes Agent Setup Script -# Automated setup for all dependencies and configuration +# ============================================================================ +# Quick setup for developers who cloned the repo manually. +# +# Usage: +# ./setup-hermes.sh +# +# This script: +# 1. Creates a virtual environment (if not exists) +# 2. Installs dependencies +# 3. Creates .env from template (if not exists) +# 4. Installs the 'hermes' CLI command +# 5. Runs the setup wizard (optional) +# ============================================================================ set -e -echo "=========================================" -echo "Hermes Agent Setup" -echo "=========================================" +# Colors +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +echo "" +echo -e "${CYAN}🦋 Hermes Agent Setup${NC}" echo "" -# Change to hermes-agent directory -cd /home/teknium/hermes-agent +# ============================================================================ +# Python check +# ============================================================================ -# Check Python version -echo "[1/10] Checking Python version..." -python_version=$(python3 --version | cut -d' ' -f2 | cut -d'.' -f1,2) -echo "✓ Python $python_version detected" -echo "" +echo -e "${CYAN}→${NC} Checking Python..." -# Install uv -echo "[2/10] Installing uv (fast Python package installer)..." -if ! command -v uv &> /dev/null; then - echo "Installing uv..." - curl -LsSf https://astral.sh/uv/install.sh | sh - export PATH="$HOME/.cargo/bin:$PATH" - echo "✓ uv installed" -else - echo "✓ uv already installed: $(uv --version)" +PYTHON_CMD="" +for cmd in python3.12 python3.11 python3.10 python3 python; do + if command -v $cmd &> /dev/null; then + if $cmd -c "import sys; exit(0 if sys.version_info >= (3, 10) else 1)" 2>/dev/null; then + PYTHON_CMD=$cmd + break + fi + fi +done + +if [ -z "$PYTHON_CMD" ]; then + echo -e "${YELLOW}✗${NC} Python 3.10+ required" + exit 1 fi -echo "" -# Install Node.js 20 using NodeSource -echo "[3/10] Installing Node.js 20..." -if ! command -v node &> /dev/null || [[ $(node --version | cut -d'v' -f2 | cut -d'.' -f1) -lt 20 ]]; then - echo "Installing Node.js 20 LTS..." - curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - - sudo apt-get install -y nodejs - echo "✓ Node.js installed" +PYTHON_VERSION=$($PYTHON_CMD -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') +echo -e "${GREEN}✓${NC} Python $PYTHON_VERSION found" + +# ============================================================================ +# Virtual environment +# ============================================================================ + +echo -e "${CYAN}→${NC} Setting up virtual environment..." + +if [ ! -d "venv" ]; then + $PYTHON_CMD -m venv venv + echo -e "${GREEN}✓${NC} Created venv" else - echo "✓ Node.js 20+ already installed: $(node --version)" + echo -e "${GREEN}✓${NC} venv exists" fi -echo "" -# Initialize git submodules -echo "[4/10] Initializing git submodules..." -git submodule update --init --recursive -echo "✓ Submodules initialized" -echo "" - -# Create Python virtual environment with uv -echo "[5/10] Creating Python virtual environment with uv..." -if [ -d "venv" ]; then - echo "Virtual environment already exists, skipping..." -else - uv venv venv - echo "✓ Virtual environment created with uv" -fi -echo "" - -# Activate virtual environment and install Python packages with uv -echo "[6/10] Installing Python dependencies with uv..." source venv/bin/activate -uv pip install -r requirements.txt -echo "✓ Python packages installed" -echo "" +pip install --upgrade pip wheel setuptools > /dev/null -# Install mini-swe-agent with uv -echo "[7/10] Installing mini-swe-agent..." -uv pip install -e ./mini-swe-agent -echo "✓ mini-swe-agent installed" -echo "" +# ============================================================================ +# Dependencies +# ============================================================================ -# Install Node.js dependencies -echo "[8/10] Installing Node.js dependencies..." -npm install -echo "✓ Node.js packages installed" -echo "" +echo -e "${CYAN}→${NC} Installing dependencies..." -# Set up environment file -echo "[9/10] Setting up environment configuration..." -if [ -f ".env" ]; then - echo ".env file already exists, creating backup..." - cp .env .env.backup.$(date +%Y%m%d_%H%M%S) -fi -cp .env.example .env -echo "✓ .env file created from .env.example" -echo "" +pip install -e ".[all]" > /dev/null 2>&1 || pip install -e "." > /dev/null -# Set up CLI config -echo "[10/10] Setting up CLI configuration..." -if [ ! -f "cli-config.yaml" ]; then - cp cli-config.yaml.example cli-config.yaml - echo "✓ cli-config.yaml created from example" +echo -e "${GREEN}✓${NC} Dependencies installed" + +# ============================================================================ +# Environment file +# ============================================================================ + +if [ ! -f ".env" ]; then + if [ -f ".env.example" ]; then + cp .env.example .env + echo -e "${GREEN}✓${NC} Created .env from template" + fi else - echo "cli-config.yaml already exists, skipping..." + echo -e "${GREEN}✓${NC} .env exists" fi + +# ============================================================================ +# PATH setup +# ============================================================================ + +echo -e "${CYAN}→${NC} Setting up hermes command..." + +BIN_DIR="$SCRIPT_DIR/venv/bin" + +# Add to shell config if not already there +SHELL_CONFIG="" +if [ -f "$HOME/.zshrc" ]; then + SHELL_CONFIG="$HOME/.zshrc" +elif [ -f "$HOME/.bashrc" ]; then + SHELL_CONFIG="$HOME/.bashrc" +elif [ -f "$HOME/.bash_profile" ]; then + SHELL_CONFIG="$HOME/.bash_profile" +fi + +if [ -n "$SHELL_CONFIG" ]; then + if ! grep -q "hermes-agent" "$SHELL_CONFIG" 2>/dev/null; then + echo "" >> "$SHELL_CONFIG" + echo "# Hermes Agent" >> "$SHELL_CONFIG" + echo "export PATH=\"$BIN_DIR:\$PATH\"" >> "$SHELL_CONFIG" + echo -e "${GREEN}✓${NC} Added to $SHELL_CONFIG" + else + echo -e "${GREEN}✓${NC} PATH already in $SHELL_CONFIG" + fi +fi + +# ============================================================================ +# Done +# ============================================================================ + +echo "" +echo -e "${GREEN}✓ Setup complete!${NC}" +echo "" +echo "Next steps:" +echo "" +echo " 1. Reload your shell:" +echo " source $SHELL_CONFIG" +echo "" +echo " 2. Run the setup wizard to configure API keys:" +echo " hermes setup" +echo "" +echo " 3. Start chatting:" +echo " hermes" +echo "" +echo "Other commands:" +echo " hermes status # Check configuration" +echo " hermes gateway # Start messaging gateway" +echo " hermes cron daemon # Run cron daemon" +echo " hermes doctor # Diagnose issues" echo "" -# Show Node.js and Python versions -echo "=========================================" -echo "Setup Complete!" -echo "=========================================" -echo "" -echo "Installed versions:" -echo " Node.js: $(node --version)" -echo " npm: $(npm --version)" -echo " Python: $(python3 --version)" -echo " uv: $(uv --version)" -echo "" - -echo "=========================================" -echo "Next Steps:" -echo "=========================================" -echo "" -echo "1. Configure API Keys in .env file:" -echo " nano .env" -echo "" -echo " Required API keys:" -echo " - OPENROUTER_API_KEY (https://openrouter.ai/keys)" -echo " - FIRECRAWL_API_KEY (https://firecrawl.dev/)" -echo " - NOUS_API_KEY (https://inference-api.nousresearch.com/)" -echo " - FAL_KEY (https://fal.ai/)" -echo "" -echo " Optional API keys:" -echo " - BROWSERBASE_API_KEY (https://browserbase.com/)" -echo " - BROWSERBASE_PROJECT_ID" -echo "" -echo "2. Activate the virtual environment:" -echo " source venv/bin/activate" -echo "" -echo "3. Run the CLI:" -echo " ./hermes" -echo "" -echo "4. Or run a single query:" -echo " python run_agent.py --query \"your question here\"" -echo "" -echo "5. List available tools:" -echo " python run_agent.py --list_tools" -echo "" -echo "=========================================" -echo "Configuration Files:" -echo "=========================================" -echo " .env - API keys and environment variables" -echo " cli-config.yaml - CLI settings and preferences" -echo "" -echo "For more information, see README.md" -echo "" +# Ask if they want to run setup wizard now +read -p "Would you like to run the setup wizard now? [Y/n] " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]] || [[ -z $REPLY ]]; then + echo "" + python -m hermes_cli.main setup +fi diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index f5573082d..5db2e1c24 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -29,6 +29,7 @@ def schedule_cronjob( schedule: str, name: Optional[str] = None, repeat: Optional[int] = None, + deliver: Optional[str] = None, task_id: str = None ) -> str: """ @@ -60,16 +61,35 @@ def schedule_cronjob( - One-shot schedules default to repeat=1 (run once) - Intervals/cron default to forever - Set repeat=5 to run 5 times then auto-delete + deliver: Where to send the output. Options: + - "origin": Back to where this job was created (default) + - "local": Save to local files only (~/.hermes/cron/output/) + - "telegram": Send to Telegram home channel + - "discord": Send to Discord home channel + - "telegram:123456": Send to specific chat ID Returns: JSON with job_id, next_run time, and confirmation """ + # Get origin info from environment if available + origin = None + origin_platform = os.getenv("HERMES_SESSION_PLATFORM") + origin_chat_id = os.getenv("HERMES_SESSION_CHAT_ID") + if origin_platform and origin_chat_id: + origin = { + "platform": origin_platform, + "chat_id": origin_chat_id, + "chat_name": os.getenv("HERMES_SESSION_CHAT_NAME"), + } + try: job = create_job( prompt=prompt, schedule=schedule, name=name, - repeat=repeat + repeat=repeat, + deliver=deliver, + origin=origin ) # Format repeat info for display @@ -87,8 +107,9 @@ def schedule_cronjob( "name": job["name"], "schedule": job["schedule_display"], "repeat": repeat_display, + "deliver": job.get("deliver", "local"), "next_run_at": job["next_run_at"], - "message": f"Cronjob '{job['name']}' created. It will run {repeat_display}, next at {job['next_run_at']}." + "message": f"Cronjob '{job['name']}' created. It will run {repeat_display}, deliver to {job.get('deliver', 'local')}, next at {job['next_run_at']}." }, indent=2) except Exception as e: @@ -122,6 +143,13 @@ REPEAT BEHAVIOR: - Intervals/cron: run forever by default - Set repeat=N to run exactly N times then auto-delete +DELIVERY OPTIONS (where output goes): +- "origin": Back to current chat (default if in messaging platform) +- "local": Save to local files only (default if in CLI) +- "telegram": Send to Telegram home channel +- "discord": Send to Discord home channel +- "telegram:123456": Send to specific chat (if user provides ID) + Use for: reminders, periodic checks, scheduled reports, automated maintenance.""", "parameters": { "type": "object", @@ -141,6 +169,10 @@ Use for: reminders, periodic checks, scheduled reports, automated maintenance."" "repeat": { "type": "integer", "description": "How many times to run. Omit for default (once for one-shot, forever for recurring). Set to N for exactly N runs." + }, + "deliver": { + "type": "string", + "description": "Where to send output: 'origin' (back to this chat), 'local' (files only), 'telegram', 'discord', or 'platform:chat_id'" } }, "required": ["prompt", "schedule"] @@ -189,6 +221,7 @@ def list_cronjobs(include_disabled: bool = False, task_id: str = None) -> str: "prompt_preview": job["prompt"][:100] + "..." if len(job["prompt"]) > 100 else job["prompt"], "schedule": job["schedule_display"], "repeat": repeat_status, + "deliver": job.get("deliver", "local"), "next_run_at": job.get("next_run_at"), "last_run_at": job.get("last_run_at"), "last_status": job.get("last_status"), diff --git a/toolsets.py b/toolsets.py index b74b2fd38..bd6b22dce 100644 --- a/toolsets.py +++ b/toolsets.py @@ -132,6 +132,63 @@ TOOLSETS = { "schedule_cronjob", "list_cronjobs", "remove_cronjob" ], "includes": [] + }, + + # ========================================================================== + # Messaging Platform-Specific Toolsets + # ========================================================================== + + "hermes-telegram": { + "description": "Telegram bot toolset - web research, skills, cronjobs (no terminal/browser for security)", + "tools": [ + # Web tools - safe for messaging + "web_search", "web_extract", + # Vision - analyze images sent by users + "vision_analyze", + # Skills - access knowledge base + "skills_categories", "skills_list", "skill_view", + # Cronjob management - let users schedule tasks + "schedule_cronjob", "list_cronjobs", "remove_cronjob" + ], + "includes": [] + }, + + "hermes-discord": { + "description": "Discord bot toolset - limited for public server safety (no terminal, no file access)", + "tools": [ + # Web tools - safe for messaging + "web_search", + # Vision - analyze images + "vision_analyze", + # Skills - access knowledge base + "skills_categories", "skills_list", "skill_view", + # Cronjob - let users schedule reminders + "schedule_cronjob", "list_cronjobs", "remove_cronjob" + ], + "includes": [] + }, + + "hermes-whatsapp": { + "description": "WhatsApp bot toolset - similar to Telegram (personal messaging, more trusted)", + "tools": [ + # Web tools + "web_search", "web_extract", + # Terminal - only for trusted personal accounts + "terminal", + # Vision + "vision_analyze", + # Skills + "skills_categories", "skills_list", "skill_view", + # Cronjob management + "schedule_cronjob", "list_cronjobs", "remove_cronjob" + ], + "includes": [] + }, + + "hermes-gateway": { + "description": "Gateway toolset - union of all messaging platform tools", + "tools": [], + "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp"] } }