diff --git a/.env.example b/.env.example index ac6a187f34..f1c0b7ea8a 100644 --- a/.env.example +++ b/.env.example @@ -164,6 +164,10 @@ VOICE_TOOLS_OPENAI_KEY= # Slack allowed users (comma-separated Slack user IDs) # SLACK_ALLOWED_USERS= +# WhatsApp (built-in Baileys bridge — run `hermes whatsapp` to pair) +# WHATSAPP_ENABLED=false +# WHATSAPP_ALLOWED_USERS=15551234567 + # Gateway-wide: allow ALL users without an allowlist (default: false = deny) # Only set to true if you intentionally want open access. # GATEWAY_ALLOW_ALL_USERS=false diff --git a/README.md b/README.md index a97e637711..bdea761044 100644 --- a/README.md +++ b/README.md @@ -235,23 +235,31 @@ SLACK_ALLOWED_USERS=U01234ABCDE # Comma-separated Slack user IDs ### WhatsApp Setup -WhatsApp doesn't have a simple bot API like Telegram or Discord. Hermes supports two approaches: +WhatsApp doesn't have a simple bot API like Telegram or Discord. Hermes includes a built-in bridge using [Baileys](https://github.com/WhiskeySockets/Baileys) that connects via WhatsApp Web. The agent links to your WhatsApp account and responds to incoming messages. -**Option A — WhatsApp Business API** (requires [Meta Business verification](https://business.facebook.com/)): -- Production-grade, but requires a verified business account -- Set `WHATSAPP_ENABLED=true` in `~/.hermes/.env` and configure the Business API credentials - -**Option B — whatsapp-web.js bridge** (personal accounts): -1. Install Node.js if not already present -2. Set up the bridge: +1. **Run the setup command:** ```bash -# Add to ~/.hermes/.env: -WHATSAPP_ENABLED=true -WHATSAPP_ALLOWED_USERS=YOUR_PHONE_NUMBER # e.g. 15551234567 +hermes whatsapp ``` -3. On first launch, the gateway will display a QR code — scan it with WhatsApp on your phone to link the session +This will: +- Enable WhatsApp in your config +- Ask for your phone number (for the allowlist) +- Install bridge dependencies (Node.js required) +- Display a QR code — scan it with your phone (WhatsApp → Settings → Linked Devices → Link a Device) +- Exit automatically once paired + +2. **Start the gateway:** + +```bash +hermes gateway # Foreground +hermes gateway install # Or install as a system service (Linux) +``` + +The gateway starts the WhatsApp bridge automatically using the saved session. + +> **Note:** WhatsApp Web sessions can disconnect if WhatsApp updates their protocol. The gateway reconnects automatically. If you see persistent failures, re-pair with `hermes whatsapp`. Agent responses are prefixed with "⚕ Hermes Agent" so you can distinguish them from your own messages in self-chat. See [docs/messaging.md](docs/messaging.md) for advanced WhatsApp configuration. @@ -331,6 +339,8 @@ HERMES_TOOL_PROGRESS_MODE=all # or "new" for only when tool changes # Chat hermes # Interactive chat (default) hermes chat -q "Hello" # Single query mode +hermes --continue # Resume the most recent session (-c) +hermes --resume # Resume a specific session (-r) # Provider & model management hermes model # Switch provider and model interactively @@ -569,8 +579,22 @@ All CLI and messaging sessions are stored in a SQLite database (`~/.hermes/state - **FTS5 search** via the `session_search` tool -- search past conversations with Gemini Flash summarization - **Compression-triggered session splitting** -- when context is compressed, a new session is created linked to the parent, giving clean trajectories - **Source tagging** -- each session is tagged with its origin (cli, telegram, discord, etc.) +- **Session resume** -- pick up where you left off with `hermes --continue` (most recent) or `hermes --resume ` (specific session) - Batch runner and RL trajectories are NOT stored here (separate systems) +When you exit a CLI session, the resume command is printed automatically: + +``` +Resume this session with: + hermes --resume 20260225_143052_a1b2c3 + +Session: 20260225_143052_a1b2c3 +Duration: 12m 34s +Messages: 28 (5 user, 18 tool calls) +``` + +Use `hermes sessions list` to browse past sessions and find IDs to resume. + ### 📝 Session Logging Every conversation is logged to `~/.hermes/sessions/` for debugging: @@ -825,6 +849,8 @@ print(summary) **When the agent uses this:** 3+ tool calls with processing logic between them, bulk data filtering, conditional branching, loops. The intermediate tool results never enter the context window -- only the final `print()` output comes back. +**Security:** The child process runs with a minimal environment -- only safe system variables (`PATH`, `HOME`, `LANG`, etc.) are passed through. API keys, tokens, and credentials are stripped entirely. The script accesses tools exclusively via the RPC channel; it cannot read secrets from environment variables. + Configure via `~/.hermes/config.yaml`: ```yaml code_execution: @@ -1401,7 +1427,9 @@ All variables go in `~/.hermes/.env`. Run `hermes config set VAR value` to set t | `ANTHROPIC_API_KEY` | Direct Anthropic access | | `OPENAI_API_KEY` | API key for custom OpenAI-compatible endpoints (used with `OPENAI_BASE_URL`) | | `OPENAI_BASE_URL` | Base URL for custom endpoint (VLLM, SGLang, etc.) | +| `LLM_MODEL` | Default model name (fallback when `HERMES_MODEL` is not set) | | `VOICE_TOOLS_OPENAI_KEY` | OpenAI key for TTS and voice transcription (separate from custom endpoint) | +| `HERMES_HOME` | Override Hermes config directory (default: `~/.hermes`). All config, sessions, logs, and skills are stored here. | **Provider Auth (OAuth):** | Variable | Description | diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 49395d9fda..24c26ef860 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -12,6 +12,50 @@ from typing import Optional logger = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# Context file scanning — detect prompt injection in AGENTS.md, .cursorrules, +# SOUL.md before they get injected into the system prompt. +# --------------------------------------------------------------------------- + +_CONTEXT_THREAT_PATTERNS = [ + (r'ignore\s+(previous|all|above|prior)\s+instructions', "prompt_injection"), + (r'do\s+not\s+tell\s+the\s+user', "deception_hide"), + (r'system\s+prompt\s+override', "sys_prompt_override"), + (r'disregard\s+(your|all|any)\s+(instructions|rules|guidelines)', "disregard_rules"), + (r'act\s+as\s+(if|though)\s+you\s+(have\s+no|don\'t\s+have)\s+(restrictions|limits|rules)', "bypass_restrictions"), + (r'', "html_comment_injection"), + (r'<\s*div\s+style\s*=\s*["\'].*display\s*:\s*none', "hidden_div"), + (r'translate\s+.*\s+into\s+.*\s+and\s+(execute|run|eval)', "translate_execute"), + (r'curl\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)', "exfil_curl"), + (r'cat\s+[^\n]*(\.env|credentials|\.netrc|\.pgpass)', "read_secrets"), +] + +_CONTEXT_INVISIBLE_CHARS = { + '\u200b', '\u200c', '\u200d', '\u2060', '\ufeff', + '\u202a', '\u202b', '\u202c', '\u202d', '\u202e', +} + + +def _scan_context_content(content: str, filename: str) -> str: + """Scan context file content for injection. Returns sanitized content.""" + findings = [] + + # Check invisible unicode + for char in _CONTEXT_INVISIBLE_CHARS: + if char in content: + findings.append(f"invisible unicode U+{ord(char):04X}") + + # Check threat patterns + for pattern, pid in _CONTEXT_THREAT_PATTERNS: + if re.search(pattern, content, re.IGNORECASE): + findings.append(pid) + + if findings: + logger.warning("Context file %s blocked: %s", filename, ", ".join(findings)) + return f"[BLOCKED: {filename} contained potential prompt injection ({', '.join(findings)}). Content not loaded.]" + + return content + # ========================================================================= # Constants # ========================================================================= @@ -215,6 +259,7 @@ def build_context_files_prompt(cwd: Optional[str] = None) -> str: content = agents_path.read_text(encoding="utf-8").strip() if content: rel_path = agents_path.relative_to(cwd_path) + content = _scan_context_content(content, str(rel_path)) total_agents_content += f"## {rel_path}\n\n{content}\n\n" except Exception as e: logger.debug("Could not read %s: %s", agents_path, e) @@ -230,6 +275,7 @@ def build_context_files_prompt(cwd: Optional[str] = None) -> str: try: content = cursorrules_file.read_text(encoding="utf-8").strip() if content: + content = _scan_context_content(content, ".cursorrules") cursorrules_content += f"## .cursorrules\n\n{content}\n\n" except Exception as e: logger.debug("Could not read .cursorrules: %s", e) @@ -241,6 +287,7 @@ def build_context_files_prompt(cwd: Optional[str] = None) -> str: try: content = mdc_file.read_text(encoding="utf-8").strip() if content: + content = _scan_context_content(content, f".cursor/rules/{mdc_file.name}") cursorrules_content += f"## .cursor/rules/{mdc_file.name}\n\n{content}\n\n" except Exception as e: logger.debug("Could not read %s: %s", mdc_file, e) @@ -265,6 +312,7 @@ def build_context_files_prompt(cwd: Optional[str] = None) -> str: try: content = soul_path.read_text(encoding="utf-8").strip() if content: + content = _scan_context_content(content, "SOUL.md") content = _truncate_content(content, "SOUL.md") sections.append( f"## SOUL.md\n\nIf SOUL.md is present, embody its persona and tone. " diff --git a/cli.py b/cli.py index 32a751d154..f0b819a406 100755 --- a/cli.py +++ b/cli.py @@ -49,16 +49,26 @@ import threading import queue -# Load environment variables first +# Load .env from ~/.hermes/.env first, then project root as dev fallback from dotenv import load_dotenv from hermes_constants import OPENROUTER_BASE_URL -env_path = Path(__file__).parent / '.env' -if env_path.exists(): +_hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) +_user_env = _hermes_home / ".env" +_project_env = Path(__file__).parent / '.env' +if _user_env.exists(): try: - load_dotenv(dotenv_path=env_path, encoding="utf-8") + load_dotenv(dotenv_path=_user_env, encoding="utf-8") except UnicodeDecodeError: - load_dotenv(dotenv_path=env_path, encoding="latin-1") + load_dotenv(dotenv_path=_user_env, encoding="latin-1") +elif _project_env.exists(): + try: + load_dotenv(dotenv_path=_project_env, encoding="utf-8") + except UnicodeDecodeError: + load_dotenv(dotenv_path=_project_env, encoding="latin-1") + +# Point mini-swe-agent at ~/.hermes/ so it shares our config +os.environ.setdefault("MSWEA_GLOBAL_CONFIG_DIR", str(_hermes_home)) # ============================================================================= # Configuration Loading @@ -132,15 +142,6 @@ def load_cli_config() -> Dict[str, Any]: 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 - try: - load_dotenv(dotenv_path=user_env_path, override=True, encoding="utf-8") - except UnicodeDecodeError: - load_dotenv(dotenv_path=user_env_path, override=True, encoding="latin-1") - # Default configuration defaults = { "model": { @@ -744,6 +745,7 @@ class HermesCLI: max_turns: int = 60, verbose: bool = False, compact: bool = False, + resume: str = None, ): """ Initialize the Hermes CLI. @@ -757,6 +759,7 @@ class HermesCLI: max_turns: Maximum tool-calling iterations (default: 60) verbose: Enable verbose logging compact: Use compact display mode + resume: Session ID to resume (restores conversation history from SQLite) """ # Initialize Rich console self.console = Console() @@ -830,12 +833,16 @@ class HermesCLI: # Conversation state self.conversation_history: List[Dict[str, Any]] = [] self.session_start = datetime.now() + self._resumed = False - # Generate session ID with timestamp for display and logging - # Format: YYYYMMDD_HHMMSS_shortUUID (e.g., 20260201_143052_a1b2c3) - timestamp_str = self.session_start.strftime("%Y%m%d_%H%M%S") - short_uuid = uuid.uuid4().hex[:6] - self.session_id = f"{timestamp_str}_{short_uuid}" + # Session ID: reuse existing one when resuming, otherwise generate fresh + if resume: + self.session_id = resume + self._resumed = True + else: + timestamp_str = self.session_start.strftime("%Y%m%d_%H%M%S") + short_uuid = uuid.uuid4().hex[:6] + self.session_id = f"{timestamp_str}_{short_uuid}" # History file for persistent input recall across sessions self._history_file = Path.home() / ".hermes_history" @@ -894,6 +901,7 @@ class HermesCLI: def _init_agent(self) -> bool: """ Initialize the agent on first use. + When resuming a session, restores conversation history from SQLite. Returns: bool: True if successful, False otherwise @@ -912,6 +920,34 @@ class HermesCLI: except Exception as e: logger.debug("SQLite session store not available: %s", e) + # If resuming, validate the session exists and load its history + if self._resumed and self._session_db: + session_meta = self._session_db.get_session(self.session_id) + if not session_meta: + _cprint(f"\033[1;31mSession not found: {self.session_id}{_RST}") + _cprint(f"{_DIM}Use a session ID from a previous CLI run (hermes sessions list).{_RST}") + return False + restored = self._session_db.get_messages_as_conversation(self.session_id) + if restored: + self.conversation_history = restored + msg_count = len([m for m in restored if m.get("role") == "user"]) + _cprint( + f"{_GOLD}↻ Resumed session {_BOLD}{self.session_id}{_RST}{_GOLD} " + f"({msg_count} user message{'s' if msg_count != 1 else ''}, " + f"{len(restored)} total messages){_RST}" + ) + else: + _cprint(f"{_GOLD}Session {self.session_id} found but has no messages. Starting fresh.{_RST}") + # Re-open the session (clear ended_at so it's active again) + try: + self._session_db._conn.execute( + "UPDATE sessions SET ended_at = NULL, end_reason = NULL WHERE id = ?", + (self.session_id,), + ) + self._session_db._conn.commit() + except Exception: + pass + try: self.agent = AIAgent( model=self.model, @@ -1909,6 +1945,32 @@ class HermesCLI: print(f"Error: {e}") return None + def _print_exit_summary(self): + """Print session resume info on exit, similar to Claude Code.""" + print() + msg_count = len(self.conversation_history) + if msg_count > 0: + user_msgs = len([m for m in self.conversation_history if m.get("role") == "user"]) + tool_calls = len([m for m in self.conversation_history if m.get("role") == "tool" or m.get("tool_calls")]) + elapsed = datetime.now() - self.session_start + hours, remainder = divmod(int(elapsed.total_seconds()), 3600) + minutes, seconds = divmod(remainder, 60) + if hours > 0: + duration_str = f"{hours}h {minutes}m {seconds}s" + elif minutes > 0: + duration_str = f"{minutes}m {seconds}s" + else: + duration_str = f"{seconds}s" + + print(f"Resume this session with:") + print(f" hermes --resume {self.session_id}") + print() + print(f"Session: {self.session_id}") + print(f"Duration: {duration_str}") + print(f"Messages: {msg_count} ({user_msgs} user, {tool_calls} tool calls)") + else: + print("Goodbye! ⚕") + def run(self): """Run the interactive CLI loop with persistent input at bottom.""" self.show_banner() @@ -2569,7 +2631,7 @@ class HermesCLI: except Exception as e: logger.debug("Could not close session in DB: %s", e) _run_cleanup() - print("\nGoodbye! ⚕") + self._print_exit_summary() # ============================================================================ @@ -2590,6 +2652,7 @@ def main( list_tools: bool = False, list_toolsets: bool = False, gateway: bool = False, + resume: str = None, ): """ Hermes Agent CLI - Interactive AI Assistant @@ -2607,12 +2670,14 @@ def main( compact: Use compact display mode list_tools: List available tools and exit list_toolsets: List available toolsets and exit + resume: Resume a previous session by its ID (e.g., 20260225_143052_a1b2c3) Examples: python cli.py # Start interactive mode python cli.py --toolsets web,terminal # Use specific toolsets python cli.py -q "What is Python?" # Single query mode python cli.py --list-tools # List tools and exit + python cli.py --resume 20260225_143052_a1b2c3 # Resume session """ # Signal to terminal_tool that we're in interactive mode # This enables interactive sudo password prompts with timeout @@ -2661,6 +2726,7 @@ def main( max_turns=max_turns, verbose=verbose, compact=compact, + resume=resume, ) # Handle list commands (don't init agent for these) @@ -2682,6 +2748,7 @@ def main( cli.show_banner() cli.console.print(f"[bold blue]Query:[/] {query}") cli.chat(query) + cli._print_exit_summary() return # Run interactive mode diff --git a/cron/scheduler.py b/cron/scheduler.py index 4d45fde1e2..df88e56b73 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -34,8 +34,11 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from cron.jobs import get_due_jobs, mark_job_run, save_job_output +# Resolve Hermes home directory (respects HERMES_HOME override) +_hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) + # File-based lock prevents concurrent ticks from gateway + daemon + systemd timer -_LOCK_DIR = Path.home() / ".hermes" / "cron" +_LOCK_DIR = _hermes_home / "cron" _LOCK_FILE = _LOCK_DIR / ".tick.lock" @@ -165,15 +168,15 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: # changes take effect without a gateway restart. from dotenv import load_dotenv try: - load_dotenv(os.path.expanduser("~/.hermes/.env"), override=True, encoding="utf-8") + load_dotenv(str(_hermes_home / ".env"), override=True, encoding="utf-8") except UnicodeDecodeError: - load_dotenv(os.path.expanduser("~/.hermes/.env"), override=True, encoding="latin-1") + load_dotenv(str(_hermes_home / ".env"), override=True, encoding="latin-1") - model = os.getenv("HERMES_MODEL", "anthropic/claude-opus-4.6") + model = os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL") or "anthropic/claude-opus-4.6" try: import yaml - _cfg_path = os.path.expanduser("~/.hermes/config.yaml") + _cfg_path = str(_hermes_home / "config.yaml") if os.path.exists(_cfg_path): with open(_cfg_path) as _f: _cfg = yaml.safe_load(_f) or {} diff --git a/docs/cli.md b/docs/cli.md index 65a675518c..76a50e5734 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -6,20 +6,24 @@ The Hermes Agent CLI provides an interactive terminal interface for working with ```bash # Basic usage -./hermes +hermes # With specific model -./hermes --model "anthropic/claude-sonnet-4" +hermes --model "anthropic/claude-sonnet-4" # With specific provider -./hermes --provider nous # Use Nous Portal (requires: hermes login) -./hermes --provider openrouter # Force OpenRouter +hermes --provider nous # Use Nous Portal (requires: hermes login) +hermes --provider openrouter # Force OpenRouter # With specific toolsets -./hermes --toolsets "web,terminal,skills" +hermes --toolsets "web,terminal,skills" + +# Resume previous sessions +hermes --continue # Resume the most recent CLI session (-c) +hermes --resume # Resume a specific session by ID (-r) # Verbose mode -./hermes --verbose +hermes --verbose ``` ## Architecture @@ -238,6 +242,34 @@ This allows you to have different terminal configs for CLI vs batch processing. - **Conversations**: Use `/save` to export conversations - **Reset**: Use `/clear` for full reset, `/reset` to just clear history - **Session Logs**: Every session automatically logs to `logs/session_{session_id}.json` +- **Resume**: Pick up any previous session with `--resume` or `--continue` + +### Resuming Sessions + +When you exit a CLI session, a resume command is printed: + +``` +Resume this session with: + hermes --resume 20260225_143052_a1b2c3 + +Session: 20260225_143052_a1b2c3 +Duration: 12m 34s +Messages: 28 (5 user, 18 tool calls) +``` + +To resume: + +```bash +hermes --continue # Resume the most recent CLI session +hermes -c # Short form +hermes --resume 20260225_143052_a1b2c3 # Resume a specific session by ID +hermes -r 20260225_143052_a1b2c3 # Short form +hermes chat --resume 20260225_143052_a1b2c3 # Explicit subcommand form +``` + +Resuming restores the full conversation history from SQLite (`~/.hermes/state.db`). The agent sees all previous messages, tool calls, and responses — just as if you never left. New messages append to the same session in the database. + +Use `hermes sessions list` to browse past sessions and find IDs. ### Session Logging diff --git a/environments/tool_context.py b/environments/tool_context.py index 52be54239c..d7fde1fec6 100644 --- a/environments/tool_context.py +++ b/environments/tool_context.py @@ -46,7 +46,8 @@ def _run_tool_in_thread(tool_name: str, arguments: Dict[str, Any], task_id: str) Run a tool call in a thread pool executor so backends that use asyncio.run() internally (modal, docker) get a clean event loop. - If we're already in an async context, uses run_in_executor. + If we're already in an async context, executes handle_function_call() in a + disposable worker thread and blocks for the result. If not (e.g., called from sync code), runs directly. """ try: @@ -94,7 +95,7 @@ class ToolContext: backend = os.getenv("TERMINAL_ENV", "local") logger.debug("ToolContext.terminal [%s backend] task=%s: %s", backend, self.task_id[:8], command[:100]) - # Run in thread pool so modal/docker backends' asyncio.run() doesn't deadlock + # Run via thread helper so modal/docker backends' asyncio.run() doesn't deadlock result = _run_tool_in_thread( "terminal", {"command": command, "timeout": timeout}, diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 9aef4033f5..b28b78e7ca 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -6,10 +6,13 @@ and implement the required methods. """ import asyncio +import logging import os import re import uuid from abc import ABC, abstractmethod + +logger = logging.getLogger(__name__) from dataclasses import dataclass, field from datetime import datetime from pathlib import Path @@ -517,6 +520,8 @@ class BasePlatformAdapter(ABC): response = await self._message_handler(event) # Send response if any + if not response: + logger.warning("[%s] Handler returned empty/None response for %s", self.name, event.source.chat_id) if response: # Extract MEDIA: tags (from TTS tool) before other processing media_files, response = self.extract_media(response) @@ -526,6 +531,7 @@ class BasePlatformAdapter(ABC): # Send the text portion first (if any remains after extractions) if text_content: + logger.info("[%s] Sending response (%d chars) to %s", self.name, len(text_content), event.source.chat_id) result = await self.send( chat_id=event.source.chat_id, content=text_content, diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index a90f94e39f..eb0d6f1b5e 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -18,6 +18,7 @@ with different backends via a bridge pattern. import asyncio import json import logging +import os import subprocess from pathlib import Path from typing import Dict, List, Optional, Any @@ -80,11 +81,17 @@ class WhatsAppAdapter(BasePlatformAdapter): # WhatsApp message limits MAX_MESSAGE_LENGTH = 65536 # WhatsApp allows longer messages + # Default bridge location relative to the hermes-agent install + _DEFAULT_BRIDGE_DIR = Path(__file__).resolve().parents[2] / "scripts" / "whatsapp-bridge" + 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._bridge_script: Optional[str] = config.extra.get( + "bridge_script", + str(self._DEFAULT_BRIDGE_DIR / "bridge.js"), + ) self._session_path: Path = Path(config.extra.get( "session_path", Path.home() / ".hermes" / "whatsapp" / "session" @@ -98,25 +105,58 @@ class WhatsAppAdapter(BasePlatformAdapter): 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.") + logger.warning("[%s] Node.js not found. WhatsApp requires Node.js.", self.name) return False bridge_path = Path(self._bridge_script) if not bridge_path.exists(): - print(f"[{self.name}] Bridge script not found: {bridge_path}") + logger.warning("[%s] Bridge script not found: %s", self.name, bridge_path) return False + logger.info("[%s] Bridge found at %s", self.name, bridge_path) + + # Auto-install npm dependencies if node_modules doesn't exist + bridge_dir = bridge_path.parent + if not (bridge_dir / "node_modules").exists(): + print(f"[{self.name}] Installing WhatsApp bridge dependencies...") + try: + install_result = subprocess.run( + ["npm", "install", "--silent"], + cwd=str(bridge_dir), + capture_output=True, + text=True, + timeout=60, + ) + if install_result.returncode != 0: + print(f"[{self.name}] npm install failed: {install_result.stderr}") + return False + print(f"[{self.name}] Dependencies installed") + except Exception as e: + print(f"[{self.name}] Failed to install dependencies: {e}") + return False + try: # Ensure session directory exists self._session_path.mkdir(parents=True, exist_ok=True) - # Start the bridge process + # Kill any orphaned bridge from a previous gateway run + try: + result = subprocess.run( + ["fuser", f"{self._bridge_port}/tcp"], + capture_output=True, timeout=5, + ) + if result.returncode == 0: + # Port is in use — kill the process + subprocess.run( + ["fuser", "-k", f"{self._bridge_port}/tcp"], + capture_output=True, timeout=5, + ) + import time + time.sleep(2) + except Exception: + pass + + # Start the bridge process in its own process group self._bridge_process = subprocess.Popen( [ "node", @@ -124,19 +164,32 @@ class WhatsAppAdapter(BasePlatformAdapter): "--port", str(self._bridge_port), "--session", str(self._session_path), ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + preexec_fn=os.setsid, ) - # 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}") + # Wait for bridge to be ready via HTTP health check + import aiohttp + for attempt in range(15): + await asyncio.sleep(1) + if self._bridge_process.poll() is not None: + print(f"[{self.name}] Bridge process died (exit code {self._bridge_process.returncode})") + return False + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"http://localhost:{self._bridge_port}/health", + timeout=aiohttp.ClientTimeout(total=2) + ) as resp: + if resp.status == 200: + data = await resp.json() + print(f"[{self.name}] Bridge ready (status: {data.get('status', '?')})") + break + except Exception: + continue + else: + print(f"[{self.name}] Bridge did not become ready in 15s") return False # Start message polling task @@ -148,20 +201,37 @@ class WhatsAppAdapter(BasePlatformAdapter): return True except Exception as e: - print(f"[{self.name}] Failed to start bridge: {e}") + logger.error("[%s] Failed to start bridge: %s", self.name, e, exc_info=True) return False async def disconnect(self) -> None: - """Stop the WhatsApp bridge.""" + """Stop the WhatsApp bridge and clean up any orphaned processes.""" if self._bridge_process: try: - self._bridge_process.terminate() + # Kill the entire process group so child node processes die too + import signal + try: + os.killpg(os.getpgid(self._bridge_process.pid), signal.SIGTERM) + except (ProcessLookupError, PermissionError): + self._bridge_process.terminate() await asyncio.sleep(1) if self._bridge_process.poll() is None: - self._bridge_process.kill() + try: + os.killpg(os.getpgid(self._bridge_process.pid), signal.SIGKILL) + except (ProcessLookupError, PermissionError): + self._bridge_process.kill() except Exception as e: print(f"[{self.name}] Error stopping bridge: {e}") + # Also kill any orphaned bridge processes on our port + try: + subprocess.run( + ["fuser", "-k", f"{self._bridge_port}/tcp"], + capture_output=True, timeout=5, + ) + except Exception: + pass + self._running = False self._bridge_process = None print(f"[{self.name}] Disconnected") @@ -355,9 +425,3 @@ class WhatsAppAdapter(BasePlatformAdapter): print(f"[{self.name}] Error building event: {e}") return None - -# Note: A reference Node.js bridge script would be provided in scripts/whatsapp-bridge/ -# It would use whatsapp-web.js or Baileys to: -# 1. Handle WhatsApp Web authentication (QR code) -# 2. Listen for incoming messages -# 3. Expose HTTP endpoints for send/receive/status diff --git a/gateway/run.py b/gateway/run.py index 387f88339f..c5b8b1c4f1 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -28,9 +28,12 @@ from typing import Dict, Optional, Any, List # Add parent directory to path sys.path.insert(0, str(Path(__file__).parent.parent)) +# Resolve Hermes home directory (respects HERMES_HOME override) +_hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) + # Load environment variables from ~/.hermes/.env first from dotenv import load_dotenv -_env_path = Path.home() / '.hermes' / '.env' +_env_path = _hermes_home / '.env' if _env_path.exists(): try: load_dotenv(_env_path, encoding="utf-8") @@ -41,7 +44,7 @@ load_dotenv() # Bridge config.yaml values into the environment so os.getenv() picks them up. # Values already set in the environment (from .env or shell) take precedence. -_config_path = Path.home() / '.hermes' / 'config.yaml' +_config_path = _hermes_home / 'config.yaml' if _config_path.exists(): try: import yaml as _yaml @@ -163,7 +166,7 @@ class GatewayRunner: if not file_path: try: import yaml as _y - cfg_path = Path.home() / ".hermes" / "config.yaml" + cfg_path = _hermes_home / "config.yaml" if cfg_path.exists(): with open(cfg_path) as _f: cfg = _y.safe_load(_f) or {} @@ -174,7 +177,7 @@ class GatewayRunner: return [] path = Path(file_path).expanduser() if not path.is_absolute(): - path = Path.home() / ".hermes" / path + path = _hermes_home / path if not path.exists(): logger.warning("Prefill messages file not found: %s", path) return [] @@ -201,7 +204,7 @@ class GatewayRunner: return prompt try: import yaml as _y - cfg_path = Path.home() / ".hermes" / "config.yaml" + cfg_path = _hermes_home / "config.yaml" if cfg_path.exists(): with open(cfg_path) as _f: cfg = _y.safe_load(_f) or {} @@ -222,7 +225,7 @@ class GatewayRunner: if not effort: try: import yaml as _y - cfg_path = Path.home() / ".hermes" / "config.yaml" + cfg_path = _hermes_home / "config.yaml" if cfg_path.exists(): with open(cfg_path) as _f: cfg = _y.safe_load(_f) or {} @@ -450,7 +453,11 @@ class GatewayRunner: if global_allowlist: allowed_ids.update(uid.strip() for uid in global_allowlist.split(",") if uid.strip()) - return user_id in allowed_ids + # WhatsApp JIDs have @s.whatsapp.net suffix — strip it for comparison + check_ids = {user_id} + if "@" in user_id: + check_ids.add(user_id.split("@")[0]) + return bool(check_ids & allowed_ids) async def _handle_message(self, event: MessageEvent) -> Optional[str]: """ @@ -787,9 +794,11 @@ class GatewayRunner: if old_history: from run_agent import AIAgent loop = asyncio.get_event_loop() + # Resolve credentials so the flush agent can reach the LLM + _flush_model = os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL") or "anthropic/claude-opus-4.6" def _do_flush(): tmp_agent = AIAgent( - model=os.getenv("HERMES_MODEL", "anthropic/claude-opus-4.6"), + model=_flush_model, **_resolve_runtime_agent_kwargs(), max_iterations=5, quiet_mode=True, @@ -897,7 +906,7 @@ class GatewayRunner: try: import yaml - config_path = Path.home() / '.hermes' / 'config.yaml' + config_path = _hermes_home / 'config.yaml' if config_path.exists(): with open(config_path, 'r') as f: config = yaml.safe_load(f) or {} @@ -994,7 +1003,7 @@ class GatewayRunner: # Save to config.yaml try: import yaml - config_path = Path.home() / '.hermes' / 'config.yaml' + config_path = _hermes_home / 'config.yaml' user_config = {} if config_path.exists(): with open(config_path) as f: @@ -1256,7 +1265,7 @@ class GatewayRunner: # Try to load platform_toolsets from config platform_toolsets_config = {} try: - config_path = Path.home() / '.hermes' / 'config.yaml' + config_path = _hermes_home / 'config.yaml' if config_path.exists(): import yaml with open(config_path, 'r') as f: @@ -1411,11 +1420,11 @@ class GatewayRunner: except Exception: pass - model = os.getenv("HERMES_MODEL", "anthropic/claude-opus-4.6") + model = os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL") or "anthropic/claude-opus-4.6" try: import yaml as _y - _cfg_path = Path.home() / ".hermes" / "config.yaml" + _cfg_path = _hermes_home / "config.yaml" if _cfg_path.exists(): with open(_cfg_path) as _f: _cfg = _y.safe_load(_f) or {} @@ -1705,7 +1714,7 @@ async def start_gateway(config: Optional[GatewayConfig] = None) -> bool: A False return causes a non-zero exit code so systemd can auto-restart. """ # Configure rotating file log so gateway output is persisted for debugging - log_dir = Path.home() / '.hermes' / 'logs' + log_dir = _hermes_home / 'logs' log_dir.mkdir(parents=True, exist_ok=True) file_handler = RotatingFileHandler( log_dir / 'gateway.log', diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 77ff65d1ec..031c6eaf8c 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -23,9 +23,13 @@ if _env_path.exists(): load_dotenv(_env_path, encoding="utf-8") except UnicodeDecodeError: load_dotenv(_env_path, encoding="latin-1") -# Also try project .env as fallback +# Also try project .env as dev fallback load_dotenv(PROJECT_ROOT / ".env", override=False, encoding="utf-8") +# Point mini-swe-agent at ~/.hermes/ so it shares our config +os.environ.setdefault("MSWEA_GLOBAL_CONFIG_DIR", str(HERMES_HOME)) +os.environ.setdefault("MSWEA_SILENT_STARTUP", "1") + from hermes_cli.colors import Colors, color from hermes_constants import OPENROUTER_MODELS_URL @@ -207,7 +211,7 @@ def run_doctor(args): print() print(color("◆ Directory Structure", Colors.CYAN, Colors.BOLD)) - hermes_home = Path.home() / ".hermes" + hermes_home = HERMES_HOME if hermes_home.exists(): check_ok("~/.hermes directory exists") else: @@ -255,17 +259,6 @@ def run_doctor(args): check_ok("Created ~/.hermes/SOUL.md with basic template") fixed_count += 1 - logs_dir = PROJECT_ROOT / "logs" - if logs_dir.exists(): - check_ok("logs/ directory exists (project root)") - else: - if should_fix: - logs_dir.mkdir(parents=True, exist_ok=True) - check_ok("Created logs/ directory") - fixed_count += 1 - else: - check_warn("logs/ not found", "(will be created on first use)") - # Check memory directory memories_dir = hermes_home / "memories" if memories_dir.exists(): @@ -374,6 +367,41 @@ def run_doctor(args): else: check_warn("Node.js not found", "(optional, needed for browser tools)") + # npm audit for all Node.js packages + if shutil.which("npm"): + npm_dirs = [ + (PROJECT_ROOT, "Browser tools (agent-browser)"), + (PROJECT_ROOT / "scripts" / "whatsapp-bridge", "WhatsApp bridge"), + ] + for npm_dir, label in npm_dirs: + if not (npm_dir / "node_modules").exists(): + continue + try: + audit_result = subprocess.run( + ["npm", "audit", "--json"], + cwd=str(npm_dir), + capture_output=True, text=True, timeout=30, + ) + import json as _json + audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {} + vuln_count = audit_data.get("metadata", {}).get("vulnerabilities", {}) + critical = vuln_count.get("critical", 0) + high = vuln_count.get("high", 0) + moderate = vuln_count.get("moderate", 0) + total = critical + high + moderate + if total == 0: + check_ok(f"{label} deps", "(no known vulnerabilities)") + elif critical > 0 or high > 0: + check_warn( + f"{label} deps", + f"({critical} critical, {high} high, {moderate} moderate — run: cd {npm_dir} && npm audit fix)" + ) + issues.append(f"{label} has {total} npm vulnerability(ies)") + else: + check_ok(f"{label} deps", f"({moderate} moderate vulnerability(ies))") + except Exception: + pass + # ========================================================================= # Check: API connectivity # ========================================================================= @@ -477,14 +505,15 @@ def run_doctor(args): check_ok(info.get("name", tid)) for item in unavailable: - if item["missing_vars"]: - vars_str = ", ".join(item["missing_vars"]) + env_vars = item.get("missing_vars") or item.get("env_vars") or [] + if env_vars: + vars_str = ", ".join(env_vars) check_warn(item["name"], f"(missing {vars_str})") else: check_warn(item["name"], "(system dependency not met)") - + # Count disabled tools with API key requirements - api_disabled = [u for u in unavailable if u["missing_vars"]] + api_disabled = [u for u in unavailable if (u.get("missing_vars") or u.get("env_vars"))] if api_disabled: issues.append("Run 'hermes setup' to configure missing API keys for full tool access") except Exception as e: @@ -496,7 +525,7 @@ def run_doctor(args): print() print(color("◆ Skills Hub", Colors.CYAN, Colors.BOLD)) - hub_dir = PROJECT_ROOT / "skills" / ".hub" + hub_dir = HERMES_HOME / "skills" / ".hub" if hub_dir.exists(): check_ok("Skills Hub directory exists") lock_file = hub_dir / "lock.json" @@ -515,7 +544,8 @@ def run_doctor(args): else: check_warn("Skills Hub directory not initialized", "(run: hermes skills list)") - github_token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + from hermes_cli.config import get_env_value + github_token = get_env_value("GITHUB_TOKEN") or get_env_value("GH_TOKEN") if github_token: check_ok("GitHub token configured (authenticated API access)") else: diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 0fe8116210..e6ee65b67b 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -28,19 +28,26 @@ import argparse import os import sys from pathlib import Path +from typing import Optional # Add project root to path PROJECT_ROOT = Path(__file__).parent.parent.resolve() sys.path.insert(0, str(PROJECT_ROOT)) -# Load .env file +# Load .env from ~/.hermes/.env first, then project root as dev fallback from dotenv import load_dotenv -env_path = PROJECT_ROOT / '.env' -if env_path.exists(): +from hermes_cli.config import get_env_path, get_hermes_home +_user_env = get_env_path() +if _user_env.exists(): try: - load_dotenv(dotenv_path=env_path, encoding="utf-8") + load_dotenv(dotenv_path=_user_env, encoding="utf-8") except UnicodeDecodeError: - load_dotenv(dotenv_path=env_path, encoding="latin-1") + load_dotenv(dotenv_path=_user_env, encoding="latin-1") +load_dotenv(dotenv_path=PROJECT_ROOT / '.env', override=False) + +# Point mini-swe-agent at ~/.hermes/ so it shares our config +os.environ.setdefault("MSWEA_GLOBAL_CONFIG_DIR", str(get_hermes_home())) +os.environ.setdefault("MSWEA_SILENT_STARTUP", "1") import logging @@ -91,8 +98,31 @@ def _has_any_provider_configured() -> bool: return False +def _resolve_last_cli_session() -> Optional[str]: + """Look up the most recent CLI session ID from SQLite. Returns None if unavailable.""" + try: + from hermes_state import SessionDB + db = SessionDB() + sessions = db.search_sessions(source="cli", limit=1) + db.close() + if sessions: + return sessions[0]["id"] + except Exception: + pass + return None + + def cmd_chat(args): """Run interactive chat CLI.""" + # Resolve --continue into --resume with the latest CLI session + if getattr(args, "continue_last", False) and not getattr(args, "resume", None): + last_id = _resolve_last_cli_session() + if last_id: + args.resume = last_id + else: + print("No previous CLI session found to continue.") + sys.exit(1) + # First-run guard: check if any provider is configured before launching if not _has_any_provider_configured(): print() @@ -121,6 +151,7 @@ def cmd_chat(args): "toolsets": args.toolsets, "verbose": args.verbose, "query": args.query, + "resume": getattr(args, "resume", None), } # Filter out None values kwargs = {k: v for k, v in kwargs.items() if v is not None} @@ -134,6 +165,116 @@ def cmd_gateway(args): gateway_command(args) +def cmd_whatsapp(args): + """Set up WhatsApp: enable, configure allowed users, install bridge, pair via QR.""" + import os + import subprocess + from pathlib import Path + from hermes_cli.config import get_env_value, save_env_value + + print() + print("⚕ WhatsApp Setup") + print("=" * 50) + print() + print("This will link your WhatsApp account to Hermes Agent.") + print("The agent will respond to messages sent to your WhatsApp number.") + print() + + # Step 1: Enable WhatsApp + current = get_env_value("WHATSAPP_ENABLED") + if current and current.lower() == "true": + print("✓ WhatsApp is already enabled") + else: + save_env_value("WHATSAPP_ENABLED", "true") + print("✓ WhatsApp enabled") + + # Step 2: Allowed users + current_users = get_env_value("WHATSAPP_ALLOWED_USERS") or "" + if current_users: + print(f"✓ Allowed users: {current_users}") + response = input("\n Update allowed users? [y/N] ").strip() + if response.lower() in ("y", "yes"): + phone = input(" Phone number(s) (e.g. 15551234567, comma-separated): ").strip() + if phone: + save_env_value("WHATSAPP_ALLOWED_USERS", phone.replace(" ", "")) + print(f" ✓ Updated to: {phone}") + else: + print() + phone = input(" Your phone number (e.g. 15551234567): ").strip() + if phone: + save_env_value("WHATSAPP_ALLOWED_USERS", phone.replace(" ", "")) + print(f" ✓ Allowed users set: {phone}") + else: + print(" ⚠ No allowlist — the agent will respond to ALL incoming messages") + + # Step 3: Install bridge deps + project_root = Path(__file__).resolve().parents[1] + bridge_dir = project_root / "scripts" / "whatsapp-bridge" + bridge_script = bridge_dir / "bridge.js" + + if not bridge_script.exists(): + print(f"\n✗ Bridge script not found at {bridge_script}") + return + + if not (bridge_dir / "node_modules").exists(): + print("\n→ Installing WhatsApp bridge dependencies...") + result = subprocess.run( + ["npm", "install"], + cwd=str(bridge_dir), + capture_output=True, + text=True, + timeout=120, + ) + if result.returncode != 0: + print(f" ✗ npm install failed: {result.stderr}") + return + print(" ✓ Dependencies installed") + else: + print("✓ Bridge dependencies already installed") + + # Step 4: Check for existing session + session_dir = Path.home() / ".hermes" / "whatsapp" / "session" + session_dir.mkdir(parents=True, exist_ok=True) + + if (session_dir / "creds.json").exists(): + print("✓ Existing WhatsApp session found") + response = input("\n Re-pair? This will clear the existing session. [y/N] ").strip() + if response.lower() in ("y", "yes"): + import shutil + shutil.rmtree(session_dir, ignore_errors=True) + session_dir.mkdir(parents=True, exist_ok=True) + print(" ✓ Session cleared") + else: + print("\n✓ WhatsApp is configured and paired!") + print(" Start the gateway with: hermes gateway") + return + + # Step 5: Run bridge in pair-only mode (no HTTP server, exits after QR scan) + print() + print("─" * 50) + print("📱 Scan the QR code with your phone:") + print(" WhatsApp → Settings → Linked Devices → Link a Device") + print("─" * 50) + print() + + try: + subprocess.run( + ["node", str(bridge_script), "--pair-only", "--session", str(session_dir)], + cwd=str(bridge_dir), + ) + except KeyboardInterrupt: + pass + + print() + if (session_dir / "creds.json").exists(): + print("✓ WhatsApp paired successfully!") + print() + print("Start the gateway with: hermes gateway") + print("Or install as a service: hermes gateway install") + else: + print("⚠ Pairing may not have completed. Run 'hermes whatsapp' to try again.") + + def cmd_setup(args): """Interactive setup wizard.""" from hermes_cli.setup import run_setup_wizard @@ -682,6 +823,8 @@ def main(): Examples: hermes Start interactive chat hermes chat -q "Hello" Single query mode + hermes --continue Resume the most recent session + hermes --resume Resume a specific session hermes setup Run setup wizard hermes login Authenticate with an inference provider hermes logout Clear stored authentication @@ -691,6 +834,7 @@ Examples: hermes config set model gpt-4 Set a config value hermes gateway Run messaging gateway hermes gateway install Install as system service + hermes sessions list List past sessions hermes update Update to latest version For more help on a command: @@ -703,6 +847,19 @@ For more help on a command: action="store_true", help="Show version and exit" ) + parser.add_argument( + "--resume", "-r", + metavar="SESSION_ID", + default=None, + help="Resume a previous session by ID (shortcut for: hermes chat --resume ID)" + ) + parser.add_argument( + "--continue", "-c", + dest="continue_last", + action="store_true", + default=False, + help="Resume the most recent CLI session" + ) subparsers = parser.add_subparsers(dest="command", help="Command to run") @@ -737,6 +894,18 @@ For more help on a command: action="store_true", help="Verbose output" ) + chat_parser.add_argument( + "--resume", "-r", + metavar="SESSION_ID", + help="Resume a previous session by ID (shown on exit)" + ) + chat_parser.add_argument( + "--continue", "-c", + dest="continue_last", + action="store_true", + default=False, + help="Resume the most recent CLI session" + ) chat_parser.set_defaults(func=cmd_chat) # ========================================================================= @@ -805,6 +974,16 @@ For more help on a command: ) setup_parser.set_defaults(func=cmd_setup) + # ========================================================================= + # whatsapp command + # ========================================================================= + whatsapp_parser = subparsers.add_parser( + "whatsapp", + help="Set up WhatsApp integration", + description="Configure WhatsApp and pair via QR code" + ) + whatsapp_parser.set_defaults(func=cmd_whatsapp) + # ========================================================================= # login command # ========================================================================= @@ -1233,6 +1412,17 @@ For more help on a command: cmd_version(args) return + # Handle top-level --resume / --continue as shortcut to chat + if (args.resume or args.continue_last) and args.command is None: + args.command = "chat" + args.query = None + args.model = None + args.provider = None + args.toolsets = None + args.verbose = False + cmd_chat(args) + return + # Default to chat if no command specified if args.command is None: args.query = None @@ -1240,6 +1430,8 @@ For more help on a command: args.provider = None args.toolsets = None args.verbose = False + args.resume = None + args.continue_last = False cmd_chat(args) return diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 035d5167cd..befb4e7b87 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -163,8 +163,15 @@ def prompt_checklist(title: str, items: list, pre_selected: list = None) -> list try: from simple_term_menu import TerminalMenu + import re - menu_items = [f" {item}" for item in items] + # Strip emoji characters from menu labels — simple_term_menu miscalculates + # visual width of emojis on macOS, causing duplicated/garbled lines. + _emoji_re = re.compile( + "[\U0001f300-\U0001f9ff\U00002600-\U000027bf\U0000fe00-\U0000fe0f" + "\U0001fa00-\U0001fa6f\U0001fa70-\U0001faff\u200d]+", flags=re.UNICODE + ) + menu_items = [f" {_emoji_re.sub('', item).strip()}" for item in items] # Map pre-selected indices to the actual menu entry strings preselected = [menu_items[i] for i in pre_selected if i < len(menu_items)] @@ -1272,13 +1279,22 @@ def run_setup_wizard(args): # WhatsApp existing_whatsapp = get_env_value('WHATSAPP_ENABLED') if not existing_whatsapp and prompt_yes_no("Set up WhatsApp?", False): - print_info("WhatsApp uses a bridge service for connectivity.") - print_info("See docs/messaging.md for detailed WhatsApp setup instructions.") + print_info("WhatsApp connects via a built-in bridge (Baileys).") + print_info("Requires Node.js (already installed if you have browser tools).") + print_info("On first gateway start, you'll scan a QR code with your phone.") print() - if prompt_yes_no("Enable WhatsApp bridge?", True): + if prompt_yes_no("Enable WhatsApp?", True): save_env_value("WHATSAPP_ENABLED", "true") print_success("WhatsApp enabled") - print_info("Run 'hermes gateway' to complete WhatsApp pairing via QR code") + + allowed_users = prompt(" Your phone number (e.g. 15551234567, comma-separated for multiple)") + if allowed_users: + save_env_value("WHATSAPP_ALLOWED_USERS", allowed_users.replace(" ", "")) + print_success("WhatsApp allowlist configured") + else: + print_info("⚠️ No allowlist set — anyone who messages your WhatsApp will get a response!") + + print_info("Start the gateway with 'hermes gateway' and scan the QR code.") # Gateway reminder any_messaging = ( diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 4d542ece68..81b55cab78 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -12,6 +12,7 @@ from pathlib import Path PROJECT_ROOT = Path(__file__).parent.parent.resolve() from hermes_cli.colors import Colors, color +from hermes_cli.config import get_env_path, get_env_value from hermes_constants import OPENROUTER_MODELS_URL def check_mark(ok: bool) -> str: @@ -65,7 +66,7 @@ def show_status(args): print(f" Project: {PROJECT_ROOT}") print(f" Python: {sys.version.split()[0]}") - env_path = PROJECT_ROOT / '.env' + env_path = get_env_path() print(f" .env file: {check_mark(env_path.exists())} {'exists' if env_path.exists() else 'not found'}") # ========================================================================= @@ -88,7 +89,7 @@ def show_status(args): } for name, env_var in keys.items(): - value = os.getenv(env_var, "") + value = get_env_value(env_var) or "" has_key = bool(value) display = redact_key(value) if not show_all else value print(f" {name:<12} {check_mark(has_key)} {display}") diff --git a/pyproject.toml b/pyproject.toml index 7f6a4695ee..fdb13cbf7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,3 +66,10 @@ py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajector [tool.setuptools.packages.find] include = ["tools", "hermes_cli", "gateway", "cron"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "integration: marks tests requiring external services (API keys, Modal, etc.)", +] +addopts = "-m 'not integration'" diff --git a/rl_cli.py b/rl_cli.py index eaeec1d96d..3aa0412d4c 100644 --- a/rl_cli.py +++ b/rl_cli.py @@ -27,19 +27,25 @@ from pathlib import Path import fire import yaml -# Load environment variables from .env file +# Load .env from ~/.hermes/.env first, then project root as dev fallback from dotenv import load_dotenv -# Load from ~/.hermes/.env first, then local .env -hermes_env_path = Path.home() / '.hermes' / '.env' -local_env_path = Path(__file__).parent / '.env' +_hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) +_user_env = _hermes_home / ".env" +_project_env = Path(__file__).parent / '.env' -if hermes_env_path.exists(): - load_dotenv(dotenv_path=hermes_env_path) - print(f"✅ Loaded environment variables from {hermes_env_path}") -elif local_env_path.exists(): - load_dotenv(dotenv_path=local_env_path) - print(f"✅ Loaded environment variables from {local_env_path}") +if _user_env.exists(): + try: + load_dotenv(dotenv_path=_user_env, encoding="utf-8") + except UnicodeDecodeError: + load_dotenv(dotenv_path=_user_env, encoding="latin-1") + print(f"✅ Loaded environment variables from {_user_env}") +elif _project_env.exists(): + try: + load_dotenv(dotenv_path=_project_env, encoding="utf-8") + except UnicodeDecodeError: + load_dotenv(dotenv_path=_project_env, encoding="latin-1") + print(f"✅ Loaded environment variables from {_project_env}") # Set terminal working directory to tinker-atropos submodule # This ensures terminal commands run in the right context for RL work @@ -77,7 +83,7 @@ def load_hermes_config() -> dict: Returns: dict: Configuration with model, base_url, etc. """ - config_path = Path.home() / '.hermes' / 'config.yaml' + config_path = _hermes_home / 'config.yaml' config = { "model": DEFAULT_MODEL, diff --git a/run_agent.py b/run_agent.py index 6adc5b1a89..e67ba32a9c 100644 --- a/run_agent.py +++ b/run_agent.py @@ -39,19 +39,30 @@ import fire from datetime import datetime from pathlib import Path -# Load environment variables from .env file +# Load .env from ~/.hermes/.env first, then project root as dev fallback from dotenv import load_dotenv -# Load .env file if it exists -env_path = Path(__file__).parent / '.env' -if env_path.exists(): +_hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) +_user_env = _hermes_home / ".env" +_project_env = Path(__file__).parent / '.env' +if _user_env.exists(): try: - load_dotenv(dotenv_path=env_path, encoding="utf-8") + load_dotenv(dotenv_path=_user_env, encoding="utf-8") except UnicodeDecodeError: - load_dotenv(dotenv_path=env_path, encoding="latin-1") - logger.info("Loaded environment variables from %s", env_path) + load_dotenv(dotenv_path=_user_env, encoding="latin-1") + logger.info("Loaded environment variables from %s", _user_env) +elif _project_env.exists(): + try: + load_dotenv(dotenv_path=_project_env, encoding="utf-8") + except UnicodeDecodeError: + load_dotenv(dotenv_path=_project_env, encoding="latin-1") + logger.info("Loaded environment variables from %s", _project_env) else: - logger.info("No .env file found at %s. Using system environment variables.", env_path) + logger.info("No .env file found. Using system environment variables.") + +# Point mini-swe-agent at ~/.hermes/ so it shares our config +os.environ.setdefault("MSWEA_GLOBAL_CONFIG_DIR", str(_hermes_home)) +os.environ.setdefault("MSWEA_SILENT_STARTUP", "1") # Import our tool system from model_tools import get_tool_definitions, handle_function_call, check_toolset_requirements diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 2acf6493b2..c9f65afe40 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -545,6 +545,7 @@ function Copy-ConfigTemplates { New-Item -ItemType Directory -Force -Path "$HermesHome\audio_cache" | Out-Null New-Item -ItemType Directory -Force -Path "$HermesHome\memories" | Out-Null New-Item -ItemType Directory -Force -Path "$HermesHome\skills" | Out-Null + New-Item -ItemType Directory -Force -Path "$HermesHome\whatsapp\session" | Out-Null # Create .env $envPath = "$HermesHome\.env" @@ -626,7 +627,7 @@ function Install-NodeDeps { Push-Location $InstallDir if (Test-Path "package.json") { - Write-Info "Installing Node.js dependencies..." + Write-Info "Installing Node.js dependencies (browser tools)..." try { npm install --silent 2>&1 | Out-Null Write-Success "Node.js dependencies installed" @@ -635,6 +636,20 @@ function Install-NodeDeps { } } + # Install WhatsApp bridge dependencies + $bridgeDir = "$InstallDir\scripts\whatsapp-bridge" + if (Test-Path "$bridgeDir\package.json") { + Write-Info "Installing WhatsApp bridge dependencies..." + Push-Location $bridgeDir + try { + npm install --silent 2>&1 | Out-Null + Write-Success "WhatsApp bridge dependencies installed" + } catch { + Write-Warn "WhatsApp bridge npm install failed (WhatsApp may not work)" + } + Pop-Location + } + Pop-Location } @@ -673,6 +688,29 @@ function Start-GatewayIfConfigured { if (-not $hasMessaging) { return } + $hermesCmd = "$InstallDir\venv\Scripts\hermes.exe" + if (-not (Test-Path $hermesCmd)) { + $hermesCmd = "hermes" + } + + # If WhatsApp is enabled but not yet paired, run foreground for QR scan + $whatsappEnabled = $content | Where-Object { $_ -match "^WHATSAPP_ENABLED=true" } + $whatsappSession = "$HermesHome\whatsapp\session\creds.json" + if ($whatsappEnabled -and -not (Test-Path $whatsappSession)) { + Write-Host "" + Write-Info "WhatsApp is enabled but not yet paired." + Write-Info "Running 'hermes whatsapp' to pair via QR code..." + Write-Host "" + $response = Read-Host "Pair WhatsApp now? [Y/n]" + if ($response -eq "" -or $response -match "^[Yy]") { + try { + & $hermesCmd whatsapp + } catch { + # Expected after pairing completes + } + } + } + Write-Host "" Write-Info "Messaging platform token detected!" Write-Info "The gateway handles messaging platforms and cron job execution." @@ -680,11 +718,6 @@ function Start-GatewayIfConfigured { $response = Read-Host "Would you like to start the gateway now? [Y/n]" if ($response -eq "" -or $response -match "^[Yy]") { - $hermesCmd = "$InstallDir\venv\Scripts\hermes.exe" - if (-not (Test-Path $hermesCmd)) { - $hermesCmd = "hermes" - } - Write-Info "Starting gateway in background..." try { $logFile = "$HermesHome\logs\gateway.log" diff --git a/scripts/install.sh b/scripts/install.sh index eec699294b..4d3a2b7d33 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -140,7 +140,7 @@ detect_os() { log_warn "Unknown operating system" ;; esac - + log_success "Detected: $OS ($DISTRO)" } @@ -150,7 +150,7 @@ detect_os() { install_uv() { log_info "Checking for uv package manager..." - + # Check common locations for uv if command -v uv &> /dev/null; then UV_CMD="uv" @@ -158,7 +158,7 @@ install_uv() { log_success "uv found ($UV_VERSION)" return 0 fi - + # Check ~/.local/bin (default uv install location) even if not on PATH yet if [ -x "$HOME/.local/bin/uv" ]; then UV_CMD="$HOME/.local/bin/uv" @@ -166,7 +166,7 @@ install_uv() { log_success "uv found at ~/.local/bin ($UV_VERSION)" return 0 fi - + # Check ~/.cargo/bin (alternative uv install location) if [ -x "$HOME/.cargo/bin/uv" ]; then UV_CMD="$HOME/.cargo/bin/uv" @@ -174,7 +174,7 @@ install_uv() { log_success "uv found at ~/.cargo/bin ($UV_VERSION)" return 0 fi - + # Install uv log_info "Installing uv (fast Python package manager)..." if curl -LsSf https://astral.sh/uv/install.sh | sh 2>/dev/null; then @@ -201,7 +201,7 @@ install_uv() { check_python() { log_info "Checking Python $PYTHON_VERSION..." - + # Let uv handle Python — it can download and manage Python versions # First check if a suitable Python is already available if $UV_CMD python find "$PYTHON_VERSION" &> /dev/null; then @@ -210,7 +210,7 @@ check_python() { log_success "Python found: $PYTHON_FOUND_VERSION" return 0 fi - + # Python not found — use uv to install it (no sudo needed!) log_info "Python $PYTHON_VERSION not found, installing via uv..." if $UV_CMD python install "$PYTHON_VERSION"; then @@ -226,16 +226,16 @@ check_python() { 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 @@ -258,7 +258,7 @@ check_git() { log_info " Or: brew install git" ;; esac - + exit 1 } @@ -363,6 +363,7 @@ install_node() { # Place into ~/.hermes/node/ and symlink binaries to ~/.local/bin/ rm -rf "$HERMES_HOME/node" + mkdir -p "$HERMES_HOME" mv "$extracted_dir" "$HERMES_HOME/node" rm -rf "$tmp_dir" @@ -523,7 +524,7 @@ show_manual_install_hint() { 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..." @@ -556,14 +557,14 @@ clone_repo() { fi fi fi - + cd "$INSTALL_DIR" - + # Ensure submodules are initialized and updated (for existing installs or if --recurse failed) log_info "Initializing submodules (mini-swe-agent, tinker-atropos)..." git submodule update --init --recursive log_success "Submodules ready" - + log_success "Repository ready" } @@ -572,33 +573,33 @@ setup_venv() { log_info "Skipping virtual environment (--no-venv)" return 0 fi - + log_info "Creating virtual environment with Python $PYTHON_VERSION..." - + if [ -d "venv" ]; then log_info "Virtual environment already exists, recreating..." rm -rf venv fi - + # uv creates the venv and pins the Python version in one step $UV_CMD venv venv --python "$PYTHON_VERSION" - + log_success "Virtual environment ready (Python $PYTHON_VERSION)" } install_deps() { log_info "Installing dependencies..." - + if [ "$USE_VENV" = true ]; then # Tell uv to install into our venv (no need to activate) export VIRTUAL_ENV="$INSTALL_DIR/venv" fi - + # Install the main package in editable mode with all extras $UV_CMD pip install -e ".[all]" || $UV_CMD pip install -e "." - + log_success "Main package installed" - + # Install submodules log_info "Installing mini-swe-agent (terminal tool backend)..." if [ -d "mini-swe-agent" ] && [ -f "mini-swe-agent/pyproject.toml" ]; then @@ -607,7 +608,7 @@ install_deps() { else log_warn "mini-swe-agent not found (run: git submodule update --init)" fi - + log_info "Installing tinker-atropos (RL training backend)..." if [ -d "tinker-atropos" ] && [ -f "tinker-atropos/pyproject.toml" ]; then $UV_CMD pip install -e "./tinker-atropos" || log_warn "tinker-atropos install failed (RL tools may not work)" @@ -615,13 +616,13 @@ install_deps() { else log_warn "tinker-atropos not found (run: git submodule update --init)" fi - + log_success "All dependencies installed" } setup_path() { log_info "Setting up hermes command..." - + if [ "$USE_VENV" = true ]; then HERMES_BIN="$INSTALL_DIR/venv/bin/hermes" else @@ -631,12 +632,12 @@ setup_path() { return 0 fi fi - + # Create symlink in ~/.local/bin (standard user binary location, usually on PATH) mkdir -p "$HOME/.local/bin" ln -sf "$HERMES_BIN" "$HOME/.local/bin/hermes" log_success "Symlinked hermes → ~/.local/bin/hermes" - + # Check if ~/.local/bin is on PATH; if not, add it to shell config if ! echo "$PATH" | tr ':' '\n' | grep -q "^$HOME/.local/bin$"; then SHELL_CONFIG="" @@ -649,9 +650,9 @@ setup_path() { elif [ -n "$ZSH_VERSION" ] || [ -f "$HOME/.zshrc" ]; then SHELL_CONFIG="$HOME/.zshrc" fi - + PATH_LINE='export PATH="$HOME/.local/bin:$PATH"' - + if [ -n "$SHELL_CONFIG" ]; then if ! grep -q '\.local/bin' "$SHELL_CONFIG" 2>/dev/null; then echo "" >> "$SHELL_CONFIG" @@ -665,19 +666,19 @@ setup_path() { else log_info "~/.local/bin already on PATH" fi - + # Export for current session so hermes works immediately export PATH="$HOME/.local/bin:$PATH" - + log_success "hermes command ready" } copy_config_templates() { log_info "Setting up configuration files..." - + # Create ~/.hermes directory structure (config at top level, code in subdir) - mkdir -p "$HERMES_HOME"/{cron,sessions,logs,pairing,hooks,image_cache,audio_cache,memories,skills} - + mkdir -p "$HERMES_HOME"/{cron,sessions,logs,pairing,hooks,image_cache,audio_cache,memories,skills,whatsapp/session} + # Create .env at ~/.hermes/.env (top level, easy to find) if [ ! -f "$HERMES_HOME/.env" ]; then if [ -f "$INSTALL_DIR/.env.example" ]; then @@ -690,7 +691,7 @@ copy_config_templates() { else log_info "~/.hermes/.env already exists, keeping it" fi - + # Create config.yaml at ~/.hermes/config.yaml (top level, easy to find) if [ ! -f "$HERMES_HOME/config.yaml" ]; then if [ -f "$INSTALL_DIR/cli-config.yaml.example" ]; then @@ -700,13 +701,13 @@ copy_config_templates() { else log_info "~/.hermes/config.yaml already exists, keeping it" fi - + # Create SOUL.md if it doesn't exist (global persona file) if [ ! -f "$HERMES_HOME/SOUL.md" ]; then cat > "$HERMES_HOME/SOUL.md" << 'SOUL_EOF' # Hermes Agent Persona -