Enhance CLI with multi-platform messaging integration and configuration management
- Updated CLI to load configuration from user-specific and project-specific YAML files, prioritizing user settings. - Introduced a new command `/platforms` to display the status of connected messaging platforms (Telegram, Discord, WhatsApp). - Implemented a gateway system for handling messaging interactions, including session management and delivery routing for cron job outputs. - Added support for environment variable configuration and a dedicated gateway configuration file for advanced settings. - Enhanced documentation in README.md and added a new messaging.md file to guide users on platform integrations and setup. - Updated toolsets to include platform-specific capabilities for Telegram, Discord, and WhatsApp, ensuring secure and tailored interactions.
This commit is contained in:
35
TODO.md
35
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.
|
**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):**
|
**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
|
- Bot token from @BotFather
|
||||||
- Easiest to set up, good for personal use
|
- Easiest to set up, good for personal use
|
||||||
- [ ] **Discord** - via `discord.py`
|
- [x] **Discord** - via `discord.py`
|
||||||
- Bot token from Discord Developer Portal
|
- Bot token from Discord Developer Portal
|
||||||
- Can work in servers (group sessions) or DMs
|
- Can work in servers (group sessions) or DMs
|
||||||
- [ ] **WhatsApp** - via `baileys` (WhatsApp Web protocol)
|
- [x] **WhatsApp** - via Node.js bridge (whatsapp-web.js/baileys)
|
||||||
- QR code scan to authenticate
|
- Requires Node.js bridge setup
|
||||||
- More complex, but reaches most people
|
- More complex, but reaches most people
|
||||||
|
|
||||||
**Session management:**
|
**Session management:**
|
||||||
- [ ] **Session store** - JSONL persistence per session key
|
- [x] **Session store** - JSONL persistence per session key
|
||||||
- `~/.hermes/sessions/{session_key}.jsonl`
|
- `~/.hermes/sessions/{session_id}.jsonl`
|
||||||
- Session keys: `telegram:dm:{user_id}`, `discord:channel:{id}`, etc.
|
- Session keys: `agent:main:telegram:dm`, `agent:main:discord:group:123`, etc.
|
||||||
- [ ] **Session expiry** - Configurable reset policies
|
- [x] **Session expiry** - Configurable reset policies
|
||||||
- Daily reset (default 4am) OR idle timeout (e.g., 2 hours)
|
- Daily reset (default 4am) OR idle timeout (default 2 hours)
|
||||||
- Manual reset via `/reset` or `/new` command in chat
|
- 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
93
cli.py
93
cli.py
@@ -46,12 +46,30 @@ if env_path.exists():
|
|||||||
|
|
||||||
def load_cli_config() -> Dict[str, Any]:
|
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.
|
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
|
# Default configuration
|
||||||
defaults = {
|
defaults = {
|
||||||
@@ -406,6 +424,7 @@ COMMANDS = {
|
|||||||
"/save": "Save the current conversation",
|
"/save": "Save the current conversation",
|
||||||
"/config": "Show current configuration",
|
"/config": "Show current configuration",
|
||||||
"/cron": "Manage scheduled tasks (list, add, remove)",
|
"/cron": "Manage scheduled tasks (list, add, remove)",
|
||||||
|
"/platforms": "Show gateway/messaging platform status",
|
||||||
"/quit": "Exit the CLI (also: /exit, /q)",
|
"/quit": "Exit the CLI (also: /exit, /q)",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1018,6 +1037,63 @@ class HermesCLI:
|
|||||||
print(f"(._.) Unknown cron command: {subcommand}")
|
print(f"(._.) Unknown cron command: {subcommand}")
|
||||||
print(" Available: list, add, remove")
|
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:
|
def process_command(self, command: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Process a slash command.
|
Process a slash command.
|
||||||
@@ -1075,6 +1151,8 @@ class HermesCLI:
|
|||||||
self.save_conversation()
|
self.save_conversation()
|
||||||
elif cmd.startswith("/cron"):
|
elif cmd.startswith("/cron"):
|
||||||
self._handle_cron_command(command) # Use original command for proper parsing
|
self._handle_cron_command(command) # Use original command for proper parsing
|
||||||
|
elif cmd == "/platforms" or cmd == "/gateway":
|
||||||
|
self._show_gateway_status()
|
||||||
else:
|
else:
|
||||||
self.console.print(f"[bold red]Unknown command: {cmd}[/]")
|
self.console.print(f"[bold red]Unknown command: {cmd}[/]")
|
||||||
self.console.print("[dim #B8860B]Type /help for available commands[/]")
|
self.console.print("[dim #B8860B]Type /help for available commands[/]")
|
||||||
@@ -1216,6 +1294,7 @@ def main(
|
|||||||
list_toolsets: bool = False,
|
list_toolsets: bool = False,
|
||||||
cron_daemon: bool = False,
|
cron_daemon: bool = False,
|
||||||
cron_tick_once: bool = False,
|
cron_tick_once: bool = False,
|
||||||
|
gateway: bool = False,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Hermes Agent CLI - Interactive AI Assistant
|
Hermes Agent CLI - Interactive AI Assistant
|
||||||
@@ -1262,6 +1341,14 @@ def main(
|
|||||||
print(f"Executed {jobs_run} job(s)")
|
print(f"Executed {jobs_run} job(s)")
|
||||||
return
|
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
|
# Handle query shorthand
|
||||||
query = query or q
|
query = query or q
|
||||||
|
|
||||||
|
|||||||
15
cron/jobs.py
15
cron/jobs.py
@@ -208,7 +208,9 @@ def create_job(
|
|||||||
prompt: str,
|
prompt: str,
|
||||||
schedule: str,
|
schedule: str,
|
||||||
name: Optional[str] = None,
|
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]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Create a new cron job.
|
Create a new cron job.
|
||||||
@@ -218,6 +220,8 @@ def create_job(
|
|||||||
schedule: Schedule string (see parse_schedule)
|
schedule: Schedule string (see parse_schedule)
|
||||||
name: Optional friendly name
|
name: Optional friendly name
|
||||||
repeat: How many times to run (None = forever, 1 = once)
|
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:
|
Returns:
|
||||||
The created job dict
|
The created job dict
|
||||||
@@ -228,6 +232,10 @@ def create_job(
|
|||||||
if parsed_schedule["kind"] == "once" and repeat is None:
|
if parsed_schedule["kind"] == "once" and repeat is None:
|
||||||
repeat = 1
|
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]
|
job_id = uuid.uuid4().hex[:12]
|
||||||
now = datetime.now().isoformat()
|
now = datetime.now().isoformat()
|
||||||
|
|
||||||
@@ -246,7 +254,10 @@ def create_job(
|
|||||||
"next_run_at": compute_next_run(parsed_schedule),
|
"next_run_at": compute_next_run(parsed_schedule),
|
||||||
"last_run_at": None,
|
"last_run_at": None,
|
||||||
"last_status": 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()
|
jobs = load_jobs()
|
||||||
|
|||||||
461
docs/messaging.md
Normal file
461
docs/messaging.md
Normal file
@@ -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) |
|
||||||
35
gateway/__init__.py
Normal file
35
gateway/__init__.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
333
gateway/config.py
Normal file
333
gateway/config.py
Normal file
@@ -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)
|
||||||
318
gateway/delivery.py
Normal file
318
gateway/delivery.py
Normal file
@@ -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,
|
||||||
|
}
|
||||||
17
gateway/platforms/__init__.py
Normal file
17
gateway/platforms/__init__.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
274
gateway/platforms/base.py
Normal file
274
gateway/platforms/base.py
Normal file
@@ -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
|
||||||
297
gateway/platforms/discord.py
Normal file
297
gateway/platforms/discord.py
Normal file
@@ -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)
|
||||||
284
gateway/platforms/telegram.py
Normal file
284
gateway/platforms/telegram.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
327
gateway/platforms/whatsapp.py
Normal file
327
gateway/platforms/whatsapp.py
Normal file
@@ -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
|
||||||
375
gateway/run.py
Normal file
375
gateway/run.py
Normal file
@@ -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()
|
||||||
522
gateway/session.py
Normal file
522
gateway/session.py
Normal file
@@ -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
|
||||||
868
hermes_agent.egg-info/PKG-INFO
Normal file
868
hermes_agent.egg-info/PKG-INFO
Normal file
@@ -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/<run_name>/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 |
|
||||||
47
hermes_agent.egg-info/SOURCES.txt
Normal file
47
hermes_agent.egg-info/SOURCES.txt
Normal file
@@ -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
|
||||||
1
hermes_agent.egg-info/dependency_links.txt
Normal file
1
hermes_agent.egg-info/dependency_links.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
3
hermes_agent.egg-info/entry_points.txt
Normal file
3
hermes_agent.egg-info/entry_points.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[console_scripts]
|
||||||
|
hermes = hermes_cli.main:main
|
||||||
|
hermes-agent = run_agent:main
|
||||||
35
hermes_agent.egg-info/requires.txt
Normal file
35
hermes_agent.egg-info/requires.txt
Normal file
@@ -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
|
||||||
11
hermes_agent.egg-info/top_level.txt
Normal file
11
hermes_agent.egg-info/top_level.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
batch_runner
|
||||||
|
cli
|
||||||
|
cron
|
||||||
|
gateway
|
||||||
|
hermes_cli
|
||||||
|
model_tools
|
||||||
|
run_agent
|
||||||
|
tools
|
||||||
|
toolset_distributions
|
||||||
|
toolsets
|
||||||
|
trajectory_compressor
|
||||||
14
hermes_cli/__init__.py
Normal file
14
hermes_cli/__init__.py
Normal file
@@ -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"
|
||||||
392
hermes_cli/config.py
Normal file
392
hermes_cli/config.py
Normal file
@@ -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)
|
||||||
131
hermes_cli/cron.py
Normal file
131
hermes_cli/cron.py
Normal file
@@ -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 <schedule> <prompt>", 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)
|
||||||
278
hermes_cli/doctor.py
Normal file
278
hermes_cli/doctor.py
Normal file
@@ -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()
|
||||||
371
hermes_cli/gateway.py
Normal file
371
hermes_cli/gateway.py
Normal file
@@ -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"""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>ai.hermes.gateway</string>
|
||||||
|
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>{python_path}</string>
|
||||||
|
<string>-m</string>
|
||||||
|
<string>hermes_cli.main</string>
|
||||||
|
<string>gateway</string>
|
||||||
|
<string>run</string>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<key>WorkingDirectory</key>
|
||||||
|
<string>{working_dir}</string>
|
||||||
|
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<dict>
|
||||||
|
<key>SuccessfulExit</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>{log_dir}/gateway.log</string>
|
||||||
|
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>{log_dir}/gateway.error.log</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
"""
|
||||||
|
|
||||||
|
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)
|
||||||
432
hermes_cli/main.py
Normal file
432
hermes_cli/main.py
Normal file
@@ -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 <command> --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()
|
||||||
448
hermes_cli/setup.py
Normal file
448
hermes_cli/setup.py
Normal file
@@ -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()
|
||||||
239
hermes_cli/status.py
Normal file
239
hermes_cli/status.py
Normal file
@@ -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()
|
||||||
@@ -34,12 +34,16 @@ dependencies = [
|
|||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
modal = ["modal", "boto3"]
|
modal = ["modal", "boto3"]
|
||||||
dev = ["pytest", "pytest-asyncio"]
|
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]
|
[project.scripts]
|
||||||
|
hermes = "hermes_cli.main:main"
|
||||||
hermes-agent = "run_agent:main"
|
hermes-agent = "run_agent:main"
|
||||||
|
|
||||||
[tool.setuptools]
|
[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]
|
[tool.setuptools.packages.find]
|
||||||
include = ["tools"]
|
include = ["tools", "hermes_cli", "gateway", "cron"]
|
||||||
|
|||||||
@@ -31,4 +31,14 @@ platformdirs
|
|||||||
# boto3
|
# boto3
|
||||||
|
|
||||||
# Optional: For cron expression parsing (cronjob scheduling)
|
# Optional: For cron expression parsing (cronjob scheduling)
|
||||||
croniter
|
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
|
||||||
414
scripts/hermes-gateway
Executable file
414
scripts/hermes-gateway
Executable file
@@ -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"""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>ai.hermes.gateway</string>
|
||||||
|
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>{python_path}</string>
|
||||||
|
<string>{script_path}</string>
|
||||||
|
<string>run</string>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<key>WorkingDirectory</key>
|
||||||
|
<string>{working_dir}</string>
|
||||||
|
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<dict>
|
||||||
|
<key>SuccessfulExit</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>{log_dir}/gateway.log</string>
|
||||||
|
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>{log_dir}/gateway.error.log</string>
|
||||||
|
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>PATH</key>
|
||||||
|
<string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
"""
|
||||||
|
|
||||||
|
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()
|
||||||
371
scripts/install.ps1
Normal file
371
scripts/install.ps1
Normal file
@@ -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
|
||||||
520
scripts/install.sh
Executable file
520
scripts/install.sh
Executable file
@@ -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
|
||||||
259
setup-hermes.sh
259
setup-hermes.sh
@@ -1,149 +1,156 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
# ============================================================================
|
||||||
# Hermes Agent Setup Script
|
# 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
|
set -e
|
||||||
|
|
||||||
echo "========================================="
|
# Colors
|
||||||
echo "Hermes Agent Setup"
|
GREEN='\033[0;32m'
|
||||||
echo "========================================="
|
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 ""
|
echo ""
|
||||||
|
|
||||||
# Change to hermes-agent directory
|
# ============================================================================
|
||||||
cd /home/teknium/hermes-agent
|
# Python check
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
# Check Python version
|
echo -e "${CYAN}→${NC} Checking Python..."
|
||||||
echo "[1/10] Checking Python version..."
|
|
||||||
python_version=$(python3 --version | cut -d' ' -f2 | cut -d'.' -f1,2)
|
|
||||||
echo "✓ Python $python_version detected"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Install uv
|
PYTHON_CMD=""
|
||||||
echo "[2/10] Installing uv (fast Python package installer)..."
|
for cmd in python3.12 python3.11 python3.10 python3 python; do
|
||||||
if ! command -v uv &> /dev/null; then
|
if command -v $cmd &> /dev/null; then
|
||||||
echo "Installing uv..."
|
if $cmd -c "import sys; exit(0 if sys.version_info >= (3, 10) else 1)" 2>/dev/null; then
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
PYTHON_CMD=$cmd
|
||||||
export PATH="$HOME/.cargo/bin:$PATH"
|
break
|
||||||
echo "✓ uv installed"
|
fi
|
||||||
else
|
fi
|
||||||
echo "✓ uv already installed: $(uv --version)"
|
done
|
||||||
|
|
||||||
|
if [ -z "$PYTHON_CMD" ]; then
|
||||||
|
echo -e "${YELLOW}✗${NC} Python 3.10+ required"
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Install Node.js 20 using NodeSource
|
PYTHON_VERSION=$($PYTHON_CMD -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||||
echo "[3/10] Installing Node.js 20..."
|
echo -e "${GREEN}✓${NC} Python $PYTHON_VERSION found"
|
||||||
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 -
|
# Virtual environment
|
||||||
sudo apt-get install -y nodejs
|
# ============================================================================
|
||||||
echo "✓ Node.js installed"
|
|
||||||
|
echo -e "${CYAN}→${NC} Setting up virtual environment..."
|
||||||
|
|
||||||
|
if [ ! -d "venv" ]; then
|
||||||
|
$PYTHON_CMD -m venv venv
|
||||||
|
echo -e "${GREEN}✓${NC} Created venv"
|
||||||
else
|
else
|
||||||
echo "✓ Node.js 20+ already installed: $(node --version)"
|
echo -e "${GREEN}✓${NC} venv exists"
|
||||||
fi
|
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
|
source venv/bin/activate
|
||||||
uv pip install -r requirements.txt
|
pip install --upgrade pip wheel setuptools > /dev/null
|
||||||
echo "✓ Python packages installed"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Install mini-swe-agent with uv
|
# ============================================================================
|
||||||
echo "[7/10] Installing mini-swe-agent..."
|
# Dependencies
|
||||||
uv pip install -e ./mini-swe-agent
|
# ============================================================================
|
||||||
echo "✓ mini-swe-agent installed"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Install Node.js dependencies
|
echo -e "${CYAN}→${NC} Installing dependencies..."
|
||||||
echo "[8/10] Installing Node.js dependencies..."
|
|
||||||
npm install
|
|
||||||
echo "✓ Node.js packages installed"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Set up environment file
|
pip install -e ".[all]" > /dev/null 2>&1 || pip install -e "." > /dev/null
|
||||||
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 ""
|
|
||||||
|
|
||||||
# Set up CLI config
|
echo -e "${GREEN}✓${NC} Dependencies installed"
|
||||||
echo "[10/10] Setting up CLI configuration..."
|
|
||||||
if [ ! -f "cli-config.yaml" ]; then
|
# ============================================================================
|
||||||
cp cli-config.yaml.example cli-config.yaml
|
# Environment file
|
||||||
echo "✓ cli-config.yaml created from example"
|
# ============================================================================
|
||||||
|
|
||||||
|
if [ ! -f ".env" ]; then
|
||||||
|
if [ -f ".env.example" ]; then
|
||||||
|
cp .env.example .env
|
||||||
|
echo -e "${GREEN}✓${NC} Created .env from template"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo "cli-config.yaml already exists, skipping..."
|
echo -e "${GREEN}✓${NC} .env exists"
|
||||||
fi
|
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 ""
|
echo ""
|
||||||
|
|
||||||
# Show Node.js and Python versions
|
# Ask if they want to run setup wizard now
|
||||||
echo "========================================="
|
read -p "Would you like to run the setup wizard now? [Y/n] " -n 1 -r
|
||||||
echo "Setup Complete!"
|
echo
|
||||||
echo "========================================="
|
if [[ $REPLY =~ ^[Yy]$ ]] || [[ -z $REPLY ]]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "Installed versions:"
|
python -m hermes_cli.main setup
|
||||||
echo " Node.js: $(node --version)"
|
fi
|
||||||
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 ""
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ def schedule_cronjob(
|
|||||||
schedule: str,
|
schedule: str,
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
repeat: Optional[int] = None,
|
repeat: Optional[int] = None,
|
||||||
|
deliver: Optional[str] = None,
|
||||||
task_id: str = None
|
task_id: str = None
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -60,16 +61,35 @@ def schedule_cronjob(
|
|||||||
- One-shot schedules default to repeat=1 (run once)
|
- One-shot schedules default to repeat=1 (run once)
|
||||||
- Intervals/cron default to forever
|
- Intervals/cron default to forever
|
||||||
- Set repeat=5 to run 5 times then auto-delete
|
- 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:
|
Returns:
|
||||||
JSON with job_id, next_run time, and confirmation
|
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:
|
try:
|
||||||
job = create_job(
|
job = create_job(
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
schedule=schedule,
|
schedule=schedule,
|
||||||
name=name,
|
name=name,
|
||||||
repeat=repeat
|
repeat=repeat,
|
||||||
|
deliver=deliver,
|
||||||
|
origin=origin
|
||||||
)
|
)
|
||||||
|
|
||||||
# Format repeat info for display
|
# Format repeat info for display
|
||||||
@@ -87,8 +107,9 @@ def schedule_cronjob(
|
|||||||
"name": job["name"],
|
"name": job["name"],
|
||||||
"schedule": job["schedule_display"],
|
"schedule": job["schedule_display"],
|
||||||
"repeat": repeat_display,
|
"repeat": repeat_display,
|
||||||
|
"deliver": job.get("deliver", "local"),
|
||||||
"next_run_at": job["next_run_at"],
|
"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)
|
}, indent=2)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -122,6 +143,13 @@ REPEAT BEHAVIOR:
|
|||||||
- Intervals/cron: run forever by default
|
- Intervals/cron: run forever by default
|
||||||
- Set repeat=N to run exactly N times then auto-delete
|
- 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.""",
|
Use for: reminders, periodic checks, scheduled reports, automated maintenance.""",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -141,6 +169,10 @@ Use for: reminders, periodic checks, scheduled reports, automated maintenance.""
|
|||||||
"repeat": {
|
"repeat": {
|
||||||
"type": "integer",
|
"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."
|
"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"]
|
"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"],
|
"prompt_preview": job["prompt"][:100] + "..." if len(job["prompt"]) > 100 else job["prompt"],
|
||||||
"schedule": job["schedule_display"],
|
"schedule": job["schedule_display"],
|
||||||
"repeat": repeat_status,
|
"repeat": repeat_status,
|
||||||
|
"deliver": job.get("deliver", "local"),
|
||||||
"next_run_at": job.get("next_run_at"),
|
"next_run_at": job.get("next_run_at"),
|
||||||
"last_run_at": job.get("last_run_at"),
|
"last_run_at": job.get("last_run_at"),
|
||||||
"last_status": job.get("last_status"),
|
"last_status": job.get("last_status"),
|
||||||
|
|||||||
57
toolsets.py
57
toolsets.py
@@ -132,6 +132,63 @@ TOOLSETS = {
|
|||||||
"schedule_cronjob", "list_cronjobs", "remove_cronjob"
|
"schedule_cronjob", "list_cronjobs", "remove_cronjob"
|
||||||
],
|
],
|
||||||
"includes": []
|
"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"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user