Merge branch 'main' into fix/docker-backend-macos

This commit is contained in:
Teknium 2026-02-25 23:14:57 -08:00 committed by GitHub
commit faa185e37c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 3087 additions and 131 deletions

View file

@ -164,6 +164,10 @@ VOICE_TOOLS_OPENAI_KEY=
# Slack allowed users (comma-separated Slack user IDs) # Slack allowed users (comma-separated Slack user IDs)
# SLACK_ALLOWED_USERS= # 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) # Gateway-wide: allow ALL users without an allowlist (default: false = deny)
# Only set to true if you intentionally want open access. # Only set to true if you intentionally want open access.
# GATEWAY_ALLOW_ALL_USERS=false # GATEWAY_ALLOW_ALL_USERS=false

View file

@ -235,23 +235,31 @@ SLACK_ALLOWED_USERS=U01234ABCDE # Comma-separated Slack user IDs
### WhatsApp Setup ### 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/)): 1. **Run the setup command:**
- 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:
```bash ```bash
# Add to ~/.hermes/.env: hermes whatsapp
WHATSAPP_ENABLED=true
WHATSAPP_ALLOWED_USERS=YOUR_PHONE_NUMBER # e.g. 15551234567
``` ```
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. 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 # Chat
hermes # Interactive chat (default) hermes # Interactive chat (default)
hermes chat -q "Hello" # Single query mode hermes chat -q "Hello" # Single query mode
hermes --continue # Resume the most recent session (-c)
hermes --resume <id> # Resume a specific session (-r)
# Provider & model management # Provider & model management
hermes model # Switch provider and model interactively 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 - **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 - **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.) - **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 <id>` (specific session)
- Batch runner and RL trajectories are NOT stored here (separate systems) - 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 ### 📝 Session Logging
Every conversation is logged to `~/.hermes/sessions/` for debugging: Every conversation is logged to `~/.hermes/sessions/` for debugging:

78
cli.py
View file

@ -744,6 +744,7 @@ class HermesCLI:
max_turns: int = 60, max_turns: int = 60,
verbose: bool = False, verbose: bool = False,
compact: bool = False, compact: bool = False,
resume: str = None,
): ):
""" """
Initialize the Hermes CLI. Initialize the Hermes CLI.
@ -757,6 +758,7 @@ class HermesCLI:
max_turns: Maximum tool-calling iterations (default: 60) max_turns: Maximum tool-calling iterations (default: 60)
verbose: Enable verbose logging verbose: Enable verbose logging
compact: Use compact display mode compact: Use compact display mode
resume: Session ID to resume (restores conversation history from SQLite)
""" """
# Initialize Rich console # Initialize Rich console
self.console = Console() self.console = Console()
@ -832,12 +834,16 @@ class HermesCLI:
# Conversation state # Conversation state
self.conversation_history: List[Dict[str, Any]] = [] self.conversation_history: List[Dict[str, Any]] = []
self.session_start = datetime.now() self.session_start = datetime.now()
self._resumed = False
# Generate session ID with timestamp for display and logging # Session ID: reuse existing one when resuming, otherwise generate fresh
# Format: YYYYMMDD_HHMMSS_shortUUID (e.g., 20260201_143052_a1b2c3) if resume:
timestamp_str = self.session_start.strftime("%Y%m%d_%H%M%S") self.session_id = resume
short_uuid = uuid.uuid4().hex[:6] self._resumed = True
self.session_id = f"{timestamp_str}_{short_uuid}" 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 # History file for persistent input recall across sessions
self._history_file = Path.home() / ".hermes_history" self._history_file = Path.home() / ".hermes_history"
@ -890,6 +896,7 @@ class HermesCLI:
def _init_agent(self) -> bool: def _init_agent(self) -> bool:
""" """
Initialize the agent on first use. Initialize the agent on first use.
When resuming a session, restores conversation history from SQLite.
Returns: Returns:
bool: True if successful, False otherwise bool: True if successful, False otherwise
@ -908,6 +915,34 @@ class HermesCLI:
except Exception as e: except Exception as e:
logger.debug("SQLite session store not available: %s", 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: try:
self.agent = AIAgent( self.agent = AIAgent(
model=self.model, model=self.model,
@ -1903,6 +1938,32 @@ class HermesCLI:
print(f"Error: {e}") print(f"Error: {e}")
return None 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): def run(self):
"""Run the interactive CLI loop with persistent input at bottom.""" """Run the interactive CLI loop with persistent input at bottom."""
self.show_banner() self.show_banner()
@ -2563,7 +2624,7 @@ class HermesCLI:
except Exception as e: except Exception as e:
logger.debug("Could not close session in DB: %s", e) logger.debug("Could not close session in DB: %s", e)
_run_cleanup() _run_cleanup()
print("\nGoodbye! ⚕") self._print_exit_summary()
# ============================================================================ # ============================================================================
@ -2584,6 +2645,7 @@ def main(
list_tools: bool = False, list_tools: bool = False,
list_toolsets: bool = False, list_toolsets: bool = False,
gateway: bool = False, gateway: bool = False,
resume: str = None,
): ):
""" """
Hermes Agent CLI - Interactive AI Assistant Hermes Agent CLI - Interactive AI Assistant
@ -2601,12 +2663,14 @@ def main(
compact: Use compact display mode compact: Use compact display mode
list_tools: List available tools and exit list_tools: List available tools and exit
list_toolsets: List available toolsets and exit list_toolsets: List available toolsets and exit
resume: Resume a previous session by its ID (e.g., 20260225_143052_a1b2c3)
Examples: Examples:
python cli.py # Start interactive mode python cli.py # Start interactive mode
python cli.py --toolsets web,terminal # Use specific toolsets python cli.py --toolsets web,terminal # Use specific toolsets
python cli.py -q "What is Python?" # Single query mode python cli.py -q "What is Python?" # Single query mode
python cli.py --list-tools # List tools and exit 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 # Signal to terminal_tool that we're in interactive mode
# This enables interactive sudo password prompts with timeout # This enables interactive sudo password prompts with timeout
@ -2655,6 +2719,7 @@ def main(
max_turns=max_turns, max_turns=max_turns,
verbose=verbose, verbose=verbose,
compact=compact, compact=compact,
resume=resume,
) )
# Handle list commands (don't init agent for these) # Handle list commands (don't init agent for these)
@ -2676,6 +2741,7 @@ def main(
cli.show_banner() cli.show_banner()
cli.console.print(f"[bold blue]Query:[/] {query}") cli.console.print(f"[bold blue]Query:[/] {query}")
cli.chat(query) cli.chat(query)
cli._print_exit_summary()
return return
# Run interactive mode # Run interactive mode

View file

@ -170,8 +170,9 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
load_dotenv(os.path.expanduser("~/.hermes/.env"), override=True, encoding="latin-1") load_dotenv(os.path.expanduser("~/.hermes/.env"), override=True, encoding="latin-1")
model = os.getenv("HERMES_MODEL", "anthropic/claude-opus-4.6") model = os.getenv("HERMES_MODEL", "anthropic/claude-opus-4.6")
api_key = os.getenv("OPENROUTER_API_KEY", "") # Custom endpoint (OPENAI_*) takes precedence, matching CLI behavior
base_url = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1") api_key = os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY", "")
base_url = os.getenv("OPENAI_BASE_URL") or os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
try: try:
import yaml import yaml

View file

@ -6,20 +6,24 @@ The Hermes Agent CLI provides an interactive terminal interface for working with
```bash ```bash
# Basic usage # Basic usage
./hermes hermes
# With specific model # With specific model
./hermes --model "anthropic/claude-sonnet-4" hermes --model "anthropic/claude-sonnet-4"
# With specific provider # With specific provider
./hermes --provider nous # Use Nous Portal (requires: hermes login) hermes --provider nous # Use Nous Portal (requires: hermes login)
./hermes --provider openrouter # Force OpenRouter hermes --provider openrouter # Force OpenRouter
# With specific toolsets # 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 <session_id> # Resume a specific session by ID (-r)
# Verbose mode # Verbose mode
./hermes --verbose hermes --verbose
``` ```
## Architecture ## Architecture
@ -238,6 +242,34 @@ This allows you to have different terminal configs for CLI vs batch processing.
- **Conversations**: Use `/save` to export conversations - **Conversations**: Use `/save` to export conversations
- **Reset**: Use `/clear` for full reset, `/reset` to just clear history - **Reset**: Use `/clear` for full reset, `/reset` to just clear history
- **Session Logs**: Every session automatically logs to `logs/session_{session_id}.json` - **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 ### Session Logging

View file

@ -6,10 +6,13 @@ and implement the required methods.
""" """
import asyncio import asyncio
import logging
import os import os
import re import re
import uuid import uuid
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
logger = logging.getLogger(__name__)
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@ -517,6 +520,8 @@ class BasePlatformAdapter(ABC):
response = await self._message_handler(event) response = await self._message_handler(event)
# Send response if any # 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: if response:
# Extract MEDIA:<path> tags (from TTS tool) before other processing # Extract MEDIA:<path> tags (from TTS tool) before other processing
media_files, response = self.extract_media(response) media_files, response = self.extract_media(response)
@ -526,6 +531,7 @@ class BasePlatformAdapter(ABC):
# Send the text portion first (if any remains after extractions) # Send the text portion first (if any remains after extractions)
if text_content: 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( result = await self.send(
chat_id=event.source.chat_id, chat_id=event.source.chat_id,
content=text_content, content=text_content,

View file

@ -18,6 +18,7 @@ with different backends via a bridge pattern.
import asyncio import asyncio
import json import json
import logging import logging
import os
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Any from typing import Dict, List, Optional, Any
@ -80,11 +81,17 @@ class WhatsAppAdapter(BasePlatformAdapter):
# WhatsApp message limits # WhatsApp message limits
MAX_MESSAGE_LENGTH = 65536 # WhatsApp allows longer messages 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): def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.WHATSAPP) super().__init__(config, Platform.WHATSAPP)
self._bridge_process: Optional[subprocess.Popen] = None self._bridge_process: Optional[subprocess.Popen] = None
self._bridge_port: int = config.extra.get("bridge_port", 3000) 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( self._session_path: Path = Path(config.extra.get(
"session_path", "session_path",
Path.home() / ".hermes" / "whatsapp" / "session" 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. This launches the Node.js bridge process and waits for it to be ready.
""" """
if not check_whatsapp_requirements(): if not check_whatsapp_requirements():
print(f"[{self.name}] Node.js not found. WhatsApp requires Node.js.") logger.warning("[%s] Node.js not found. WhatsApp requires Node.js.", self.name)
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 return False
bridge_path = Path(self._bridge_script) bridge_path = Path(self._bridge_script)
if not bridge_path.exists(): 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 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: try:
# Ensure session directory exists # Ensure session directory exists
self._session_path.mkdir(parents=True, exist_ok=True) 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( self._bridge_process = subprocess.Popen(
[ [
"node", "node",
@ -124,19 +164,32 @@ class WhatsAppAdapter(BasePlatformAdapter):
"--port", str(self._bridge_port), "--port", str(self._bridge_port),
"--session", str(self._session_path), "--session", str(self._session_path),
], ],
stdout=subprocess.PIPE, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, stderr=subprocess.DEVNULL,
text=True, preexec_fn=os.setsid,
) )
# Wait for bridge to be ready (look for ready signal) # Wait for bridge to be ready via HTTP health check
# This is a simplified version - real implementation would import aiohttp
# wait for an HTTP health check or specific stdout message for attempt in range(15):
await asyncio.sleep(5) await asyncio.sleep(1)
if self._bridge_process.poll() is not None:
if self._bridge_process.poll() is not None: print(f"[{self.name}] Bridge process died (exit code {self._bridge_process.returncode})")
stderr = self._bridge_process.stderr.read() if self._bridge_process.stderr else "" return False
print(f"[{self.name}] Bridge process died: {stderr}") 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 return False
# Start message polling task # Start message polling task
@ -148,20 +201,37 @@ class WhatsAppAdapter(BasePlatformAdapter):
return True return True
except Exception as e: 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 return False
async def disconnect(self) -> None: async def disconnect(self) -> None:
"""Stop the WhatsApp bridge.""" """Stop the WhatsApp bridge and clean up any orphaned processes."""
if self._bridge_process: if self._bridge_process:
try: 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) await asyncio.sleep(1)
if self._bridge_process.poll() is None: 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: except Exception as e:
print(f"[{self.name}] Error stopping bridge: {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._running = False
self._bridge_process = None self._bridge_process = None
print(f"[{self.name}] Disconnected") print(f"[{self.name}] Disconnected")
@ -355,9 +425,3 @@ class WhatsAppAdapter(BasePlatformAdapter):
print(f"[{self.name}] Error building event: {e}") print(f"[{self.name}] Error building event: {e}")
return None 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

View file

@ -428,7 +428,11 @@ class GatewayRunner:
if global_allowlist: if global_allowlist:
allowed_ids.update(uid.strip() for uid in global_allowlist.split(",") if uid.strip()) 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]: async def _handle_message(self, event: MessageEvent) -> Optional[str]:
""" """
@ -1388,8 +1392,9 @@ class GatewayRunner:
except Exception: except Exception:
pass pass
api_key = os.getenv("OPENROUTER_API_KEY", "") # Custom endpoint (OPENAI_*) takes precedence, matching CLI behavior
base_url = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1") api_key = os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY", "")
base_url = os.getenv("OPENAI_BASE_URL") or os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
model = os.getenv("HERMES_MODEL", "anthropic/claude-opus-4.6") model = os.getenv("HERMES_MODEL", "anthropic/claude-opus-4.6")
try: try:

View file

@ -23,9 +23,13 @@ if _env_path.exists():
load_dotenv(_env_path, encoding="utf-8") load_dotenv(_env_path, encoding="utf-8")
except UnicodeDecodeError: except UnicodeDecodeError:
load_dotenv(_env_path, encoding="latin-1") 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") 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_cli.colors import Colors, color
from hermes_constants import OPENROUTER_MODELS_URL from hermes_constants import OPENROUTER_MODELS_URL
@ -225,17 +229,6 @@ def run_doctor(args):
check_ok("Created ~/.hermes/SOUL.md with basic template") check_ok("Created ~/.hermes/SOUL.md with basic template")
fixed_count += 1 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 # Check memory directory
memories_dir = hermes_home / "memories" memories_dir = hermes_home / "memories"
if memories_dir.exists(): if memories_dir.exists():
@ -447,14 +440,15 @@ def run_doctor(args):
check_ok(info.get("name", tid)) check_ok(info.get("name", tid))
for item in unavailable: for item in unavailable:
if item["missing_vars"]: env_vars = item.get("missing_vars") or item.get("env_vars") or []
vars_str = ", ".join(item["missing_vars"]) if env_vars:
vars_str = ", ".join(env_vars)
check_warn(item["name"], f"(missing {vars_str})") check_warn(item["name"], f"(missing {vars_str})")
else: else:
check_warn(item["name"], "(system dependency not met)") check_warn(item["name"], "(system dependency not met)")
# Count disabled tools with API key requirements # 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: if api_disabled:
issues.append("Run 'hermes setup' to configure missing API keys for full tool access") issues.append("Run 'hermes setup' to configure missing API keys for full tool access")
except Exception as e: except Exception as e:
@ -466,7 +460,7 @@ def run_doctor(args):
print() print()
print(color("◆ Skills Hub", Colors.CYAN, Colors.BOLD)) print(color("◆ Skills Hub", Colors.CYAN, Colors.BOLD))
hub_dir = PROJECT_ROOT / "skills" / ".hub" hub_dir = HERMES_HOME / "skills" / ".hub"
if hub_dir.exists(): if hub_dir.exists():
check_ok("Skills Hub directory exists") check_ok("Skills Hub directory exists")
lock_file = hub_dir / "lock.json" lock_file = hub_dir / "lock.json"
@ -485,7 +479,8 @@ def run_doctor(args):
else: else:
check_warn("Skills Hub directory not initialized", "(run: hermes skills list)") 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: if github_token:
check_ok("GitHub token configured (authenticated API access)") check_ok("GitHub token configured (authenticated API access)")
else: else:

View file

@ -28,19 +28,26 @@ import argparse
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Optional
# Add project root to path # Add project root to path
PROJECT_ROOT = Path(__file__).parent.parent.resolve() PROJECT_ROOT = Path(__file__).parent.parent.resolve()
sys.path.insert(0, str(PROJECT_ROOT)) 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 from dotenv import load_dotenv
env_path = PROJECT_ROOT / '.env' from hermes_cli.config import get_env_path, get_hermes_home
if env_path.exists(): _user_env = get_env_path()
if _user_env.exists():
try: try:
load_dotenv(dotenv_path=env_path, encoding="utf-8") load_dotenv(dotenv_path=_user_env, encoding="utf-8")
except UnicodeDecodeError: 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 import logging
@ -90,8 +97,31 @@ def _has_any_provider_configured() -> bool:
return False 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): def cmd_chat(args):
"""Run interactive chat CLI.""" """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 # First-run guard: check if any provider is configured before launching
if not _has_any_provider_configured(): if not _has_any_provider_configured():
print() print()
@ -120,6 +150,7 @@ def cmd_chat(args):
"toolsets": args.toolsets, "toolsets": args.toolsets,
"verbose": args.verbose, "verbose": args.verbose,
"query": args.query, "query": args.query,
"resume": getattr(args, "resume", None),
} }
# Filter out None values # Filter out None values
kwargs = {k: v for k, v in kwargs.items() if v is not None} kwargs = {k: v for k, v in kwargs.items() if v is not None}
@ -133,6 +164,116 @@ def cmd_gateway(args):
gateway_command(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): def cmd_setup(args):
"""Interactive setup wizard.""" """Interactive setup wizard."""
from hermes_cli.setup import run_setup_wizard from hermes_cli.setup import run_setup_wizard
@ -632,6 +773,8 @@ def main():
Examples: Examples:
hermes Start interactive chat hermes Start interactive chat
hermes chat -q "Hello" Single query mode hermes chat -q "Hello" Single query mode
hermes --continue Resume the most recent session
hermes --resume <session_id> Resume a specific session
hermes setup Run setup wizard hermes setup Run setup wizard
hermes login Authenticate with an inference provider hermes login Authenticate with an inference provider
hermes logout Clear stored authentication hermes logout Clear stored authentication
@ -641,6 +784,7 @@ Examples:
hermes config set model gpt-4 Set a config value hermes config set model gpt-4 Set a config value
hermes gateway Run messaging gateway hermes gateway Run messaging gateway
hermes gateway install Install as system service hermes gateway install Install as system service
hermes sessions list List past sessions
hermes update Update to latest version hermes update Update to latest version
For more help on a command: For more help on a command:
@ -653,6 +797,19 @@ For more help on a command:
action="store_true", action="store_true",
help="Show version and exit" 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") subparsers = parser.add_subparsers(dest="command", help="Command to run")
@ -687,6 +844,18 @@ For more help on a command:
action="store_true", action="store_true",
help="Verbose output" 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) chat_parser.set_defaults(func=cmd_chat)
# ========================================================================= # =========================================================================
@ -755,6 +924,16 @@ For more help on a command:
) )
setup_parser.set_defaults(func=cmd_setup) 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 # login command
# ========================================================================= # =========================================================================
@ -1183,6 +1362,17 @@ For more help on a command:
cmd_version(args) cmd_version(args)
return 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 # Default to chat if no command specified
if args.command is None: if args.command is None:
args.query = None args.query = None
@ -1190,6 +1380,8 @@ For more help on a command:
args.provider = None args.provider = None
args.toolsets = None args.toolsets = None
args.verbose = False args.verbose = False
args.resume = None
args.continue_last = False
cmd_chat(args) cmd_chat(args)
return return

View file

@ -163,8 +163,15 @@ def prompt_checklist(title: str, items: list, pre_selected: list = None) -> list
try: try:
from simple_term_menu import TerminalMenu 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 # Map pre-selected indices to the actual menu entry strings
preselected = [menu_items[i] for i in pre_selected if i < len(menu_items)] preselected = [menu_items[i] for i in pre_selected if i < len(menu_items)]
@ -1227,13 +1234,22 @@ def run_setup_wizard(args):
# WhatsApp # WhatsApp
existing_whatsapp = get_env_value('WHATSAPP_ENABLED') existing_whatsapp = get_env_value('WHATSAPP_ENABLED')
if not existing_whatsapp and prompt_yes_no("Set up WhatsApp?", False): if not existing_whatsapp and prompt_yes_no("Set up WhatsApp?", False):
print_info("WhatsApp uses a bridge service for connectivity.") print_info("WhatsApp connects via a built-in bridge (Baileys).")
print_info("See docs/messaging.md for detailed WhatsApp setup instructions.") 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() print()
if prompt_yes_no("Enable WhatsApp bridge?", True): if prompt_yes_no("Enable WhatsApp?", True):
save_env_value("WHATSAPP_ENABLED", "true") save_env_value("WHATSAPP_ENABLED", "true")
print_success("WhatsApp enabled") 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 # Gateway reminder
any_messaging = ( any_messaging = (

View file

@ -12,6 +12,7 @@ from pathlib import Path
PROJECT_ROOT = Path(__file__).parent.parent.resolve() PROJECT_ROOT = Path(__file__).parent.parent.resolve()
from hermes_cli.colors import Colors, color 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 from hermes_constants import OPENROUTER_MODELS_URL
def check_mark(ok: bool) -> str: def check_mark(ok: bool) -> str:
@ -65,7 +66,7 @@ def show_status(args):
print(f" Project: {PROJECT_ROOT}") print(f" Project: {PROJECT_ROOT}")
print(f" Python: {sys.version.split()[0]}") 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'}") 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(): for name, env_var in keys.items():
value = os.getenv(env_var, "") value = get_env_value(env_var) or ""
has_key = bool(value) has_key = bool(value)
display = redact_key(value) if not show_all else value display = redact_key(value) if not show_all else value
print(f" {name:<12} {check_mark(has_key)} {display}") print(f" {name:<12} {check_mark(has_key)} {display}")

View file

@ -37,19 +37,30 @@ import fire
from datetime import datetime from datetime import datetime
from pathlib import Path 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 from dotenv import load_dotenv
# Load .env file if it exists _hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
env_path = Path(__file__).parent / '.env' _user_env = _hermes_home / ".env"
if env_path.exists(): _project_env = Path(__file__).parent / '.env'
if _user_env.exists():
try: try:
load_dotenv(dotenv_path=env_path, encoding="utf-8") load_dotenv(dotenv_path=_user_env, encoding="utf-8")
except UnicodeDecodeError: except UnicodeDecodeError:
load_dotenv(dotenv_path=env_path, encoding="latin-1") load_dotenv(dotenv_path=_user_env, encoding="latin-1")
logger.info("Loaded environment variables from %s", env_path) 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: 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 # Import our tool system
from model_tools import get_tool_definitions, handle_function_call, check_toolset_requirements from model_tools import get_tool_definitions, handle_function_call, check_toolset_requirements

View file

@ -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\audio_cache" | Out-Null
New-Item -ItemType Directory -Force -Path "$HermesHome\memories" | 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\skills" | Out-Null
New-Item -ItemType Directory -Force -Path "$HermesHome\whatsapp\session" | Out-Null
# Create .env # Create .env
$envPath = "$HermesHome\.env" $envPath = "$HermesHome\.env"
@ -626,7 +627,7 @@ function Install-NodeDeps {
Push-Location $InstallDir Push-Location $InstallDir
if (Test-Path "package.json") { if (Test-Path "package.json") {
Write-Info "Installing Node.js dependencies..." Write-Info "Installing Node.js dependencies (browser tools)..."
try { try {
npm install --silent 2>&1 | Out-Null npm install --silent 2>&1 | Out-Null
Write-Success "Node.js dependencies installed" 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 Pop-Location
} }
@ -673,6 +688,29 @@ function Start-GatewayIfConfigured {
if (-not $hasMessaging) { return } 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-Host ""
Write-Info "Messaging platform token detected!" Write-Info "Messaging platform token detected!"
Write-Info "The gateway handles messaging platforms and cron job execution." 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]" $response = Read-Host "Would you like to start the gateway now? [Y/n]"
if ($response -eq "" -or $response -match "^[Yy]") { 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..." Write-Info "Starting gateway in background..."
try { try {
$logFile = "$HermesHome\logs\gateway.log" $logFile = "$HermesHome\logs\gateway.log"

View file

@ -676,7 +676,7 @@ copy_config_templates() {
log_info "Setting up configuration files..." log_info "Setting up configuration files..."
# Create ~/.hermes directory structure (config at top level, code in subdir) # 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) # Create .env at ~/.hermes/.env (top level, easy to find)
if [ ! -f "$HERMES_HOME/.env" ]; then if [ ! -f "$HERMES_HOME/.env" ]; then
@ -745,14 +745,23 @@ install_node_deps() {
fi fi
if [ -f "$INSTALL_DIR/package.json" ]; then if [ -f "$INSTALL_DIR/package.json" ]; then
log_info "Installing Node.js dependencies..." log_info "Installing Node.js dependencies (browser tools)..."
cd "$INSTALL_DIR" cd "$INSTALL_DIR"
npm install --silent 2>/dev/null || { npm install --silent 2>/dev/null || {
log_warn "npm install failed (browser tools may not work)" log_warn "npm install failed (browser tools may not work)"
return 0
} }
log_success "Node.js dependencies installed" log_success "Node.js dependencies installed"
fi fi
# Install WhatsApp bridge dependencies
if [ -f "$INSTALL_DIR/scripts/whatsapp-bridge/package.json" ]; then
log_info "Installing WhatsApp bridge dependencies..."
cd "$INSTALL_DIR/scripts/whatsapp-bridge"
npm install --silent 2>/dev/null || {
log_warn "WhatsApp bridge npm install failed (WhatsApp may not work)"
}
log_success "WhatsApp bridge dependencies installed"
fi
} }
run_setup_wizard() { run_setup_wizard() {
@ -798,6 +807,24 @@ maybe_start_gateway() {
echo "" echo ""
log_info "Messaging platform token detected!" log_info "Messaging platform token detected!"
log_info "The gateway needs to be running for Hermes to send/receive messages." log_info "The gateway needs to be running for Hermes to send/receive messages."
# If WhatsApp is enabled and no session exists yet, run foreground first for QR scan
WHATSAPP_VAL=$(grep "^WHATSAPP_ENABLED=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2-)
WHATSAPP_SESSION="$HERMES_HOME/whatsapp/session/creds.json"
if [ "$WHATSAPP_VAL" = "true" ] && [ ! -f "$WHATSAPP_SESSION" ]; then
echo ""
log_info "WhatsApp is enabled but not yet paired."
log_info "Running 'hermes whatsapp' to pair via QR code..."
echo ""
read -p "Pair WhatsApp now? [Y/n] " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]] || [[ -z $REPLY ]]; then
HERMES_CMD="$HOME/.local/bin/hermes"
[ ! -x "$HERMES_CMD" ] && HERMES_CMD="hermes"
$HERMES_CMD whatsapp || true
fi
fi
echo "" echo ""
read -p "Would you like to install the gateway as a background service? [Y/n] " -n 1 -r read -p "Would you like to install the gateway as a background service? [Y/n] " -n 1 -r
echo echo

View file

@ -0,0 +1,278 @@
#!/usr/bin/env node
/**
* Hermes Agent WhatsApp Bridge
*
* Standalone Node.js process that connects to WhatsApp via Baileys
* and exposes HTTP endpoints for the Python gateway adapter.
*
* Endpoints (matches gateway/platforms/whatsapp.py expectations):
* GET /messages - Long-poll for new incoming messages
* POST /send - Send a message { chatId, message, replyTo? }
* POST /typing - Send typing indicator { chatId }
* GET /chat/:id - Get chat info
* GET /health - Health check
*
* Usage:
* node bridge.js --port 3000 --session ~/.hermes/whatsapp/session
*/
import { makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion } from '@whiskeysockets/baileys';
import express from 'express';
import { Boom } from '@hapi/boom';
import pino from 'pino';
import path from 'path';
import { mkdirSync } from 'fs';
import qrcode from 'qrcode-terminal';
// Parse CLI args
const args = process.argv.slice(2);
function getArg(name, defaultVal) {
const idx = args.indexOf(`--${name}`);
return idx !== -1 && args[idx + 1] ? args[idx + 1] : defaultVal;
}
const PORT = parseInt(getArg('port', '3000'), 10);
const SESSION_DIR = getArg('session', path.join(process.env.HOME || '~', '.hermes', 'whatsapp', 'session'));
const PAIR_ONLY = args.includes('--pair-only');
const ALLOWED_USERS = (process.env.WHATSAPP_ALLOWED_USERS || '').split(',').map(s => s.trim()).filter(Boolean);
mkdirSync(SESSION_DIR, { recursive: true });
const logger = pino({ level: 'warn' });
// Message queue for polling
const messageQueue = [];
const MAX_QUEUE_SIZE = 100;
let sock = null;
let connectionState = 'disconnected';
async function startSocket() {
const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR);
const { version } = await fetchLatestBaileysVersion();
sock = makeWASocket({
version,
auth: state,
logger,
printQRInTerminal: false,
browser: ['Hermes Agent', 'Chrome', '120.0'],
syncFullHistory: false,
markOnlineOnConnect: false,
});
sock.ev.on('creds.update', saveCreds);
sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
console.log('\n📱 Scan this QR code with WhatsApp on your phone:\n');
qrcode.generate(qr, { small: true });
console.log('\nWaiting for scan...\n');
}
if (connection === 'close') {
const reason = new Boom(lastDisconnect?.error)?.output?.statusCode;
connectionState = 'disconnected';
if (reason === DisconnectReason.loggedOut) {
console.log('❌ Logged out. Delete session and restart to re-authenticate.');
process.exit(1);
} else {
// 515 = restart requested (common after pairing). Always reconnect.
if (reason === 515) {
console.log('↻ WhatsApp requested restart (code 515). Reconnecting...');
} else {
console.log(`⚠️ Connection closed (reason: ${reason}). Reconnecting in 3s...`);
}
setTimeout(startSocket, reason === 515 ? 1000 : 3000);
}
} else if (connection === 'open') {
connectionState = 'connected';
console.log('✅ WhatsApp connected!');
if (PAIR_ONLY) {
console.log('✅ Pairing complete. Credentials saved.');
// Give Baileys a moment to flush creds, then exit cleanly
setTimeout(() => process.exit(0), 2000);
}
}
});
sock.ev.on('messages.upsert', ({ messages, type }) => {
if (type !== 'notify') return;
for (const msg of messages) {
if (!msg.message) continue;
const chatId = msg.key.remoteJid;
const senderId = msg.key.participant || chatId;
const isGroup = chatId.endsWith('@g.us');
const senderNumber = senderId.replace(/@.*/, '');
// Skip own messages UNLESS it's a self-chat ("Message Yourself")
// Self-chat JID ends with the user's own number
if (msg.key.fromMe && !chatId.includes('status') && isGroup) continue;
// In non-group chats, fromMe means we sent it — skip unless allowed user sent to themselves
if (msg.key.fromMe && !isGroup && ALLOWED_USERS.length > 0 && !ALLOWED_USERS.includes(senderNumber)) continue;
// Check allowlist for messages from others
if (!msg.key.fromMe && ALLOWED_USERS.length > 0 && !ALLOWED_USERS.includes(senderNumber)) {
continue;
}
// Extract message body
let body = '';
let hasMedia = false;
let mediaType = '';
const mediaUrls = [];
if (msg.message.conversation) {
body = msg.message.conversation;
} else if (msg.message.extendedTextMessage?.text) {
body = msg.message.extendedTextMessage.text;
} else if (msg.message.imageMessage) {
body = msg.message.imageMessage.caption || '';
hasMedia = true;
mediaType = 'image';
} else if (msg.message.videoMessage) {
body = msg.message.videoMessage.caption || '';
hasMedia = true;
mediaType = 'video';
} else if (msg.message.audioMessage || msg.message.pttMessage) {
hasMedia = true;
mediaType = msg.message.pttMessage ? 'ptt' : 'audio';
} else if (msg.message.documentMessage) {
body = msg.message.documentMessage.caption || msg.message.documentMessage.fileName || '';
hasMedia = true;
mediaType = 'document';
}
// Skip empty messages
if (!body && !hasMedia) continue;
const event = {
messageId: msg.key.id,
chatId,
senderId,
senderName: msg.pushName || senderNumber,
chatName: isGroup ? (chatId.split('@')[0]) : (msg.pushName || senderNumber),
isGroup,
body,
hasMedia,
mediaType,
mediaUrls,
timestamp: msg.messageTimestamp,
};
messageQueue.push(event);
if (messageQueue.length > MAX_QUEUE_SIZE) {
messageQueue.shift();
}
}
});
}
// HTTP server
const app = express();
app.use(express.json());
// Poll for new messages (long-poll style)
app.get('/messages', (req, res) => {
const msgs = messageQueue.splice(0, messageQueue.length);
res.json(msgs);
});
// Send a message
app.post('/send', async (req, res) => {
if (!sock || connectionState !== 'connected') {
return res.status(503).json({ error: 'Not connected to WhatsApp' });
}
const { chatId, message, replyTo } = req.body;
if (!chatId || !message) {
return res.status(400).json({ error: 'chatId and message are required' });
}
try {
// Prefix responses so the user can distinguish agent replies from their
// own messages (especially in self-chat / "Message Yourself").
const prefixed = `⚕ *Hermes Agent*\n────────────\n${message}`;
const sent = await sock.sendMessage(chatId, { text: prefixed });
res.json({ success: true, messageId: sent?.key?.id });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Typing indicator
app.post('/typing', async (req, res) => {
if (!sock || connectionState !== 'connected') {
return res.status(503).json({ error: 'Not connected' });
}
const { chatId } = req.body;
if (!chatId) return res.status(400).json({ error: 'chatId required' });
try {
await sock.sendPresenceUpdate('composing', chatId);
res.json({ success: true });
} catch (err) {
res.json({ success: false });
}
});
// Chat info
app.get('/chat/:id', async (req, res) => {
const chatId = req.params.id;
const isGroup = chatId.endsWith('@g.us');
if (isGroup && sock) {
try {
const metadata = await sock.groupMetadata(chatId);
return res.json({
name: metadata.subject,
isGroup: true,
participants: metadata.participants.map(p => p.id),
});
} catch {
// Fall through to default
}
}
res.json({
name: chatId.replace(/@.*/, ''),
isGroup,
participants: [],
});
});
// Health check
app.get('/health', (req, res) => {
res.json({
status: connectionState,
queueLength: messageQueue.length,
uptime: process.uptime(),
});
});
// Start
if (PAIR_ONLY) {
// Pair-only mode: just connect, show QR, save creds, exit. No HTTP server.
console.log('📱 WhatsApp pairing mode');
console.log(`📁 Session: ${SESSION_DIR}`);
console.log();
startSocket();
} else {
app.listen(PORT, () => {
console.log(`🌉 WhatsApp bridge listening on port ${PORT}`);
console.log(`📁 Session stored in: ${SESSION_DIR}`);
if (ALLOWED_USERS.length > 0) {
console.log(`🔒 Allowed users: ${ALLOWED_USERS.join(', ')}`);
} else {
console.log(`⚠️ No WHATSAPP_ALLOWED_USERS set — all messages will be processed`);
}
console.log();
startSocket();
});
}

2156
scripts/whatsapp-bridge/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,16 @@
{
"name": "hermes-whatsapp-bridge",
"version": "1.0.0",
"description": "WhatsApp bridge for Hermes Agent using Baileys",
"private": true,
"type": "module",
"scripts": {
"start": "node bridge.js"
},
"dependencies": {
"@whiskeysockets/baileys": "7.0.0-rc.9",
"express": "^4.21.0",
"qrcode-terminal": "^0.12.0",
"pino": "^9.0.0"
}
}

View file

@ -42,6 +42,20 @@ curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scri
This installs uv, Python 3.11, clones the repo, sets up the venv, and launches an interactive setup wizard to configure your API provider and model. See the [GitHub repo](https://github.com/NousResearch/hermes-agent) for details. This installs uv, Python 3.11, clones the repo, sets up the venv, and launches an interactive setup wizard to configure your API provider and model. See the [GitHub repo](https://github.com/NousResearch/hermes-agent) for details.
## Resuming Previous Sessions
Resume a prior CLI session instead of starting fresh. Useful for continuing long tasks across process restarts:
```
# Resume the most recent CLI session
terminal(command="hermes --continue", background=true, pty=true)
# Resume a specific session by ID (shown on exit)
terminal(command="hermes --resume 20260225_143052_a1b2c3", background=true, pty=true)
```
The full conversation history (messages, tool calls, responses) is restored from SQLite. The agent sees everything from the previous session.
## Mode 1: One-Shot Query (-q flag) ## Mode 1: One-Shot Query (-q flag)
Run a single query non-interactively. The agent executes, does its work, and exits: Run a single query non-interactively. The agent executes, does its work, and exits:
@ -145,13 +159,13 @@ For scheduled autonomous tasks, use the `schedule_cronjob` tool instead of spawn
## Key Differences Between Modes ## Key Differences Between Modes
| | `-q` (one-shot) | Interactive (PTY) | | | `-q` (one-shot) | Interactive (PTY) | `--continue` / `--resume` |
|---|---|---| |---|---|---|---|
| User interaction | None | Full back-and-forth | | User interaction | None | Full back-and-forth | Full back-and-forth |
| PTY required | No | Yes (`pty=true`) | | PTY required | No | Yes (`pty=true`) | Yes (`pty=true`) |
| Multi-turn | Single query | Unlimited turns | | Multi-turn | Single query | Unlimited turns | Continues previous turns |
| Best for | Fire-and-forget tasks | Iterative work, reviews, steering | | Best for | Fire-and-forget tasks | Iterative work, steering | Picking up where you left off |
| Exit | Automatic after completion | Send `/exit` or kill | | Exit | Automatic after completion | Send `/exit` or kill | Send `/exit` or kill |
## Known Issues ## Known Issues

View file

@ -5,40 +5,48 @@ description: Read, search, and create notes in the Obsidian vault.
# Obsidian Vault # Obsidian Vault
**Location:** `/home/teknium/Documents/Primary Vault` **Location:** Set via `OBSIDIAN_VAULT_PATH` environment variable (e.g. in `~/.hermes/.env`).
Note: Path contains a space - always quote it. If unset, defaults to `~/Documents/Obsidian Vault`.
Note: Vault paths may contain spaces - always quote them.
## Read a note ## Read a note
```bash ```bash
cat "/home/teknium/Documents/Primary Vault/Note Name.md" VAULT="${OBSIDIAN_VAULT_PATH:-$HOME/Documents/Obsidian Vault}"
cat "$VAULT/Note Name.md"
``` ```
## List notes ## List notes
```bash ```bash
VAULT="${OBSIDIAN_VAULT_PATH:-$HOME/Documents/Obsidian Vault}"
# All notes # All notes
find "/home/teknium/Documents/Primary Vault" -name "*.md" -type f find "$VAULT" -name "*.md" -type f
# In a specific folder # In a specific folder
ls "/home/teknium/Documents/Primary Vault/AI Research/" ls "$VAULT/Subfolder/"
``` ```
## Search ## Search
```bash ```bash
VAULT="${OBSIDIAN_VAULT_PATH:-$HOME/Documents/Obsidian Vault}"
# By filename # By filename
find "/home/teknium/Documents/Primary Vault" -name "*.md" -iname "*keyword*" find "$VAULT" -name "*.md" -iname "*keyword*"
# By content # By content
grep -rli "keyword" "/home/teknium/Documents/Primary Vault" --include="*.md" grep -rli "keyword" "$VAULT" --include="*.md"
``` ```
## Create a note ## Create a note
```bash ```bash
cat > "/home/teknium/Documents/Primary Vault/New Note.md" << 'ENDNOTE' VAULT="${OBSIDIAN_VAULT_PATH:-$HOME/Documents/Obsidian Vault}"
cat > "$VAULT/New Note.md" << 'ENDNOTE'
# Title # Title
Content here. Content here.
@ -48,8 +56,9 @@ ENDNOTE
## Append to a note ## Append to a note
```bash ```bash
VAULT="${OBSIDIAN_VAULT_PATH:-$HOME/Documents/Obsidian Vault}"
echo " echo "
New content here." >> "/home/teknium/Documents/Primary Vault/Existing Note.md" New content here." >> "$VAULT/Existing Note.md"
``` ```
## Wikilinks ## Wikilinks

View file

@ -381,14 +381,20 @@ def execute_code(
rpc_thread.start() rpc_thread.start()
# --- Spawn child process --- # --- Spawn child process ---
# Filter out secret env vars to prevent exfiltration from sandbox # Build a minimal environment for the child. We intentionally exclude
_SECRET_PATTERNS = ("KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", # API keys and tokens to prevent credential exfiltration from LLM-
"API_KEY", "OPENROUTER", "ANTHROPIC", "OPENAI", # generated scripts. The child accesses tools via RPC, not direct API.
"AWS_SECRET", "GITHUB_TOKEN") _SAFE_ENV_PREFIXES = ("PATH", "HOME", "USER", "LANG", "LC_", "TERM",
child_env = { "TMPDIR", "TMP", "TEMP", "SHELL", "LOGNAME",
k: v for k, v in os.environ.items() "XDG_", "PYTHONPATH", "VIRTUAL_ENV", "CONDA")
if not any(pat in k.upper() for pat in _SECRET_PATTERNS) _SECRET_SUBSTRINGS = ("KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL",
} "PASSWD", "AUTH")
child_env = {}
for k, v in os.environ.items():
if any(s in k.upper() for s in _SECRET_SUBSTRINGS):
continue
if any(k.startswith(p) for p in _SAFE_ENV_PREFIXES):
child_env[k] = v
child_env["HERMES_RPC_SOCKET"] = sock_path child_env["HERMES_RPC_SOCKET"] = sock_path
child_env["PYTHONDONTWRITEBYTECODE"] = "1" child_env["PYTHONDONTWRITEBYTECODE"] = "1"