2026-02-02 19:01:51 -08:00
|
|
|
"""
|
|
|
|
|
Gateway configuration management.
|
|
|
|
|
|
|
|
|
|
Handles loading and validating configuration for:
|
|
|
|
|
- Connected platforms (Telegram, Discord, WhatsApp)
|
|
|
|
|
- Home channels for each platform
|
|
|
|
|
- Session reset policies
|
|
|
|
|
- Delivery preferences
|
|
|
|
|
"""
|
|
|
|
|
|
2026-02-22 02:16:11 -08:00
|
|
|
import logging
|
2026-02-02 19:01:51 -08:00
|
|
|
import os
|
|
|
|
|
import json
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
from typing import Dict, List, Optional, Any
|
|
|
|
|
from enum import Enum
|
|
|
|
|
|
fix(cli): respect HERMES_HOME in all remaining hardcoded ~/.hermes paths
Several files resolved paths via Path.home() / ".hermes" or
os.path.expanduser("~/.hermes/..."), bypassing the HERMES_HOME
environment variable. This broke isolation when running multiple
Hermes instances with distinct HERMES_HOME directories.
Replace all hardcoded paths with calls to get_hermes_home() from
hermes_cli.config, consistent with the rest of the codebase.
Files fixed:
- tools/process_registry.py (processes.json)
- gateway/pairing.py (pairing/)
- gateway/sticker_cache.py (sticker_cache.json)
- gateway/channel_directory.py (channel_directory.json, sessions.json)
- gateway/config.py (gateway.json, config.yaml, sessions_dir)
- gateway/mirror.py (sessions/)
- gateway/hooks.py (hooks/)
- gateway/platforms/base.py (image_cache/, audio_cache/, document_cache/)
- gateway/platforms/whatsapp.py (whatsapp/session)
- gateway/delivery.py (cron/output)
- agent/auxiliary_client.py (auth.json)
- agent/prompt_builder.py (SOUL.md)
- cli.py (config.yaml, images/, pastes/, history)
- run_agent.py (logs/)
- tools/environments/base.py (sandboxes/)
- tools/environments/modal.py (modal_snapshots.json)
- tools/environments/singularity.py (singularity_snapshots.json)
- tools/tts_tool.py (audio_cache)
- hermes_cli/status.py (cron/jobs.json, sessions.json)
- hermes_cli/gateway.py (logs/, whatsapp session)
- hermes_cli/main.py (whatsapp/session)
Tests updated to use HERMES_HOME env var instead of patching Path.home().
Closes #892
(cherry picked from commit 78ac1bba43b8b74a934c6172f2c29bb4d03164b9)
2026-03-11 07:31:41 +01:00
|
|
|
from hermes_cli.config import get_hermes_home
|
|
|
|
|
|
2026-02-22 02:16:11 -08:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
|
2026-03-14 22:09:53 -07:00
|
|
|
def _coerce_bool(value: Any, default: bool = True) -> bool:
|
|
|
|
|
"""Coerce bool-ish config values, preserving a caller-provided default."""
|
|
|
|
|
if value is None:
|
|
|
|
|
return default
|
|
|
|
|
if isinstance(value, bool):
|
|
|
|
|
return value
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
return value.strip().lower() in ("true", "1", "yes", "on")
|
|
|
|
|
return bool(value)
|
|
|
|
|
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
class Platform(Enum):
|
|
|
|
|
"""Supported messaging platforms."""
|
|
|
|
|
LOCAL = "local"
|
|
|
|
|
TELEGRAM = "telegram"
|
|
|
|
|
DISCORD = "discord"
|
|
|
|
|
WHATSAPP = "whatsapp"
|
Add messaging platform enhancements: STT, stickers, Discord UX, Slack, pairing, hooks
Major feature additions inspired by OpenClaw/ClawdBot integration analysis:
Voice Message Transcription (STT):
- Auto-transcribe voice/audio messages via OpenAI Whisper API
- Download voice to ~/.hermes/audio_cache/ on Telegram/Discord/WhatsApp
- Inject transcript as text so all models can understand voice input
- Configurable model (whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe)
Telegram Sticker Understanding:
- Describe static stickers via vision tool with JSON-backed cache
- Cache keyed by file_unique_id avoids redundant API calls
- Animated/video stickers get emoji-based fallback description
Discord Rich UX:
- Native slash commands (/ask, /reset, /status, /stop) via app_commands
- Button-based exec approvals (Allow Once / Always Allow / Deny)
- ExecApprovalView with user authorization and timeout handling
Slack Integration:
- Full SlackAdapter using slack-bolt with Socket Mode
- DMs, channel messages (mention-gated), /hermes slash command
- File attachment handling with bot-token-authenticated downloads
DM Pairing System:
- Code-based user authorization as alternative to static allowlists
- 8-char codes from unambiguous alphabet, 1-hour expiry
- Rate limiting, lockout after failed attempts, chmod 0600 on data
- CLI: hermes pairing list/approve/revoke/clear-pending
Event Hook System:
- File-based hook discovery from ~/.hermes/hooks/
- HOOK.yaml + handler.py per hook, sync/async handler support
- Events: gateway:startup, session:start/reset, agent:start/step/end
- Wildcard matching (command:* catches all command events)
Cross-Channel Messaging:
- send_message agent tool for delivering to any connected platform
- Enables cron job delivery and cross-platform notifications
Human-Like Response Pacing:
- Configurable delays between message chunks (off/natural/custom)
- HERMES_HUMAN_DELAY_MODE env var with min/max ms settings
Warm Injection Message Style:
- Retrofitted image vision messages with friendly kawaii-consistent tone
- All new injection messages (STT, stickers, errors) use warm style
Also: updated config migration to prompt for optional keys interactively,
bumped config version, updated README, AGENTS.md, .env.example,
cli-config.yaml.example, install scripts, pyproject.toml, and toolsets.
2026-02-15 21:38:59 -08:00
|
|
|
SLACK = "slack"
|
feat: add Signal messenger gateway platform (#405)
Complete Signal adapter using signal-cli daemon HTTP API.
Based on PR #268 by ibhagwan, rebuilt on current main with bug fixes.
Architecture:
- SSE streaming for inbound messages with exponential backoff (2s→60s)
- JSON-RPC 2.0 for outbound (send, typing, attachments, contacts)
- Health monitor detects stale SSE connections (120s threshold)
- Phone number redaction in all logs and global redact.py
Features:
- DM and group message support with separate access policies
- DM policies: pairing (default), allowlist, open
- Group policies: disabled (default), allowlist, open
- Attachment download with magic-byte type detection
- Typing indicators (8s refresh interval)
- 100MB attachment size limit, 8000 char message limit
- E.164 phone + UUID allowlist support
Integration:
- Platform.SIGNAL enum in gateway/config.py
- Signal in _is_user_authorized() allowlist maps (gateway/run.py)
- Adapter factory in _create_adapter() (gateway/run.py)
- user_id_alt/chat_id_alt fields in SessionSource for UUIDs
- send_message tool support via httpx JSON-RPC (not aiohttp)
- Interactive setup wizard in 'hermes gateway setup'
- Connectivity testing during setup (pings /api/v1/check)
- signal-cli detection and install guidance
Bug fixes from PR #268:
- Timestamp reads from envelope_data (not outer wrapper)
- Uses httpx consistently (not aiohttp in send_message tool)
- SIGNAL_DEBUG scoped to signal logger (not root)
- extract_images regex NOT modified (preserves group numbering)
- pairing.py NOT modified (no cross-platform side effects)
- No dual authorization (adapter defers to run.py for user auth)
- Wildcard uses set membership ('*' in set, not list equality)
- .zip default for PK magic bytes (not .docx)
No new Python dependencies — uses httpx (already core).
External requirement: signal-cli daemon (user-installed).
Tests: 30 new tests covering config, init, helpers, session source,
phone redaction, authorization, and send_message integration.
Co-authored-by: ibhagwan <ibhagwan@users.noreply.github.com>
2026-03-08 20:20:35 -07:00
|
|
|
SIGNAL = "signal"
|
2026-02-28 13:32:48 +03:00
|
|
|
HOMEASSISTANT = "homeassistant"
|
feat: add email gateway platform (IMAP/SMTP)
Allow users to interact with Hermes by sending and receiving emails.
Uses IMAP polling for incoming messages and SMTP for replies with
proper threading (In-Reply-To, References headers).
Integrates with all 14 gateway extension points: config, adapter
factory, authorization, send_message tool, cron delivery, toolsets,
prompt hints, channel directory, setup wizard, status display, and
env example.
65 tests covering config, parsing, dispatch, threading, IMAP fetch,
SMTP send, attachments, and all integration points.
2026-03-10 03:15:38 +03:00
|
|
|
EMAIL = "email"
|
feat: add SMS (Twilio) platform adapter
Add SMS as a first-class messaging platform via the Twilio API.
Shares credentials with the existing telephony skill — same
TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER env vars.
Adapter (gateway/platforms/sms.py):
- aiohttp webhook server for inbound (Twilio form-encoded POSTs)
- Twilio REST API with Basic auth for outbound
- Markdown stripping, smart chunking at 1600 chars
- Echo loop prevention, phone number redaction in logs
Integration (13 files):
- gateway config, run, channel_directory
- agent prompt_builder (SMS platform hint)
- cron scheduler, cronjob tools
- send_message_tool (_send_sms via Twilio API)
- toolsets (hermes-sms + hermes-gateway)
- gateway setup wizard, status display
- pyproject.toml (sms optional extra)
- 21 tests
Docs:
- website/docs/user-guide/messaging/sms.md (full setup guide)
- Updated messaging index (architecture, toolsets, security, links)
- Updated environment-variables.md reference
Inspired by PR #1575 (@sunsakis), rewritten for Twilio.
2026-03-17 03:14:53 -07:00
|
|
|
SMS = "sms"
|
2026-03-17 03:04:58 -07:00
|
|
|
DINGTALK = "dingtalk"
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class HomeChannel:
|
|
|
|
|
"""
|
|
|
|
|
Default destination for a platform.
|
|
|
|
|
|
|
|
|
|
When a cron job specifies deliver="telegram" without a specific chat ID,
|
|
|
|
|
messages are sent to this home channel.
|
|
|
|
|
"""
|
|
|
|
|
platform: Platform
|
|
|
|
|
chat_id: str
|
|
|
|
|
name: str # Human-readable name for display
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"platform": self.platform.value,
|
|
|
|
|
"chat_id": self.chat_id,
|
|
|
|
|
"name": self.name,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def from_dict(cls, data: Dict[str, Any]) -> "HomeChannel":
|
|
|
|
|
return cls(
|
|
|
|
|
platform=Platform(data["platform"]),
|
|
|
|
|
chat_id=str(data["chat_id"]),
|
|
|
|
|
name=data.get("name", "Home"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class SessionResetPolicy:
|
|
|
|
|
"""
|
|
|
|
|
Controls when sessions reset (lose context).
|
|
|
|
|
|
|
|
|
|
Modes:
|
|
|
|
|
- "daily": Reset at a specific hour each day
|
|
|
|
|
- "idle": Reset after N minutes of inactivity
|
|
|
|
|
- "both": Whichever triggers first (daily boundary OR idle timeout)
|
2026-02-26 21:20:50 -08:00
|
|
|
- "none": Never auto-reset (context managed only by compression)
|
2026-02-02 19:01:51 -08:00
|
|
|
"""
|
2026-02-26 21:20:50 -08:00
|
|
|
mode: str = "both" # "daily", "idle", "both", or "none"
|
2026-02-02 19:01:51 -08:00
|
|
|
at_hour: int = 4 # Hour for daily reset (0-23, local time)
|
Add background process management with process tool, wait, PTY, and stdin support
New process registry and tool for managing long-running background processes
across all terminal backends (local, Docker, Singularity, Modal, SSH).
Process Registry (tools/process_registry.py):
- ProcessSession tracking with rolling 200KB output buffer
- spawn_local() with optional PTY via ptyprocess for interactive CLIs
- spawn_via_env() for non-local backends (runs inside sandbox, never on host)
- Background reader threads per process (Popen stdout or PTY)
- wait() with timeout clamping, interrupt support, and transparent limit reporting
- JSON checkpoint to ~/.hermes/processes.json for gateway crash recovery
- Module-level singleton shared across agent loop, gateway, and RL
Process Tool (model_tools.py):
- 7 actions: list, poll, log, wait, kill, write, submit
- Paired with terminal in all toolsets (CLI, messaging, RL)
- Timeout clamping with transparent notes in response
Terminal Tool Updates (tools/terminal_tool.py):
- Replaced nohup background mode with registry spawn (returns session_id)
- Added workdir parameter for per-command working directory
- Added check_interval parameter for gateway auto-check watchers
- Added pty parameter for interactive CLI tools (Codex, Claude Code)
- Updated TERMINAL_TOOL_DESCRIPTION with full background workflow docs
- Cleanup thread now respects active background processes (won't reap sandbox)
Gateway Integration (gateway/run.py, session.py, config.py):
- Session reset protection: sessions with active processes exempt from reset
- Default idle timeout increased from 2 hours to 24 hours
- from_dict fallback aligned to match (was 120, now 1440)
- session_key env var propagated to process registry for session mapping
- Crash recovery on gateway startup via checkpoint probe
- check_interval watcher: asyncio task polls process, delivers updates to platform
RL Safety (environments/):
- tool_context.py cleanup() kills background processes on episode end
- hermes_base_env.py warns when enabled_toolsets is None (loads all tools)
- Process tool safe in RL via wait() blocking the agent loop
Also:
- Added ptyprocess as optional dependency (in pyproject.toml [pty] extra + [all])
- Fixed pre-existing bug: rl_test_inference missing from TOOL_TO_TOOLSET_MAP
- Updated AGENTS.md with process management docs and project structure
- Updated README.md terminal section with process management overview
2026-02-17 02:51:31 -08:00
|
|
|
idle_minutes: int = 1440 # Minutes of inactivity before reset (24 hours)
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"mode": self.mode,
|
|
|
|
|
"at_hour": self.at_hour,
|
|
|
|
|
"idle_minutes": self.idle_minutes,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def from_dict(cls, data: Dict[str, Any]) -> "SessionResetPolicy":
|
fix: handle YAML null values in session reset policy + configurable API timeout (#1194)
* fix: Home Assistant event filtering now closed by default
Previously, when no watch_domains or watch_entities were configured,
ALL state_changed events passed through to the agent, causing users
to be flooded with notifications for every HA entity change.
Now events are dropped by default unless the user explicitly configures:
- watch_domains: list of domains to monitor (e.g. climate, light)
- watch_entities: list of specific entity IDs to monitor
- watch_all: true (new option — opt-in to receive all events)
A warning is logged at connect time if no filters are configured,
guiding users to set up their HA platform config.
All 49 gateway HA tests + 52 HA tool tests pass.
* docs: update Home Assistant integration documentation
- homeassistant.md: Fix event filtering docs to reflect closed-by-default
behavior. Add watch_all option. Replace Python dict config example with
YAML. Fix defaults table (was incorrectly showing 'all'). Add required
configuration warning admonition.
- environment-variables.md: Add HASS_TOKEN and HASS_URL to Messaging section.
- messaging/index.md: Add Home Assistant to description, architecture
diagram, platform toolsets table, and Next Steps links.
* fix(terminal): strip provider env vars from background and PTY subprocesses
Extends the env var blocklist from #1157 to also cover the two remaining
leaky paths in process_registry.py:
- spawn_local() PTY path (line 156)
- spawn_local() background Popen path (line 197)
Both were still using raw os.environ, leaking provider vars to background
processes and interactive PTY sessions. Now uses the same dynamic
_HERMES_PROVIDER_ENV_BLOCKLIST from local.py.
Explicit env_vars passed to spawn_local() still override the blocklist,
matching the existing behavior for callers that intentionally need these.
Gap identified by PR #1004 (@PeterFile).
* feat(delegate): add observability metadata to subagent results
Enrich delegate_task results with metadata from the child AIAgent:
- model: which model the child used
- exit_reason: completed | interrupted | max_iterations
- tokens.input / tokens.output: token counts
- tool_trace: per-tool-call trace with byte sizes and ok/error status
Tool trace uses tool_call_id matching to correctly pair parallel tool
calls with their results, with a fallback for messages without IDs.
Cherry-picked from PR #872 by @omerkaz, with fixes:
- Fixed parallel tool call trace pairing (was always updating last entry)
- Removed redundant 'iterations' field (identical to existing 'api_calls')
- Added test for parallel tool call trace correctness
Co-authored-by: omerkaz <omerkaz@users.noreply.github.com>
* feat(stt): add free local whisper transcription via faster-whisper
Replace OpenAI-only STT with a dual-provider system mirroring the TTS
architecture (Edge TTS free / ElevenLabs paid):
STT: faster-whisper local (free, default) / OpenAI Whisper API (paid)
Changes:
- tools/transcription_tools.py: Full rewrite with provider dispatch,
config loading, local faster-whisper backend, and OpenAI API backend.
Auto-downloads model (~150MB for 'base') on first voice message.
Singleton model instance reused across calls.
- pyproject.toml: Add faster-whisper>=1.0.0 as core dependency
- hermes_cli/config.py: Expand stt config to match TTS pattern with
provider selection and per-provider model settings
- agent/context_compressor.py: Fix .strip() crash when LLM returns
non-string content (dict from llama.cpp, None). Fixes #1100 partially.
- tests/: 23 new tests for STT providers + 2 for compressor fix
- docs/: Updated Voice & TTS page with STT provider table, model sizes,
config examples, and fallback behavior
Fallback behavior:
- Local not installed → OpenAI API (if key set)
- OpenAI key not set → local whisper (if installed)
- Neither → graceful error message to user
Co-authored-by: Jah-yee <Jah-yee@users.noreply.github.com>
* fix: handle YAML null values in session reset policy + configurable API timeout
Two fixes from PR #888 by @Jah-yee:
1. SessionResetPolicy.from_dict() — data.get('at_hour', 4) returns None
when the YAML key exists with a null value. Now explicitly checks for
None and falls back to defaults. Zero remains a valid value.
2. API timeout — hardcoded 900s is now configurable via HERMES_API_TIMEOUT
env var. Useful for slow local models (llama.cpp) that need longer.
Co-authored-by: Jah-yee <Jah-yee@users.noreply.github.com>
---------
Co-authored-by: omerkaz <omerkaz@users.noreply.github.com>
Co-authored-by: Jah-yee <Jah-yee@users.noreply.github.com>
2026-03-13 11:16:42 -07:00
|
|
|
# Handle both missing keys and explicit null values (YAML null → None)
|
2026-03-15 21:40:22 -07:00
|
|
|
mode = data.get("mode")
|
fix: handle YAML null values in session reset policy + configurable API timeout (#1194)
* fix: Home Assistant event filtering now closed by default
Previously, when no watch_domains or watch_entities were configured,
ALL state_changed events passed through to the agent, causing users
to be flooded with notifications for every HA entity change.
Now events are dropped by default unless the user explicitly configures:
- watch_domains: list of domains to monitor (e.g. climate, light)
- watch_entities: list of specific entity IDs to monitor
- watch_all: true (new option — opt-in to receive all events)
A warning is logged at connect time if no filters are configured,
guiding users to set up their HA platform config.
All 49 gateway HA tests + 52 HA tool tests pass.
* docs: update Home Assistant integration documentation
- homeassistant.md: Fix event filtering docs to reflect closed-by-default
behavior. Add watch_all option. Replace Python dict config example with
YAML. Fix defaults table (was incorrectly showing 'all'). Add required
configuration warning admonition.
- environment-variables.md: Add HASS_TOKEN and HASS_URL to Messaging section.
- messaging/index.md: Add Home Assistant to description, architecture
diagram, platform toolsets table, and Next Steps links.
* fix(terminal): strip provider env vars from background and PTY subprocesses
Extends the env var blocklist from #1157 to also cover the two remaining
leaky paths in process_registry.py:
- spawn_local() PTY path (line 156)
- spawn_local() background Popen path (line 197)
Both were still using raw os.environ, leaking provider vars to background
processes and interactive PTY sessions. Now uses the same dynamic
_HERMES_PROVIDER_ENV_BLOCKLIST from local.py.
Explicit env_vars passed to spawn_local() still override the blocklist,
matching the existing behavior for callers that intentionally need these.
Gap identified by PR #1004 (@PeterFile).
* feat(delegate): add observability metadata to subagent results
Enrich delegate_task results with metadata from the child AIAgent:
- model: which model the child used
- exit_reason: completed | interrupted | max_iterations
- tokens.input / tokens.output: token counts
- tool_trace: per-tool-call trace with byte sizes and ok/error status
Tool trace uses tool_call_id matching to correctly pair parallel tool
calls with their results, with a fallback for messages without IDs.
Cherry-picked from PR #872 by @omerkaz, with fixes:
- Fixed parallel tool call trace pairing (was always updating last entry)
- Removed redundant 'iterations' field (identical to existing 'api_calls')
- Added test for parallel tool call trace correctness
Co-authored-by: omerkaz <omerkaz@users.noreply.github.com>
* feat(stt): add free local whisper transcription via faster-whisper
Replace OpenAI-only STT with a dual-provider system mirroring the TTS
architecture (Edge TTS free / ElevenLabs paid):
STT: faster-whisper local (free, default) / OpenAI Whisper API (paid)
Changes:
- tools/transcription_tools.py: Full rewrite with provider dispatch,
config loading, local faster-whisper backend, and OpenAI API backend.
Auto-downloads model (~150MB for 'base') on first voice message.
Singleton model instance reused across calls.
- pyproject.toml: Add faster-whisper>=1.0.0 as core dependency
- hermes_cli/config.py: Expand stt config to match TTS pattern with
provider selection and per-provider model settings
- agent/context_compressor.py: Fix .strip() crash when LLM returns
non-string content (dict from llama.cpp, None). Fixes #1100 partially.
- tests/: 23 new tests for STT providers + 2 for compressor fix
- docs/: Updated Voice & TTS page with STT provider table, model sizes,
config examples, and fallback behavior
Fallback behavior:
- Local not installed → OpenAI API (if key set)
- OpenAI key not set → local whisper (if installed)
- Neither → graceful error message to user
Co-authored-by: Jah-yee <Jah-yee@users.noreply.github.com>
* fix: handle YAML null values in session reset policy + configurable API timeout
Two fixes from PR #888 by @Jah-yee:
1. SessionResetPolicy.from_dict() — data.get('at_hour', 4) returns None
when the YAML key exists with a null value. Now explicitly checks for
None and falls back to defaults. Zero remains a valid value.
2. API timeout — hardcoded 900s is now configurable via HERMES_API_TIMEOUT
env var. Useful for slow local models (llama.cpp) that need longer.
Co-authored-by: Jah-yee <Jah-yee@users.noreply.github.com>
---------
Co-authored-by: omerkaz <omerkaz@users.noreply.github.com>
Co-authored-by: Jah-yee <Jah-yee@users.noreply.github.com>
2026-03-13 11:16:42 -07:00
|
|
|
at_hour = data.get("at_hour")
|
|
|
|
|
idle_minutes = data.get("idle_minutes")
|
2026-02-02 19:01:51 -08:00
|
|
|
return cls(
|
2026-03-15 21:40:22 -07:00
|
|
|
mode=mode if mode is not None else "both",
|
fix: handle YAML null values in session reset policy + configurable API timeout (#1194)
* fix: Home Assistant event filtering now closed by default
Previously, when no watch_domains or watch_entities were configured,
ALL state_changed events passed through to the agent, causing users
to be flooded with notifications for every HA entity change.
Now events are dropped by default unless the user explicitly configures:
- watch_domains: list of domains to monitor (e.g. climate, light)
- watch_entities: list of specific entity IDs to monitor
- watch_all: true (new option — opt-in to receive all events)
A warning is logged at connect time if no filters are configured,
guiding users to set up their HA platform config.
All 49 gateway HA tests + 52 HA tool tests pass.
* docs: update Home Assistant integration documentation
- homeassistant.md: Fix event filtering docs to reflect closed-by-default
behavior. Add watch_all option. Replace Python dict config example with
YAML. Fix defaults table (was incorrectly showing 'all'). Add required
configuration warning admonition.
- environment-variables.md: Add HASS_TOKEN and HASS_URL to Messaging section.
- messaging/index.md: Add Home Assistant to description, architecture
diagram, platform toolsets table, and Next Steps links.
* fix(terminal): strip provider env vars from background and PTY subprocesses
Extends the env var blocklist from #1157 to also cover the two remaining
leaky paths in process_registry.py:
- spawn_local() PTY path (line 156)
- spawn_local() background Popen path (line 197)
Both were still using raw os.environ, leaking provider vars to background
processes and interactive PTY sessions. Now uses the same dynamic
_HERMES_PROVIDER_ENV_BLOCKLIST from local.py.
Explicit env_vars passed to spawn_local() still override the blocklist,
matching the existing behavior for callers that intentionally need these.
Gap identified by PR #1004 (@PeterFile).
* feat(delegate): add observability metadata to subagent results
Enrich delegate_task results with metadata from the child AIAgent:
- model: which model the child used
- exit_reason: completed | interrupted | max_iterations
- tokens.input / tokens.output: token counts
- tool_trace: per-tool-call trace with byte sizes and ok/error status
Tool trace uses tool_call_id matching to correctly pair parallel tool
calls with their results, with a fallback for messages without IDs.
Cherry-picked from PR #872 by @omerkaz, with fixes:
- Fixed parallel tool call trace pairing (was always updating last entry)
- Removed redundant 'iterations' field (identical to existing 'api_calls')
- Added test for parallel tool call trace correctness
Co-authored-by: omerkaz <omerkaz@users.noreply.github.com>
* feat(stt): add free local whisper transcription via faster-whisper
Replace OpenAI-only STT with a dual-provider system mirroring the TTS
architecture (Edge TTS free / ElevenLabs paid):
STT: faster-whisper local (free, default) / OpenAI Whisper API (paid)
Changes:
- tools/transcription_tools.py: Full rewrite with provider dispatch,
config loading, local faster-whisper backend, and OpenAI API backend.
Auto-downloads model (~150MB for 'base') on first voice message.
Singleton model instance reused across calls.
- pyproject.toml: Add faster-whisper>=1.0.0 as core dependency
- hermes_cli/config.py: Expand stt config to match TTS pattern with
provider selection and per-provider model settings
- agent/context_compressor.py: Fix .strip() crash when LLM returns
non-string content (dict from llama.cpp, None). Fixes #1100 partially.
- tests/: 23 new tests for STT providers + 2 for compressor fix
- docs/: Updated Voice & TTS page with STT provider table, model sizes,
config examples, and fallback behavior
Fallback behavior:
- Local not installed → OpenAI API (if key set)
- OpenAI key not set → local whisper (if installed)
- Neither → graceful error message to user
Co-authored-by: Jah-yee <Jah-yee@users.noreply.github.com>
* fix: handle YAML null values in session reset policy + configurable API timeout
Two fixes from PR #888 by @Jah-yee:
1. SessionResetPolicy.from_dict() — data.get('at_hour', 4) returns None
when the YAML key exists with a null value. Now explicitly checks for
None and falls back to defaults. Zero remains a valid value.
2. API timeout — hardcoded 900s is now configurable via HERMES_API_TIMEOUT
env var. Useful for slow local models (llama.cpp) that need longer.
Co-authored-by: Jah-yee <Jah-yee@users.noreply.github.com>
---------
Co-authored-by: omerkaz <omerkaz@users.noreply.github.com>
Co-authored-by: Jah-yee <Jah-yee@users.noreply.github.com>
2026-03-13 11:16:42 -07:00
|
|
|
at_hour=at_hour if at_hour is not None else 4,
|
|
|
|
|
idle_minutes=idle_minutes if idle_minutes is not None else 1440,
|
2026-02-02 19:01:51 -08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class PlatformConfig:
|
|
|
|
|
"""Configuration for a single messaging platform."""
|
|
|
|
|
enabled: bool = False
|
|
|
|
|
token: Optional[str] = None # Bot token (Telegram, Discord)
|
|
|
|
|
api_key: Optional[str] = None # API key if different from token
|
|
|
|
|
home_channel: Optional[HomeChannel] = None
|
|
|
|
|
|
|
|
|
|
# Platform-specific settings
|
|
|
|
|
extra: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
|
|
|
result = {
|
|
|
|
|
"enabled": self.enabled,
|
|
|
|
|
"extra": self.extra,
|
|
|
|
|
}
|
|
|
|
|
if self.token:
|
|
|
|
|
result["token"] = self.token
|
|
|
|
|
if self.api_key:
|
|
|
|
|
result["api_key"] = self.api_key
|
|
|
|
|
if self.home_channel:
|
|
|
|
|
result["home_channel"] = self.home_channel.to_dict()
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig":
|
|
|
|
|
home_channel = None
|
|
|
|
|
if "home_channel" in data:
|
|
|
|
|
home_channel = HomeChannel.from_dict(data["home_channel"])
|
|
|
|
|
|
|
|
|
|
return cls(
|
|
|
|
|
enabled=data.get("enabled", False),
|
|
|
|
|
token=data.get("token"),
|
|
|
|
|
api_key=data.get("api_key"),
|
|
|
|
|
home_channel=home_channel,
|
|
|
|
|
extra=data.get("extra", {}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
feat(gateway): streaming token delivery — StreamingConfig, GatewayStreamConsumer, already_sent
Stage 3 of streaming support. Gateway now streams tokens to messaging platforms:
- StreamingConfig dataclass (enabled, transport, edit_interval, buffer_threshold, cursor)
on GatewayConfig with from_dict/to_dict serialization
- GatewayStreamConsumer: async queue-based consumer that progressively edits
a single message on the target platform (edit transport)
- on_delta() → queue → run() async task → send_or_edit() with rate limiting
- already_sent propagation: when streaming delivered the response, handler
returns None so base adapter skips duplicate send()
- stream_delta_callback wired into AIAgent constructor in _run_agent
- Consumer lifecycle: started as asyncio task, awaited with timeout in finally
Config (config.yaml):
streaming:
enabled: true
transport: edit # progressive editMessageText
edit_interval: 0.3 # seconds between edits
buffer_threshold: 40 # chars before forcing flush
cursor: ' ▉'
Credit: jobless0x (#774, #1312), OutThisLife (#798), clicksingh (#697).
2026-03-16 05:52:42 -07:00
|
|
|
@dataclass
|
|
|
|
|
class StreamingConfig:
|
|
|
|
|
"""Configuration for real-time token streaming to messaging platforms."""
|
2026-03-16 07:44:42 -07:00
|
|
|
enabled: bool = False
|
feat(gateway): streaming token delivery — StreamingConfig, GatewayStreamConsumer, already_sent
Stage 3 of streaming support. Gateway now streams tokens to messaging platforms:
- StreamingConfig dataclass (enabled, transport, edit_interval, buffer_threshold, cursor)
on GatewayConfig with from_dict/to_dict serialization
- GatewayStreamConsumer: async queue-based consumer that progressively edits
a single message on the target platform (edit transport)
- on_delta() → queue → run() async task → send_or_edit() with rate limiting
- already_sent propagation: when streaming delivered the response, handler
returns None so base adapter skips duplicate send()
- stream_delta_callback wired into AIAgent constructor in _run_agent
- Consumer lifecycle: started as asyncio task, awaited with timeout in finally
Config (config.yaml):
streaming:
enabled: true
transport: edit # progressive editMessageText
edit_interval: 0.3 # seconds between edits
buffer_threshold: 40 # chars before forcing flush
cursor: ' ▉'
Credit: jobless0x (#774, #1312), OutThisLife (#798), clicksingh (#697).
2026-03-16 05:52:42 -07:00
|
|
|
transport: str = "edit" # "edit" (progressive editMessageText) or "off"
|
|
|
|
|
edit_interval: float = 0.3 # Seconds between message edits
|
|
|
|
|
buffer_threshold: int = 40 # Chars before forcing an edit
|
|
|
|
|
cursor: str = " ▉" # Cursor shown during streaming
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"enabled": self.enabled,
|
|
|
|
|
"transport": self.transport,
|
|
|
|
|
"edit_interval": self.edit_interval,
|
|
|
|
|
"buffer_threshold": self.buffer_threshold,
|
|
|
|
|
"cursor": self.cursor,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def from_dict(cls, data: Dict[str, Any]) -> "StreamingConfig":
|
|
|
|
|
if not data:
|
|
|
|
|
return cls()
|
|
|
|
|
return cls(
|
2026-03-16 07:44:42 -07:00
|
|
|
enabled=data.get("enabled", False),
|
feat(gateway): streaming token delivery — StreamingConfig, GatewayStreamConsumer, already_sent
Stage 3 of streaming support. Gateway now streams tokens to messaging platforms:
- StreamingConfig dataclass (enabled, transport, edit_interval, buffer_threshold, cursor)
on GatewayConfig with from_dict/to_dict serialization
- GatewayStreamConsumer: async queue-based consumer that progressively edits
a single message on the target platform (edit transport)
- on_delta() → queue → run() async task → send_or_edit() with rate limiting
- already_sent propagation: when streaming delivered the response, handler
returns None so base adapter skips duplicate send()
- stream_delta_callback wired into AIAgent constructor in _run_agent
- Consumer lifecycle: started as asyncio task, awaited with timeout in finally
Config (config.yaml):
streaming:
enabled: true
transport: edit # progressive editMessageText
edit_interval: 0.3 # seconds between edits
buffer_threshold: 40 # chars before forcing flush
cursor: ' ▉'
Credit: jobless0x (#774, #1312), OutThisLife (#798), clicksingh (#697).
2026-03-16 05:52:42 -07:00
|
|
|
transport=data.get("transport", "edit"),
|
|
|
|
|
edit_interval=float(data.get("edit_interval", 0.3)),
|
|
|
|
|
buffer_threshold=int(data.get("buffer_threshold", 40)),
|
|
|
|
|
cursor=data.get("cursor", " ▉"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
@dataclass
|
|
|
|
|
class GatewayConfig:
|
|
|
|
|
"""
|
|
|
|
|
Main gateway configuration.
|
|
|
|
|
|
|
|
|
|
Manages all platform connections, session policies, and delivery settings.
|
|
|
|
|
"""
|
|
|
|
|
# Platform configurations
|
|
|
|
|
platforms: Dict[Platform, PlatformConfig] = field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
# Session reset policies by type
|
|
|
|
|
default_reset_policy: SessionResetPolicy = field(default_factory=SessionResetPolicy)
|
|
|
|
|
reset_by_type: Dict[str, SessionResetPolicy] = field(default_factory=dict)
|
|
|
|
|
reset_by_platform: Dict[Platform, SessionResetPolicy] = field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
# Reset trigger commands
|
|
|
|
|
reset_triggers: List[str] = field(default_factory=lambda: ["/new", "/reset"])
|
2026-03-11 12:46:56 -07:00
|
|
|
|
|
|
|
|
# User-defined quick commands (slash commands that bypass the agent loop)
|
|
|
|
|
quick_commands: Dict[str, Any] = field(default_factory=dict)
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
|
|
|
# Storage paths
|
fix(cli): respect HERMES_HOME in all remaining hardcoded ~/.hermes paths
Several files resolved paths via Path.home() / ".hermes" or
os.path.expanduser("~/.hermes/..."), bypassing the HERMES_HOME
environment variable. This broke isolation when running multiple
Hermes instances with distinct HERMES_HOME directories.
Replace all hardcoded paths with calls to get_hermes_home() from
hermes_cli.config, consistent with the rest of the codebase.
Files fixed:
- tools/process_registry.py (processes.json)
- gateway/pairing.py (pairing/)
- gateway/sticker_cache.py (sticker_cache.json)
- gateway/channel_directory.py (channel_directory.json, sessions.json)
- gateway/config.py (gateway.json, config.yaml, sessions_dir)
- gateway/mirror.py (sessions/)
- gateway/hooks.py (hooks/)
- gateway/platforms/base.py (image_cache/, audio_cache/, document_cache/)
- gateway/platforms/whatsapp.py (whatsapp/session)
- gateway/delivery.py (cron/output)
- agent/auxiliary_client.py (auth.json)
- agent/prompt_builder.py (SOUL.md)
- cli.py (config.yaml, images/, pastes/, history)
- run_agent.py (logs/)
- tools/environments/base.py (sandboxes/)
- tools/environments/modal.py (modal_snapshots.json)
- tools/environments/singularity.py (singularity_snapshots.json)
- tools/tts_tool.py (audio_cache)
- hermes_cli/status.py (cron/jobs.json, sessions.json)
- hermes_cli/gateway.py (logs/, whatsapp session)
- hermes_cli/main.py (whatsapp/session)
Tests updated to use HERMES_HOME env var instead of patching Path.home().
Closes #892
(cherry picked from commit 78ac1bba43b8b74a934c6172f2c29bb4d03164b9)
2026-03-11 07:31:41 +01:00
|
|
|
sessions_dir: Path = field(default_factory=lambda: get_hermes_home() / "sessions")
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
|
|
|
# Delivery settings
|
|
|
|
|
always_log_local: bool = True # Always save cron outputs to local files
|
2026-03-14 22:09:53 -07:00
|
|
|
|
|
|
|
|
# STT settings
|
|
|
|
|
stt_enabled: bool = True # Whether to auto-transcribe inbound voice messages
|
2026-03-16 00:22:23 -07:00
|
|
|
|
|
|
|
|
# Session isolation in shared chats
|
|
|
|
|
group_sessions_per_user: bool = True # Isolate group/channel sessions per participant when user IDs are available
|
|
|
|
|
|
feat(gateway): streaming token delivery — StreamingConfig, GatewayStreamConsumer, already_sent
Stage 3 of streaming support. Gateway now streams tokens to messaging platforms:
- StreamingConfig dataclass (enabled, transport, edit_interval, buffer_threshold, cursor)
on GatewayConfig with from_dict/to_dict serialization
- GatewayStreamConsumer: async queue-based consumer that progressively edits
a single message on the target platform (edit transport)
- on_delta() → queue → run() async task → send_or_edit() with rate limiting
- already_sent propagation: when streaming delivered the response, handler
returns None so base adapter skips duplicate send()
- stream_delta_callback wired into AIAgent constructor in _run_agent
- Consumer lifecycle: started as asyncio task, awaited with timeout in finally
Config (config.yaml):
streaming:
enabled: true
transport: edit # progressive editMessageText
edit_interval: 0.3 # seconds between edits
buffer_threshold: 40 # chars before forcing flush
cursor: ' ▉'
Credit: jobless0x (#774, #1312), OutThisLife (#798), clicksingh (#697).
2026-03-16 05:52:42 -07:00
|
|
|
# Streaming configuration
|
|
|
|
|
streaming: StreamingConfig = field(default_factory=StreamingConfig)
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
def get_connected_platforms(self) -> List[Platform]:
|
|
|
|
|
"""Return list of platforms that are enabled and configured."""
|
|
|
|
|
connected = []
|
|
|
|
|
for platform, config in self.platforms.items():
|
feat: add Signal messenger gateway platform (#405)
Complete Signal adapter using signal-cli daemon HTTP API.
Based on PR #268 by ibhagwan, rebuilt on current main with bug fixes.
Architecture:
- SSE streaming for inbound messages with exponential backoff (2s→60s)
- JSON-RPC 2.0 for outbound (send, typing, attachments, contacts)
- Health monitor detects stale SSE connections (120s threshold)
- Phone number redaction in all logs and global redact.py
Features:
- DM and group message support with separate access policies
- DM policies: pairing (default), allowlist, open
- Group policies: disabled (default), allowlist, open
- Attachment download with magic-byte type detection
- Typing indicators (8s refresh interval)
- 100MB attachment size limit, 8000 char message limit
- E.164 phone + UUID allowlist support
Integration:
- Platform.SIGNAL enum in gateway/config.py
- Signal in _is_user_authorized() allowlist maps (gateway/run.py)
- Adapter factory in _create_adapter() (gateway/run.py)
- user_id_alt/chat_id_alt fields in SessionSource for UUIDs
- send_message tool support via httpx JSON-RPC (not aiohttp)
- Interactive setup wizard in 'hermes gateway setup'
- Connectivity testing during setup (pings /api/v1/check)
- signal-cli detection and install guidance
Bug fixes from PR #268:
- Timestamp reads from envelope_data (not outer wrapper)
- Uses httpx consistently (not aiohttp in send_message tool)
- SIGNAL_DEBUG scoped to signal logger (not root)
- extract_images regex NOT modified (preserves group numbering)
- pairing.py NOT modified (no cross-platform side effects)
- No dual authorization (adapter defers to run.py for user auth)
- Wildcard uses set membership ('*' in set, not list equality)
- .zip default for PK magic bytes (not .docx)
No new Python dependencies — uses httpx (already core).
External requirement: signal-cli daemon (user-installed).
Tests: 30 new tests covering config, init, helpers, session source,
phone redaction, authorization, and send_message integration.
Co-authored-by: ibhagwan <ibhagwan@users.noreply.github.com>
2026-03-08 20:20:35 -07:00
|
|
|
if not config.enabled:
|
|
|
|
|
continue
|
|
|
|
|
# Platforms that use token/api_key auth
|
|
|
|
|
if config.token or config.api_key:
|
|
|
|
|
connected.append(platform)
|
|
|
|
|
# WhatsApp uses enabled flag only (bridge handles auth)
|
|
|
|
|
elif platform == Platform.WHATSAPP:
|
|
|
|
|
connected.append(platform)
|
|
|
|
|
# Signal uses extra dict for config (http_url + account)
|
|
|
|
|
elif platform == Platform.SIGNAL and config.extra.get("http_url"):
|
2026-02-02 19:01:51 -08:00
|
|
|
connected.append(platform)
|
feat: add email gateway platform (IMAP/SMTP)
Allow users to interact with Hermes by sending and receiving emails.
Uses IMAP polling for incoming messages and SMTP for replies with
proper threading (In-Reply-To, References headers).
Integrates with all 14 gateway extension points: config, adapter
factory, authorization, send_message tool, cron delivery, toolsets,
prompt hints, channel directory, setup wizard, status display, and
env example.
65 tests covering config, parsing, dispatch, threading, IMAP fetch,
SMTP send, attachments, and all integration points.
2026-03-10 03:15:38 +03:00
|
|
|
# Email uses extra dict for config (address + imap_host + smtp_host)
|
|
|
|
|
elif platform == Platform.EMAIL and config.extra.get("address"):
|
|
|
|
|
connected.append(platform)
|
feat: add SMS (Twilio) platform adapter
Add SMS as a first-class messaging platform via the Twilio API.
Shares credentials with the existing telephony skill — same
TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER env vars.
Adapter (gateway/platforms/sms.py):
- aiohttp webhook server for inbound (Twilio form-encoded POSTs)
- Twilio REST API with Basic auth for outbound
- Markdown stripping, smart chunking at 1600 chars
- Echo loop prevention, phone number redaction in logs
Integration (13 files):
- gateway config, run, channel_directory
- agent prompt_builder (SMS platform hint)
- cron scheduler, cronjob tools
- send_message_tool (_send_sms via Twilio API)
- toolsets (hermes-sms + hermes-gateway)
- gateway setup wizard, status display
- pyproject.toml (sms optional extra)
- 21 tests
Docs:
- website/docs/user-guide/messaging/sms.md (full setup guide)
- Updated messaging index (architecture, toolsets, security, links)
- Updated environment-variables.md reference
Inspired by PR #1575 (@sunsakis), rewritten for Twilio.
2026-03-17 03:14:53 -07:00
|
|
|
# SMS uses api_key (Twilio auth token) — SID checked via env
|
|
|
|
|
elif platform == Platform.SMS and os.getenv("TWILIO_ACCOUNT_SID"):
|
|
|
|
|
connected.append(platform)
|
2026-02-02 19:01:51 -08:00
|
|
|
return connected
|
|
|
|
|
|
|
|
|
|
def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]:
|
|
|
|
|
"""Get the home channel for a platform."""
|
|
|
|
|
config = self.platforms.get(platform)
|
|
|
|
|
if config:
|
|
|
|
|
return config.home_channel
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def get_reset_policy(
|
|
|
|
|
self,
|
|
|
|
|
platform: Optional[Platform] = None,
|
|
|
|
|
session_type: Optional[str] = None
|
|
|
|
|
) -> SessionResetPolicy:
|
|
|
|
|
"""
|
|
|
|
|
Get the appropriate reset policy for a session.
|
|
|
|
|
|
|
|
|
|
Priority: platform override > type override > default
|
|
|
|
|
"""
|
|
|
|
|
# Platform-specific override takes precedence
|
|
|
|
|
if platform and platform in self.reset_by_platform:
|
|
|
|
|
return self.reset_by_platform[platform]
|
|
|
|
|
|
|
|
|
|
# Type-specific override (dm, group, thread)
|
|
|
|
|
if session_type and session_type in self.reset_by_type:
|
|
|
|
|
return self.reset_by_type[session_type]
|
|
|
|
|
|
|
|
|
|
return self.default_reset_policy
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"platforms": {
|
|
|
|
|
p.value: c.to_dict() for p, c in self.platforms.items()
|
|
|
|
|
},
|
|
|
|
|
"default_reset_policy": self.default_reset_policy.to_dict(),
|
|
|
|
|
"reset_by_type": {
|
|
|
|
|
k: v.to_dict() for k, v in self.reset_by_type.items()
|
|
|
|
|
},
|
|
|
|
|
"reset_by_platform": {
|
|
|
|
|
p.value: v.to_dict() for p, v in self.reset_by_platform.items()
|
|
|
|
|
},
|
|
|
|
|
"reset_triggers": self.reset_triggers,
|
2026-03-11 12:46:56 -07:00
|
|
|
"quick_commands": self.quick_commands,
|
2026-02-02 19:01:51 -08:00
|
|
|
"sessions_dir": str(self.sessions_dir),
|
|
|
|
|
"always_log_local": self.always_log_local,
|
2026-03-14 22:09:53 -07:00
|
|
|
"stt_enabled": self.stt_enabled,
|
2026-03-16 00:22:23 -07:00
|
|
|
"group_sessions_per_user": self.group_sessions_per_user,
|
feat(gateway): streaming token delivery — StreamingConfig, GatewayStreamConsumer, already_sent
Stage 3 of streaming support. Gateway now streams tokens to messaging platforms:
- StreamingConfig dataclass (enabled, transport, edit_interval, buffer_threshold, cursor)
on GatewayConfig with from_dict/to_dict serialization
- GatewayStreamConsumer: async queue-based consumer that progressively edits
a single message on the target platform (edit transport)
- on_delta() → queue → run() async task → send_or_edit() with rate limiting
- already_sent propagation: when streaming delivered the response, handler
returns None so base adapter skips duplicate send()
- stream_delta_callback wired into AIAgent constructor in _run_agent
- Consumer lifecycle: started as asyncio task, awaited with timeout in finally
Config (config.yaml):
streaming:
enabled: true
transport: edit # progressive editMessageText
edit_interval: 0.3 # seconds between edits
buffer_threshold: 40 # chars before forcing flush
cursor: ' ▉'
Credit: jobless0x (#774, #1312), OutThisLife (#798), clicksingh (#697).
2026-03-16 05:52:42 -07:00
|
|
|
"streaming": self.streaming.to_dict(),
|
2026-02-02 19:01:51 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig":
|
|
|
|
|
platforms = {}
|
|
|
|
|
for platform_name, platform_data in data.get("platforms", {}).items():
|
|
|
|
|
try:
|
|
|
|
|
platform = Platform(platform_name)
|
|
|
|
|
platforms[platform] = PlatformConfig.from_dict(platform_data)
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass # Skip unknown platforms
|
|
|
|
|
|
|
|
|
|
reset_by_type = {}
|
|
|
|
|
for type_name, policy_data in data.get("reset_by_type", {}).items():
|
|
|
|
|
reset_by_type[type_name] = SessionResetPolicy.from_dict(policy_data)
|
|
|
|
|
|
|
|
|
|
reset_by_platform = {}
|
|
|
|
|
for platform_name, policy_data in data.get("reset_by_platform", {}).items():
|
|
|
|
|
try:
|
|
|
|
|
platform = Platform(platform_name)
|
|
|
|
|
reset_by_platform[platform] = SessionResetPolicy.from_dict(policy_data)
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
default_policy = SessionResetPolicy()
|
|
|
|
|
if "default_reset_policy" in data:
|
|
|
|
|
default_policy = SessionResetPolicy.from_dict(data["default_reset_policy"])
|
|
|
|
|
|
fix(cli): respect HERMES_HOME in all remaining hardcoded ~/.hermes paths
Several files resolved paths via Path.home() / ".hermes" or
os.path.expanduser("~/.hermes/..."), bypassing the HERMES_HOME
environment variable. This broke isolation when running multiple
Hermes instances with distinct HERMES_HOME directories.
Replace all hardcoded paths with calls to get_hermes_home() from
hermes_cli.config, consistent with the rest of the codebase.
Files fixed:
- tools/process_registry.py (processes.json)
- gateway/pairing.py (pairing/)
- gateway/sticker_cache.py (sticker_cache.json)
- gateway/channel_directory.py (channel_directory.json, sessions.json)
- gateway/config.py (gateway.json, config.yaml, sessions_dir)
- gateway/mirror.py (sessions/)
- gateway/hooks.py (hooks/)
- gateway/platforms/base.py (image_cache/, audio_cache/, document_cache/)
- gateway/platforms/whatsapp.py (whatsapp/session)
- gateway/delivery.py (cron/output)
- agent/auxiliary_client.py (auth.json)
- agent/prompt_builder.py (SOUL.md)
- cli.py (config.yaml, images/, pastes/, history)
- run_agent.py (logs/)
- tools/environments/base.py (sandboxes/)
- tools/environments/modal.py (modal_snapshots.json)
- tools/environments/singularity.py (singularity_snapshots.json)
- tools/tts_tool.py (audio_cache)
- hermes_cli/status.py (cron/jobs.json, sessions.json)
- hermes_cli/gateway.py (logs/, whatsapp session)
- hermes_cli/main.py (whatsapp/session)
Tests updated to use HERMES_HOME env var instead of patching Path.home().
Closes #892
(cherry picked from commit 78ac1bba43b8b74a934c6172f2c29bb4d03164b9)
2026-03-11 07:31:41 +01:00
|
|
|
sessions_dir = get_hermes_home() / "sessions"
|
2026-02-02 19:01:51 -08:00
|
|
|
if "sessions_dir" in data:
|
|
|
|
|
sessions_dir = Path(data["sessions_dir"])
|
|
|
|
|
|
2026-03-11 12:46:56 -07:00
|
|
|
quick_commands = data.get("quick_commands", {})
|
|
|
|
|
if not isinstance(quick_commands, dict):
|
|
|
|
|
quick_commands = {}
|
|
|
|
|
|
2026-03-14 22:09:53 -07:00
|
|
|
stt_enabled = data.get("stt_enabled")
|
|
|
|
|
if stt_enabled is None:
|
|
|
|
|
stt_enabled = data.get("stt", {}).get("enabled") if isinstance(data.get("stt"), dict) else None
|
|
|
|
|
|
2026-03-16 00:22:23 -07:00
|
|
|
group_sessions_per_user = data.get("group_sessions_per_user")
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
return cls(
|
|
|
|
|
platforms=platforms,
|
|
|
|
|
default_reset_policy=default_policy,
|
|
|
|
|
reset_by_type=reset_by_type,
|
|
|
|
|
reset_by_platform=reset_by_platform,
|
|
|
|
|
reset_triggers=data.get("reset_triggers", ["/new", "/reset"]),
|
2026-03-11 12:46:56 -07:00
|
|
|
quick_commands=quick_commands,
|
2026-02-02 19:01:51 -08:00
|
|
|
sessions_dir=sessions_dir,
|
|
|
|
|
always_log_local=data.get("always_log_local", True),
|
2026-03-14 22:09:53 -07:00
|
|
|
stt_enabled=_coerce_bool(stt_enabled, True),
|
2026-03-16 00:22:23 -07:00
|
|
|
group_sessions_per_user=_coerce_bool(group_sessions_per_user, True),
|
feat(gateway): streaming token delivery — StreamingConfig, GatewayStreamConsumer, already_sent
Stage 3 of streaming support. Gateway now streams tokens to messaging platforms:
- StreamingConfig dataclass (enabled, transport, edit_interval, buffer_threshold, cursor)
on GatewayConfig with from_dict/to_dict serialization
- GatewayStreamConsumer: async queue-based consumer that progressively edits
a single message on the target platform (edit transport)
- on_delta() → queue → run() async task → send_or_edit() with rate limiting
- already_sent propagation: when streaming delivered the response, handler
returns None so base adapter skips duplicate send()
- stream_delta_callback wired into AIAgent constructor in _run_agent
- Consumer lifecycle: started as asyncio task, awaited with timeout in finally
Config (config.yaml):
streaming:
enabled: true
transport: edit # progressive editMessageText
edit_interval: 0.3 # seconds between edits
buffer_threshold: 40 # chars before forcing flush
cursor: ' ▉'
Credit: jobless0x (#774, #1312), OutThisLife (#798), clicksingh (#697).
2026-03-16 05:52:42 -07:00
|
|
|
streaming=StreamingConfig.from_dict(data.get("streaming", {})),
|
2026-02-02 19:01:51 -08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_gateway_config() -> GatewayConfig:
|
|
|
|
|
"""
|
|
|
|
|
Load gateway configuration from multiple sources.
|
|
|
|
|
|
|
|
|
|
Priority (highest to lowest):
|
|
|
|
|
1. Environment variables
|
|
|
|
|
2. ~/.hermes/gateway.json
|
|
|
|
|
3. cli-config.yaml gateway section
|
|
|
|
|
4. Defaults
|
|
|
|
|
"""
|
|
|
|
|
config = GatewayConfig()
|
|
|
|
|
|
|
|
|
|
# Try loading from ~/.hermes/gateway.json
|
fix(cli): respect HERMES_HOME in all remaining hardcoded ~/.hermes paths
Several files resolved paths via Path.home() / ".hermes" or
os.path.expanduser("~/.hermes/..."), bypassing the HERMES_HOME
environment variable. This broke isolation when running multiple
Hermes instances with distinct HERMES_HOME directories.
Replace all hardcoded paths with calls to get_hermes_home() from
hermes_cli.config, consistent with the rest of the codebase.
Files fixed:
- tools/process_registry.py (processes.json)
- gateway/pairing.py (pairing/)
- gateway/sticker_cache.py (sticker_cache.json)
- gateway/channel_directory.py (channel_directory.json, sessions.json)
- gateway/config.py (gateway.json, config.yaml, sessions_dir)
- gateway/mirror.py (sessions/)
- gateway/hooks.py (hooks/)
- gateway/platforms/base.py (image_cache/, audio_cache/, document_cache/)
- gateway/platforms/whatsapp.py (whatsapp/session)
- gateway/delivery.py (cron/output)
- agent/auxiliary_client.py (auth.json)
- agent/prompt_builder.py (SOUL.md)
- cli.py (config.yaml, images/, pastes/, history)
- run_agent.py (logs/)
- tools/environments/base.py (sandboxes/)
- tools/environments/modal.py (modal_snapshots.json)
- tools/environments/singularity.py (singularity_snapshots.json)
- tools/tts_tool.py (audio_cache)
- hermes_cli/status.py (cron/jobs.json, sessions.json)
- hermes_cli/gateway.py (logs/, whatsapp session)
- hermes_cli/main.py (whatsapp/session)
Tests updated to use HERMES_HOME env var instead of patching Path.home().
Closes #892
(cherry picked from commit 78ac1bba43b8b74a934c6172f2c29bb4d03164b9)
2026-03-11 07:31:41 +01:00
|
|
|
_home = get_hermes_home()
|
|
|
|
|
gateway_config_path = _home / "gateway.json"
|
2026-02-02 19:01:51 -08:00
|
|
|
if gateway_config_path.exists():
|
|
|
|
|
try:
|
2026-03-05 17:04:33 -05:00
|
|
|
with open(gateway_config_path, "r", encoding="utf-8") as f:
|
2026-02-02 19:01:51 -08:00
|
|
|
data = json.load(f)
|
|
|
|
|
config = GatewayConfig.from_dict(data)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"[gateway] Warning: Failed to load {gateway_config_path}: {e}")
|
fix(cli): respect HERMES_HOME in all remaining hardcoded ~/.hermes paths
Several files resolved paths via Path.home() / ".hermes" or
os.path.expanduser("~/.hermes/..."), bypassing the HERMES_HOME
environment variable. This broke isolation when running multiple
Hermes instances with distinct HERMES_HOME directories.
Replace all hardcoded paths with calls to get_hermes_home() from
hermes_cli.config, consistent with the rest of the codebase.
Files fixed:
- tools/process_registry.py (processes.json)
- gateway/pairing.py (pairing/)
- gateway/sticker_cache.py (sticker_cache.json)
- gateway/channel_directory.py (channel_directory.json, sessions.json)
- gateway/config.py (gateway.json, config.yaml, sessions_dir)
- gateway/mirror.py (sessions/)
- gateway/hooks.py (hooks/)
- gateway/platforms/base.py (image_cache/, audio_cache/, document_cache/)
- gateway/platforms/whatsapp.py (whatsapp/session)
- gateway/delivery.py (cron/output)
- agent/auxiliary_client.py (auth.json)
- agent/prompt_builder.py (SOUL.md)
- cli.py (config.yaml, images/, pastes/, history)
- run_agent.py (logs/)
- tools/environments/base.py (sandboxes/)
- tools/environments/modal.py (modal_snapshots.json)
- tools/environments/singularity.py (singularity_snapshots.json)
- tools/tts_tool.py (audio_cache)
- hermes_cli/status.py (cron/jobs.json, sessions.json)
- hermes_cli/gateway.py (logs/, whatsapp session)
- hermes_cli/main.py (whatsapp/session)
Tests updated to use HERMES_HOME env var instead of patching Path.home().
Closes #892
(cherry picked from commit 78ac1bba43b8b74a934c6172f2c29bb4d03164b9)
2026-03-11 07:31:41 +01:00
|
|
|
|
2026-02-26 21:20:50 -08:00
|
|
|
# Bridge session_reset from config.yaml (the user-facing config file)
|
|
|
|
|
# into the gateway config. config.yaml takes precedence over gateway.json
|
|
|
|
|
# for session reset policy since that's where hermes setup writes it.
|
|
|
|
|
try:
|
|
|
|
|
import yaml
|
fix(cli): respect HERMES_HOME in all remaining hardcoded ~/.hermes paths
Several files resolved paths via Path.home() / ".hermes" or
os.path.expanduser("~/.hermes/..."), bypassing the HERMES_HOME
environment variable. This broke isolation when running multiple
Hermes instances with distinct HERMES_HOME directories.
Replace all hardcoded paths with calls to get_hermes_home() from
hermes_cli.config, consistent with the rest of the codebase.
Files fixed:
- tools/process_registry.py (processes.json)
- gateway/pairing.py (pairing/)
- gateway/sticker_cache.py (sticker_cache.json)
- gateway/channel_directory.py (channel_directory.json, sessions.json)
- gateway/config.py (gateway.json, config.yaml, sessions_dir)
- gateway/mirror.py (sessions/)
- gateway/hooks.py (hooks/)
- gateway/platforms/base.py (image_cache/, audio_cache/, document_cache/)
- gateway/platforms/whatsapp.py (whatsapp/session)
- gateway/delivery.py (cron/output)
- agent/auxiliary_client.py (auth.json)
- agent/prompt_builder.py (SOUL.md)
- cli.py (config.yaml, images/, pastes/, history)
- run_agent.py (logs/)
- tools/environments/base.py (sandboxes/)
- tools/environments/modal.py (modal_snapshots.json)
- tools/environments/singularity.py (singularity_snapshots.json)
- tools/tts_tool.py (audio_cache)
- hermes_cli/status.py (cron/jobs.json, sessions.json)
- hermes_cli/gateway.py (logs/, whatsapp session)
- hermes_cli/main.py (whatsapp/session)
Tests updated to use HERMES_HOME env var instead of patching Path.home().
Closes #892
(cherry picked from commit 78ac1bba43b8b74a934c6172f2c29bb4d03164b9)
2026-03-11 07:31:41 +01:00
|
|
|
config_yaml_path = _home / "config.yaml"
|
2026-02-26 21:20:50 -08:00
|
|
|
if config_yaml_path.exists():
|
2026-03-05 17:04:33 -05:00
|
|
|
with open(config_yaml_path, encoding="utf-8") as f:
|
2026-02-26 21:20:50 -08:00
|
|
|
yaml_cfg = yaml.safe_load(f) or {}
|
|
|
|
|
sr = yaml_cfg.get("session_reset")
|
|
|
|
|
if sr and isinstance(sr, dict):
|
|
|
|
|
config.default_reset_policy = SessionResetPolicy.from_dict(sr)
|
2026-03-11 09:15:31 -07:00
|
|
|
|
2026-03-14 03:57:25 -07:00
|
|
|
# Bridge quick commands from config.yaml into gateway runtime config.
|
|
|
|
|
# config.yaml is the user-facing config source, so when present it
|
|
|
|
|
# should override gateway.json for this setting.
|
|
|
|
|
qc = yaml_cfg.get("quick_commands")
|
|
|
|
|
if qc is not None:
|
|
|
|
|
if isinstance(qc, dict):
|
|
|
|
|
config.quick_commands = qc
|
|
|
|
|
else:
|
|
|
|
|
logger.warning("Ignoring invalid quick_commands in config.yaml (expected mapping, got %s)", type(qc).__name__)
|
|
|
|
|
|
2026-03-14 22:09:53 -07:00
|
|
|
# Bridge STT enable/disable from config.yaml into gateway runtime.
|
|
|
|
|
# This keeps the gateway aligned with the user-facing config source.
|
|
|
|
|
stt_cfg = yaml_cfg.get("stt")
|
|
|
|
|
if isinstance(stt_cfg, dict) and "enabled" in stt_cfg:
|
|
|
|
|
config.stt_enabled = _coerce_bool(stt_cfg.get("enabled"), True)
|
|
|
|
|
|
2026-03-16 00:22:23 -07:00
|
|
|
# Bridge group session isolation from config.yaml into gateway runtime.
|
|
|
|
|
# Secure default is per-user isolation in shared chats.
|
|
|
|
|
if "group_sessions_per_user" in yaml_cfg:
|
|
|
|
|
config.group_sessions_per_user = _coerce_bool(
|
|
|
|
|
yaml_cfg.get("group_sessions_per_user"),
|
|
|
|
|
True,
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-11 09:15:31 -07:00
|
|
|
# Bridge discord settings from config.yaml to env vars
|
|
|
|
|
# (env vars take precedence — only set if not already defined)
|
|
|
|
|
discord_cfg = yaml_cfg.get("discord", {})
|
|
|
|
|
if isinstance(discord_cfg, dict):
|
|
|
|
|
if "require_mention" in discord_cfg and not os.getenv("DISCORD_REQUIRE_MENTION"):
|
|
|
|
|
os.environ["DISCORD_REQUIRE_MENTION"] = str(discord_cfg["require_mention"]).lower()
|
|
|
|
|
frc = discord_cfg.get("free_response_channels")
|
|
|
|
|
if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"):
|
|
|
|
|
if isinstance(frc, list):
|
|
|
|
|
frc = ",".join(str(v) for v in frc)
|
|
|
|
|
os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc)
|
2026-03-13 08:52:54 -07:00
|
|
|
if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"):
|
|
|
|
|
os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower()
|
2026-02-26 21:20:50 -08:00
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
# Override with environment variables
|
|
|
|
|
_apply_env_overrides(config)
|
|
|
|
|
|
2026-02-22 02:16:11 -08:00
|
|
|
# --- Validate loaded values ---
|
|
|
|
|
policy = config.default_reset_policy
|
|
|
|
|
|
|
|
|
|
if not (0 <= policy.at_hour <= 23):
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Invalid at_hour=%s (must be 0-23). Using default 4.", policy.at_hour
|
|
|
|
|
)
|
|
|
|
|
policy.at_hour = 4
|
|
|
|
|
|
|
|
|
|
if policy.idle_minutes is None or policy.idle_minutes <= 0:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Invalid idle_minutes=%s (must be positive). Using default 1440.",
|
|
|
|
|
policy.idle_minutes,
|
|
|
|
|
)
|
|
|
|
|
policy.idle_minutes = 1440
|
|
|
|
|
|
|
|
|
|
# Warn about empty bot tokens — platforms that loaded an empty string
|
|
|
|
|
# won't connect and the cause can be confusing without a log line.
|
|
|
|
|
_token_env_names = {
|
|
|
|
|
Platform.TELEGRAM: "TELEGRAM_BOT_TOKEN",
|
|
|
|
|
Platform.DISCORD: "DISCORD_BOT_TOKEN",
|
|
|
|
|
Platform.SLACK: "SLACK_BOT_TOKEN",
|
|
|
|
|
}
|
|
|
|
|
for platform, pconfig in config.platforms.items():
|
|
|
|
|
if not pconfig.enabled:
|
|
|
|
|
continue
|
|
|
|
|
env_name = _token_env_names.get(platform)
|
|
|
|
|
if env_name and pconfig.token is not None and not pconfig.token.strip():
|
|
|
|
|
logger.warning(
|
|
|
|
|
"%s is enabled but %s is empty. "
|
|
|
|
|
"The adapter will likely fail to connect.",
|
|
|
|
|
platform.value, env_name,
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
return config
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _apply_env_overrides(config: GatewayConfig) -> None:
|
|
|
|
|
"""Apply environment variable overrides to config."""
|
|
|
|
|
|
|
|
|
|
# Telegram
|
|
|
|
|
telegram_token = os.getenv("TELEGRAM_BOT_TOKEN")
|
|
|
|
|
if telegram_token:
|
|
|
|
|
if Platform.TELEGRAM not in config.platforms:
|
|
|
|
|
config.platforms[Platform.TELEGRAM] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.TELEGRAM].enabled = True
|
|
|
|
|
config.platforms[Platform.TELEGRAM].token = telegram_token
|
|
|
|
|
|
|
|
|
|
telegram_home = os.getenv("TELEGRAM_HOME_CHANNEL")
|
|
|
|
|
if telegram_home and Platform.TELEGRAM in config.platforms:
|
|
|
|
|
config.platforms[Platform.TELEGRAM].home_channel = HomeChannel(
|
|
|
|
|
platform=Platform.TELEGRAM,
|
|
|
|
|
chat_id=telegram_home,
|
|
|
|
|
name=os.getenv("TELEGRAM_HOME_CHANNEL_NAME", "Home"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Discord
|
|
|
|
|
discord_token = os.getenv("DISCORD_BOT_TOKEN")
|
|
|
|
|
if discord_token:
|
|
|
|
|
if Platform.DISCORD not in config.platforms:
|
|
|
|
|
config.platforms[Platform.DISCORD] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.DISCORD].enabled = True
|
|
|
|
|
config.platforms[Platform.DISCORD].token = discord_token
|
|
|
|
|
|
|
|
|
|
discord_home = os.getenv("DISCORD_HOME_CHANNEL")
|
|
|
|
|
if discord_home and Platform.DISCORD in config.platforms:
|
|
|
|
|
config.platforms[Platform.DISCORD].home_channel = HomeChannel(
|
|
|
|
|
platform=Platform.DISCORD,
|
|
|
|
|
chat_id=discord_home,
|
|
|
|
|
name=os.getenv("DISCORD_HOME_CHANNEL_NAME", "Home"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# WhatsApp (typically uses different auth mechanism)
|
|
|
|
|
whatsapp_enabled = os.getenv("WHATSAPP_ENABLED", "").lower() in ("true", "1", "yes")
|
|
|
|
|
if whatsapp_enabled:
|
|
|
|
|
if Platform.WHATSAPP not in config.platforms:
|
|
|
|
|
config.platforms[Platform.WHATSAPP] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.WHATSAPP].enabled = True
|
|
|
|
|
|
Add messaging platform enhancements: STT, stickers, Discord UX, Slack, pairing, hooks
Major feature additions inspired by OpenClaw/ClawdBot integration analysis:
Voice Message Transcription (STT):
- Auto-transcribe voice/audio messages via OpenAI Whisper API
- Download voice to ~/.hermes/audio_cache/ on Telegram/Discord/WhatsApp
- Inject transcript as text so all models can understand voice input
- Configurable model (whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe)
Telegram Sticker Understanding:
- Describe static stickers via vision tool with JSON-backed cache
- Cache keyed by file_unique_id avoids redundant API calls
- Animated/video stickers get emoji-based fallback description
Discord Rich UX:
- Native slash commands (/ask, /reset, /status, /stop) via app_commands
- Button-based exec approvals (Allow Once / Always Allow / Deny)
- ExecApprovalView with user authorization and timeout handling
Slack Integration:
- Full SlackAdapter using slack-bolt with Socket Mode
- DMs, channel messages (mention-gated), /hermes slash command
- File attachment handling with bot-token-authenticated downloads
DM Pairing System:
- Code-based user authorization as alternative to static allowlists
- 8-char codes from unambiguous alphabet, 1-hour expiry
- Rate limiting, lockout after failed attempts, chmod 0600 on data
- CLI: hermes pairing list/approve/revoke/clear-pending
Event Hook System:
- File-based hook discovery from ~/.hermes/hooks/
- HOOK.yaml + handler.py per hook, sync/async handler support
- Events: gateway:startup, session:start/reset, agent:start/step/end
- Wildcard matching (command:* catches all command events)
Cross-Channel Messaging:
- send_message agent tool for delivering to any connected platform
- Enables cron job delivery and cross-platform notifications
Human-Like Response Pacing:
- Configurable delays between message chunks (off/natural/custom)
- HERMES_HUMAN_DELAY_MODE env var with min/max ms settings
Warm Injection Message Style:
- Retrofitted image vision messages with friendly kawaii-consistent tone
- All new injection messages (STT, stickers, errors) use warm style
Also: updated config migration to prompt for optional keys interactively,
bumped config version, updated README, AGENTS.md, .env.example,
cli-config.yaml.example, install scripts, pyproject.toml, and toolsets.
2026-02-15 21:38:59 -08:00
|
|
|
# Slack
|
|
|
|
|
slack_token = os.getenv("SLACK_BOT_TOKEN")
|
|
|
|
|
if slack_token:
|
|
|
|
|
if Platform.SLACK not in config.platforms:
|
|
|
|
|
config.platforms[Platform.SLACK] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.SLACK].enabled = True
|
|
|
|
|
config.platforms[Platform.SLACK].token = slack_token
|
|
|
|
|
# Home channel
|
|
|
|
|
slack_home = os.getenv("SLACK_HOME_CHANNEL")
|
|
|
|
|
if slack_home:
|
|
|
|
|
config.platforms[Platform.SLACK].home_channel = HomeChannel(
|
|
|
|
|
platform=Platform.SLACK,
|
|
|
|
|
chat_id=slack_home,
|
|
|
|
|
name=os.getenv("SLACK_HOME_CHANNEL_NAME", ""),
|
|
|
|
|
)
|
|
|
|
|
|
feat: add Signal messenger gateway platform (#405)
Complete Signal adapter using signal-cli daemon HTTP API.
Based on PR #268 by ibhagwan, rebuilt on current main with bug fixes.
Architecture:
- SSE streaming for inbound messages with exponential backoff (2s→60s)
- JSON-RPC 2.0 for outbound (send, typing, attachments, contacts)
- Health monitor detects stale SSE connections (120s threshold)
- Phone number redaction in all logs and global redact.py
Features:
- DM and group message support with separate access policies
- DM policies: pairing (default), allowlist, open
- Group policies: disabled (default), allowlist, open
- Attachment download with magic-byte type detection
- Typing indicators (8s refresh interval)
- 100MB attachment size limit, 8000 char message limit
- E.164 phone + UUID allowlist support
Integration:
- Platform.SIGNAL enum in gateway/config.py
- Signal in _is_user_authorized() allowlist maps (gateway/run.py)
- Adapter factory in _create_adapter() (gateway/run.py)
- user_id_alt/chat_id_alt fields in SessionSource for UUIDs
- send_message tool support via httpx JSON-RPC (not aiohttp)
- Interactive setup wizard in 'hermes gateway setup'
- Connectivity testing during setup (pings /api/v1/check)
- signal-cli detection and install guidance
Bug fixes from PR #268:
- Timestamp reads from envelope_data (not outer wrapper)
- Uses httpx consistently (not aiohttp in send_message tool)
- SIGNAL_DEBUG scoped to signal logger (not root)
- extract_images regex NOT modified (preserves group numbering)
- pairing.py NOT modified (no cross-platform side effects)
- No dual authorization (adapter defers to run.py for user auth)
- Wildcard uses set membership ('*' in set, not list equality)
- .zip default for PK magic bytes (not .docx)
No new Python dependencies — uses httpx (already core).
External requirement: signal-cli daemon (user-installed).
Tests: 30 new tests covering config, init, helpers, session source,
phone redaction, authorization, and send_message integration.
Co-authored-by: ibhagwan <ibhagwan@users.noreply.github.com>
2026-03-08 20:20:35 -07:00
|
|
|
# Signal
|
|
|
|
|
signal_url = os.getenv("SIGNAL_HTTP_URL")
|
|
|
|
|
signal_account = os.getenv("SIGNAL_ACCOUNT")
|
|
|
|
|
if signal_url and signal_account:
|
|
|
|
|
if Platform.SIGNAL not in config.platforms:
|
|
|
|
|
config.platforms[Platform.SIGNAL] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.SIGNAL].enabled = True
|
|
|
|
|
config.platforms[Platform.SIGNAL].extra.update({
|
|
|
|
|
"http_url": signal_url,
|
|
|
|
|
"account": signal_account,
|
|
|
|
|
"ignore_stories": os.getenv("SIGNAL_IGNORE_STORIES", "true").lower() in ("true", "1", "yes"),
|
|
|
|
|
})
|
|
|
|
|
signal_home = os.getenv("SIGNAL_HOME_CHANNEL")
|
|
|
|
|
if signal_home:
|
|
|
|
|
config.platforms[Platform.SIGNAL].home_channel = HomeChannel(
|
|
|
|
|
platform=Platform.SIGNAL,
|
|
|
|
|
chat_id=signal_home,
|
|
|
|
|
name=os.getenv("SIGNAL_HOME_CHANNEL_NAME", "Home"),
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-28 13:32:48 +03:00
|
|
|
# Home Assistant
|
|
|
|
|
hass_token = os.getenv("HASS_TOKEN")
|
|
|
|
|
if hass_token:
|
|
|
|
|
if Platform.HOMEASSISTANT not in config.platforms:
|
|
|
|
|
config.platforms[Platform.HOMEASSISTANT] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.HOMEASSISTANT].enabled = True
|
|
|
|
|
config.platforms[Platform.HOMEASSISTANT].token = hass_token
|
|
|
|
|
hass_url = os.getenv("HASS_URL")
|
|
|
|
|
if hass_url:
|
|
|
|
|
config.platforms[Platform.HOMEASSISTANT].extra["url"] = hass_url
|
|
|
|
|
|
feat: add email gateway platform (IMAP/SMTP)
Allow users to interact with Hermes by sending and receiving emails.
Uses IMAP polling for incoming messages and SMTP for replies with
proper threading (In-Reply-To, References headers).
Integrates with all 14 gateway extension points: config, adapter
factory, authorization, send_message tool, cron delivery, toolsets,
prompt hints, channel directory, setup wizard, status display, and
env example.
65 tests covering config, parsing, dispatch, threading, IMAP fetch,
SMTP send, attachments, and all integration points.
2026-03-10 03:15:38 +03:00
|
|
|
# Email
|
|
|
|
|
email_addr = os.getenv("EMAIL_ADDRESS")
|
|
|
|
|
email_pwd = os.getenv("EMAIL_PASSWORD")
|
|
|
|
|
email_imap = os.getenv("EMAIL_IMAP_HOST")
|
|
|
|
|
email_smtp = os.getenv("EMAIL_SMTP_HOST")
|
|
|
|
|
if all([email_addr, email_pwd, email_imap, email_smtp]):
|
|
|
|
|
if Platform.EMAIL not in config.platforms:
|
|
|
|
|
config.platforms[Platform.EMAIL] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.EMAIL].enabled = True
|
|
|
|
|
config.platforms[Platform.EMAIL].extra.update({
|
|
|
|
|
"address": email_addr,
|
|
|
|
|
"imap_host": email_imap,
|
|
|
|
|
"smtp_host": email_smtp,
|
|
|
|
|
})
|
|
|
|
|
email_home = os.getenv("EMAIL_HOME_ADDRESS")
|
|
|
|
|
if email_home:
|
|
|
|
|
config.platforms[Platform.EMAIL].home_channel = HomeChannel(
|
|
|
|
|
platform=Platform.EMAIL,
|
|
|
|
|
chat_id=email_home,
|
|
|
|
|
name=os.getenv("EMAIL_HOME_ADDRESS_NAME", "Home"),
|
|
|
|
|
)
|
|
|
|
|
|
feat: add SMS (Twilio) platform adapter
Add SMS as a first-class messaging platform via the Twilio API.
Shares credentials with the existing telephony skill — same
TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER env vars.
Adapter (gateway/platforms/sms.py):
- aiohttp webhook server for inbound (Twilio form-encoded POSTs)
- Twilio REST API with Basic auth for outbound
- Markdown stripping, smart chunking at 1600 chars
- Echo loop prevention, phone number redaction in logs
Integration (13 files):
- gateway config, run, channel_directory
- agent prompt_builder (SMS platform hint)
- cron scheduler, cronjob tools
- send_message_tool (_send_sms via Twilio API)
- toolsets (hermes-sms + hermes-gateway)
- gateway setup wizard, status display
- pyproject.toml (sms optional extra)
- 21 tests
Docs:
- website/docs/user-guide/messaging/sms.md (full setup guide)
- Updated messaging index (architecture, toolsets, security, links)
- Updated environment-variables.md reference
Inspired by PR #1575 (@sunsakis), rewritten for Twilio.
2026-03-17 03:14:53 -07:00
|
|
|
# SMS (Twilio)
|
|
|
|
|
twilio_sid = os.getenv("TWILIO_ACCOUNT_SID")
|
|
|
|
|
if twilio_sid:
|
|
|
|
|
if Platform.SMS not in config.platforms:
|
|
|
|
|
config.platforms[Platform.SMS] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.SMS].enabled = True
|
|
|
|
|
config.platforms[Platform.SMS].api_key = os.getenv("TWILIO_AUTH_TOKEN", "")
|
|
|
|
|
sms_home = os.getenv("SMS_HOME_CHANNEL")
|
|
|
|
|
if sms_home:
|
|
|
|
|
config.platforms[Platform.SMS].home_channel = HomeChannel(
|
|
|
|
|
platform=Platform.SMS,
|
|
|
|
|
chat_id=sms_home,
|
|
|
|
|
name=os.getenv("SMS_HOME_CHANNEL_NAME", "Home"),
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
# Session settings
|
|
|
|
|
idle_minutes = os.getenv("SESSION_IDLE_MINUTES")
|
|
|
|
|
if idle_minutes:
|
|
|
|
|
try:
|
|
|
|
|
config.default_reset_policy.idle_minutes = int(idle_minutes)
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
reset_hour = os.getenv("SESSION_RESET_HOUR")
|
|
|
|
|
if reset_hour:
|
|
|
|
|
try:
|
|
|
|
|
config.default_reset_policy.at_hour = int(reset_hour)
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def save_gateway_config(config: GatewayConfig) -> None:
|
|
|
|
|
"""Save gateway configuration to ~/.hermes/gateway.json."""
|
fix(cli): respect HERMES_HOME in all remaining hardcoded ~/.hermes paths
Several files resolved paths via Path.home() / ".hermes" or
os.path.expanduser("~/.hermes/..."), bypassing the HERMES_HOME
environment variable. This broke isolation when running multiple
Hermes instances with distinct HERMES_HOME directories.
Replace all hardcoded paths with calls to get_hermes_home() from
hermes_cli.config, consistent with the rest of the codebase.
Files fixed:
- tools/process_registry.py (processes.json)
- gateway/pairing.py (pairing/)
- gateway/sticker_cache.py (sticker_cache.json)
- gateway/channel_directory.py (channel_directory.json, sessions.json)
- gateway/config.py (gateway.json, config.yaml, sessions_dir)
- gateway/mirror.py (sessions/)
- gateway/hooks.py (hooks/)
- gateway/platforms/base.py (image_cache/, audio_cache/, document_cache/)
- gateway/platforms/whatsapp.py (whatsapp/session)
- gateway/delivery.py (cron/output)
- agent/auxiliary_client.py (auth.json)
- agent/prompt_builder.py (SOUL.md)
- cli.py (config.yaml, images/, pastes/, history)
- run_agent.py (logs/)
- tools/environments/base.py (sandboxes/)
- tools/environments/modal.py (modal_snapshots.json)
- tools/environments/singularity.py (singularity_snapshots.json)
- tools/tts_tool.py (audio_cache)
- hermes_cli/status.py (cron/jobs.json, sessions.json)
- hermes_cli/gateway.py (logs/, whatsapp session)
- hermes_cli/main.py (whatsapp/session)
Tests updated to use HERMES_HOME env var instead of patching Path.home().
Closes #892
(cherry picked from commit 78ac1bba43b8b74a934c6172f2c29bb4d03164b9)
2026-03-11 07:31:41 +01:00
|
|
|
gateway_config_path = get_hermes_home() / "gateway.json"
|
2026-02-02 19:01:51 -08:00
|
|
|
gateway_config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
2026-03-05 17:04:33 -05:00
|
|
|
with open(gateway_config_path, "w", encoding="utf-8") as f:
|
2026-02-02 19:01:51 -08:00
|
|
|
json.dump(config.to_dict(), f, indent=2)
|