Compare commits

..
Author SHA1 Message Date
Ben Barclay 0d814cd115 refactor: update github-code-review skill to use MCP tools
Replace gh CLI and curl-based GitHub API interactions with native
GitHub MCP tools (mcp_github_*). This modernizes the skill to use
the agent's built-in MCP integration for all GitHub operations.

Key changes:
- Replace gh CLI commands with mcp_github_pull_request_read(),
  mcp_github_pull_request_review_write(), etc.
- Replace curl API calls with mcp_github_add_issue_comment(),
  mcp_github_add_comment_to_pending_review(), etc.
- Add mcp_github_run_secret_scanning() to security checklist
- Add mcp_github_request_copilot_review() as optional step
- Add quick reference table mapping tasks to MCP tools
- Keep git CLI for local diff operations (unchanged)
- Bump version to 2.0.0
2026-04-12 18:12:35 +10:00
Teknium c52f6348b6 fix: list all available toolsets in delegate_task schema description (#8231)
* fix: list all available toolsets in delegate_task schema description

The delegate_task tool's toolsets parameter description only mentioned
'terminal', 'file', and 'web' as examples. Models (especially smaller
ones like Gemma) would substitute 'web' for 'browser' because they
didn't know 'browser' was a valid option.

Now dynamically builds the toolset list from the TOOLSETS dict at import
time, excluding blocked, composite, and platform-specific toolsets.
Auto-updates when new toolsets are added.

Reported by jeffutter on Discord.

* chore: exclude moa and rl from delegate_task toolset list
2026-04-12 00:54:35 -07:00
Teknium 3162472674 feat(tips): add 69 deeper hidden-gem tips (279 total) (#8237)
Add lesser-known power-user tips covering:
- BOOT.md gateway startup automation
- Cron script attachment for data collection pipelines
- Prefill messages for few-shot priming
- Focus topic compression (/compress <topic>)
- Terminal exit code annotations and auto-retry
- Automatic sudo password piping
- execute_code built-in helpers (json_parse, shell_quote, retry)
- File loop detection and staleness warnings
- MCP sampling and dynamic tool discovery
- Delegation heartbeat and ACP child agents (Claude Code)
- 402 auto-fallback in auxiliary client
- Container mode, HERMES_HOME_MODE, subprocess HOME isolation
- Ctrl+C 5-tier priority system
- Browser CDP URL override and stealth mode
- Skills quarantine, audit log, and well-known protocol
- Per-platform display overrides, human delay mode
- And many more deep-cut features
2026-04-12 00:54:07 -07:00
Teknium 8b9d22a74b revert: keep debian:13.4 full image instead of slim
The slim image drops packages that may be needed at runtime.
Keep the full Debian base for compatibility.
2026-04-12 00:53:16 -07:00
m0n5t3r fee0e0d35e fix(docker): run as non-root user, use virtualenv (salvage #5811)
- Add gosu for runtime privilege dropping from root to hermes user
- Support HERMES_UID/HERMES_GID env vars for host mount permission matching
- Switch to debian:13.4-slim base image
- Use uv venv instead of pip install --break-system-packages
- Pin uv and gosu multi-stage images with SHA256 digests
- Set PLAYWRIGHT_BROWSERS_PATH to /opt/hermes/.playwright so build-time
  chromium install survives the /opt/data volume mount
- Keep procps for container debugging

Based on work by m0n5t3r in PR #5811. Stripped to hardening-only
changes (non-root, virtualenv, slim base); matrix deps, fonts, xvfb,
and entrypoint playwright download deferred to follow-up.
2026-04-12 00:53:16 -07:00
bravohenry 81ac62c0e9 fix(weixin): split chatty short replies into separate bubbles, keep structured content together
Add content-aware splitting to compact mode: short chat-like exchanges
(2-6 short lines without headings/lists/quotes) get separate message
bubbles for a natural chat feel, while structured content (tables,
headings with body, numbered lists) stays in a single message.

Cherry-picked from PR #7587 by bravohenry, adapted to the compact/legacy
split_per_line architecture from #7903.
2026-04-12 00:38:07 -07:00
Teknium f53a5a7fe1 fix: suppress duplicate completion notifications when agent already consumed output via wait/poll/log (#8228)
When the agent calls process(action='wait') or process(action='poll')
and gets the exited status, the completion_queue notification is
redundant — the agent already has the output from the tool return.
Previously, the drain loops in CLI and gateway would still inject
the [SYSTEM: Background process completed] message, causing the
agent to receive the same information twice.

Fix: track session IDs in _completion_consumed set when wait/poll/log
returns an exited process. Drain loops in cli.py and gateway watcher
skip completion events for consumed sessions. Watch pattern events
are never suppressed (they have independent semantics).

Adds 4 tests covering wait/poll/log marking and running-process
negative case.
2026-04-12 00:36:22 -07:00
Teknium fdf55e0fe9 feat(cli): show random tip on new session start (#8225)
Add a 'tip of the day' feature that displays a random one-liner about
Hermes Agent features on every new session — CLI startup, /clear, /new,
and gateway /new across all messaging platforms.

- New hermes_cli/tips.py module with 210 curated tips covering slash
  commands, keybindings, CLI flags, config options, tools, gateway
  platforms, profiles, sessions, memory, skills, cron, voice, security,
  and more
- CLI: tips display in skin-aware dim gold color after the welcome line
- Gateway: tips append to the /new and /reset response on all platforms
- Fully wrapped in try/except — tips are non-critical and never break
  startup or reset

Display format (CLI):
  ✦ Tip: /btw <question> asks a quick side question without tools or history.

Display format (gateway):
  ✨ Session reset! Starting fresh.
  ✦ Tip: hermes -c resumes your most recent CLI session.
2026-04-12 00:34:01 -07:00
opriz 36f57dbc51 fix(migration): don't auto-archive OpenClaw source directory
Remove auto-archival from hermes claw migrate — not its
responsibility (hermes claw cleanup is still there for that).

Skip MESSAGING_CWD when it points inside the OpenClaw source
directory, which was the actual root cause of agent confusion
after migration. Use Path.is_relative_to() for robust path
containment check.

Salvaged from PR #8192 by opriz.
Co-authored-by: opriz <opriz@users.noreply.github.com>
2026-04-12 00:33:54 -07:00
Teknium 1871227198 feat: rebrand OpenClaw references to Hermes during migration
- Add rebrand_text() that replaces OpenClaw, Open Claw, Open-Claw,
  ClawdBot, and MoltBot with Hermes (case-insensitive, word-boundary)
- Apply rebranding to memory entries (MEMORY.md, USER.md, daily memory)
- Apply rebranding to SOUL.md and workspace instructions via new
  transform parameter on copy_file()
- Fix moldbot -> moltbot typo across codebase (claw.py, migration
  script, docs, tests)
- Add unit tests for rebrand_text and integration tests for memory
  and soul migration rebranding
2026-04-12 00:33:54 -07:00
Teknium eb2a49f95a fix: openai-codex and anthropic not appearing in /model picker for external credentials (#8224)
Users whose credentials exist only in external files — OpenAI Codex
OAuth tokens in ~/.codex/auth.json or Anthropic Claude Code credentials
in ~/.claude/.credentials.json — would not see those providers in the
/model picker, even though hermes auth and hermes model detected them.

Root cause: list_authenticated_providers() only checked the raw Hermes
auth store and env vars. External credential file fallbacks (Codex CLI
import, Claude Code file discovery) were never triggered.

Fix (three parts):
1. _seed_from_singletons() in credential_pool.py: openai-codex now
   imports from ~/.codex/auth.json when the Hermes auth store is empty,
   mirroring resolve_codex_runtime_credentials().
2. list_authenticated_providers() in model_switch.py: auth store + pool
   checks now run for ALL providers (not just OAuth auth_type), catching
   providers like anthropic that support both API key and OAuth.
3. list_authenticated_providers(): direct check for anthropic external
   credential files (Claude Code, Hermes PKCE). The credential pool
   intentionally gates anthropic behind is_provider_explicitly_configured()
   to prevent auxiliary tasks from silently consuming tokens. The /model
   picker bypasses this gate since it is discovery-oriented.
2026-04-12 00:33:42 -07:00
Teknium 73f970fa4d fix: make gateway interrupt detection resilient to monitor task failures
The interrupt mechanism for regular text messages (non-commands) during
active agent runs relied on a single async polling task
(monitor_for_interrupt) with no error handling. If this task died
silently due to an unhandled exception, stale adapter reference after
reconnect, or any other failure, user messages sent during agent
execution would be queued but never trigger an actual interrupt — the
agent would continue running until it finished naturally, then process
the queued message.

Three improvements:

1. Error handling in monitor_for_interrupt(): wrap the polling body in
   try/except so transient errors are logged and retried instead of
   silently killing the task.

2. Fresh adapter reference on each poll iteration: re-resolve
   self.adapters.get(source.platform) every 200ms instead of capturing
   the adapter once at task creation time. This prevents stale
   references after adapter reconnects.

3. Backup interrupt check in the inactivity poll loop: both the
   unlimited and timeout-enabled paths now check for pending interrupts
   every 5 seconds (the existing poll interval). Uses a shared
   _interrupt_detected asyncio.Event to avoid double-firing when the
   primary monitor already handled the interrupt. Logs at INFO level
   with monitor task state for debugging.
2026-04-12 00:25:05 -07:00
Teknium 4cadfef8e3 fix(cli): restore stacked tool progress scrollback in TUI (#8201)
The TUI transition (4970705, f83e86d) replaced stacked per-tool history
lines with a single live-updating spinner widget. While the spinner
provides a nice live timer, it removed the scrollback history that
users relied on to see what the agent did during a session.

This restores stacked tool progress lines in 'all' and 'new' modes by
printing persistent scrollback lines via _cprint() when tools complete,
in addition to the existing live spinner display.

Behavior per mode:
- off: no scrollback lines, no spinner (unchanged)
- new: scrollback line on completion, skipping consecutive same-tool repeats
- all: scrollback line on every tool completion
- verbose: no scrollback (run_agent.py handles verbose output directly)

Implementation:
- Store function_args from tool.started events in _pending_tool_info
- On tool.completed, pop stored args and format via get_cute_tool_message()
- FIFO queue per function_name handles concurrent tool execution
- 'new' mode tracks _last_scrollback_tool for dedup
- State cleared at end of agent run

Reported by community user Mr.D — the stacked history provides
transparency into what the agent is doing, which builds trust.

Addresses user report from Discord about lost tool call visibility.
2026-04-11 23:22:34 -07:00
Teknium 8e00b3a69e fix(cron): steer model away from explicit deliver targets that lose topic context (#8187)
Rewrite the cronjob tool's 'deliver' parameter description to strongly
guide models toward omitting the parameter (which auto-detects origin
including thread/topic). The previous description listed all platform
names equally, inviting models to construct explicit targets like
'telegram:<chat_id>' which silently drops the thread_id.

New description:
- Leads with 'Omit this parameter' as the recommended path
- Explicitly warns that platform:chat_id without :thread_id loses topics
- Removes the long flat list of platform names that invited construction

Also adds diagnostic logging at two key points:
- _origin_from_env(): logs when thread_id is captured during job creation
- _deliver_result(): warns when origin has thread_id but delivery target
  lost it; logs at debug when delivering to a specific thread

Helps diagnose user-reported issue where cron responses from Telegram
topics are delivered to the main chat instead of the originating topic.
2026-04-11 23:20:39 -07:00
Teknium 1ca9b19750 feat: add network.force_ipv4 config to fix IPv6 timeout issues (#8196)
On servers with broken or unreachable IPv6, Python's socket.getaddrinfo
returns AAAA records first. urllib/httpx/requests all try IPv6 connections
first and hang for the full TCP timeout before falling back to IPv4. This
affects web_extract, web_search, the OpenAI SDK, and all HTTP tools.

Adds network.force_ipv4 config option (default: false) that monkey-patches
socket.getaddrinfo to resolve as AF_INET when the caller didn't specify a
family. Falls back to full resolution if no A record exists, so pure-IPv6
hosts still work.

Applied early at all three entry points (CLI, gateway, cron scheduler)
before any HTTP clients are created.

Reported by user @29n — Chinese Ubuntu server with unreachable IPv6 causing
timeouts on lobste.rs and other IPv6-enabled sites while Google/GitHub
worked fine (IPv4-only resolution).
2026-04-11 23:12:11 -07:00
Teknium 1cec910b6a fix: improve context compaction to prevent model answering stale questions (#8107)
After compression, models (especially Kimi 2.5) would sometimes respond
to questions from the summary instead of the latest user message. This
happened ~30% of the time on Telegram.

Root cause: the summary's 'Next Steps' section read as active instructions,
and the SUMMARY_PREFIX didn't explicitly tell the model to ignore questions
in the summary. When the summary merged into the first tail message, there
was no clear separator between historical context and the actual user message.

Changes inspired by competitor analysis (Claude Code, OpenCode, Codex):

1. SUMMARY_PREFIX rewritten with explicit 'Do NOT answer questions from
   this summary — respond ONLY to the latest user message AFTER it'

2. Summarizer preamble (shared by both prompts) adds:
   - 'Do NOT respond to any questions' (from OpenCode's approach)
   - 'Different assistant' framing (from Codex) to create psychological
     distance between summary content and active conversation

3. New summary sections:
   - '## Resolved Questions' — tracks already-answered questions with
     their answers, preventing re-answering (from Claude Code's
     'Pending user asks' pattern)
   - '## Pending User Asks' — explicitly marks unanswered questions
   - '## Remaining Work' replaces '## Next Steps' — passive framing
     avoids reading as active instructions

4. merge-summary-into-tail path now inserts a clear separator:
   '--- END OF CONTEXT SUMMARY — respond to the message below ---'

5. Iterative update prompt now instructs: 'Move answered questions to
   Resolved Questions' to maintain the resolved/pending distinction
   across multiple compactions.
2026-04-11 19:43:58 -07:00
Tom QiaoandClaude Opus 4.6 8a48c58bd3 fix(gateway): add missing RedactingFormatter import
The gateway startup path references RedactingFormatter without
importing it, causing a NameError crash when launched with a
verbosity flag (e.g. via launchd --replace).

Fixes #8044

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 19:38:05 -07:00
Teknium a0a02c1bc0 feat: /compress <focus> — guided compression with focus topic (#8017)
Adds an optional focus topic to /compress: `/compress database schema`
guides the summariser to preserve information related to the focus topic
(60-70% of summary budget) while compressing everything else more aggressively.
Inspired by Claude Code's /compact <focus>.

Changes:
- context_compressor.py: focus_topic parameter on _generate_summary() and
  compress(); appends FOCUS TOPIC guidance block to the LLM prompt
- run_agent.py: focus_topic parameter on _compress_context(), passed through
  to the compressor
- cli.py: _manual_compress() extracts focus topic from command string,
  preserves existing manual_compression_feedback integration (no regression)
- gateway/run.py: _handle_compress_command() extracts focus from event args
  and passes through — full gateway parity
- commands.py: args_hint="[focus topic]" on /compress CommandDef

Salvaged from PR #7459 (CLI /compress focus only — /context command deferred).
15 new tests across CLI, compressor, and gateway.
2026-04-11 19:23:29 -07:00
58 changed files with 2593 additions and 2687 deletions
+23 -6
View File
@@ -1,27 +1,44 @@
FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source
FROM tianon/gosu:1.19-trixie@sha256:3b176695959c71e123eb390d427efc665eeb561b1540e82679c15e992006b8b9 AS gosu_source
FROM debian:13.4
# Disable Python stdout buffering to ensure logs are printed immediately
ENV PYTHONUNBUFFERED=1
# Store Playwright browsers outside the volume mount so the build-time
# install survives the /opt/data volume overlay at runtime.
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
# Install system dependencies in one layer, clear APT cache
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential nodejs npm python3 python3-pip ripgrep ffmpeg gcc python3-dev libffi-dev procps && \
build-essential nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps && \
rm -rf /var/lib/apt/lists/*
# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime
RUN useradd -u 10000 -m -d /opt/data hermes
COPY --chmod=0755 --from=gosu_source /gosu /usr/local/bin/
COPY --chmod=0755 --from=uv_source /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/
COPY . /opt/hermes
WORKDIR /opt/hermes
# Install Python and Node dependencies in one layer, no cache
RUN pip install --no-cache-dir uv --break-system-packages && \
uv pip install --system --break-system-packages --no-cache -e ".[all]" && \
npm install --prefer-offline --no-audit && \
# Install Node dependencies and Playwright as root (--with-deps needs apt)
RUN npm install --prefer-offline --no-audit && \
npx playwright install --with-deps chromium --only-shell && \
cd /opt/hermes/scripts/whatsapp-bridge && \
npm install --prefer-offline --no-audit && \
npm cache clean --force
WORKDIR /opt/hermes
# Hand ownership to hermes user, then install Python deps in a virtualenv
RUN chown -R hermes:hermes /opt/hermes
USER hermes
RUN uv venv && \
uv pip install --no-cache-dir -e ".[all]"
USER root
RUN chmod +x /opt/hermes/docker/entrypoint.sh
ENV HERMES_HOME=/opt/data
+103 -71
View File
@@ -4,8 +4,12 @@ Self-contained class with its own OpenAI client for summarization.
Uses auxiliary model (cheap/fast) to summarize middle turns while
protecting head and tail context.
Improvements over v1:
- Structured summary template (Goal, Progress, Decisions, Files, Next Steps)
Improvements over v2:
- Structured summary template with Resolved/Pending question tracking
- Summarizer preamble: "Do not respond to any questions" (from OpenCode)
- Handoff framing: "different assistant" (from Codex) to create separation
- "Remaining Work" replaces "Next Steps" to avoid reading as active instructions
- Clear separator when summary merges into tail message
- Iterative summary updates (preserves info across multiple compactions)
- Token-budget tail protection instead of fixed message count
- Tool output pruning before LLM summarization (cheap pre-pass)
@@ -28,12 +32,13 @@ from agent.model_metadata import (
logger = logging.getLogger(__name__)
SUMMARY_PREFIX = (
"[CONTEXT COMPACTION] Earlier turns in this conversation were compacted "
"to save context space. The summary below describes work that was "
"already completed, and the current session state may still reflect "
"that work (for example, files may already be changed). Use the summary "
"and the current state to continue from where things left off, and "
"avoid repeating work:"
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
"into the summary below. This is a handoff from a previous context "
"window — treat it as background reference, NOT as active instructions. "
"Do NOT answer questions or fulfill requests mentioned in this summary; "
"they were already addressed. Respond ONLY to the latest user message "
"that appears AFTER this summary. The current session state (files, "
"config, etc.) may reflect work described here — avoid repeating it:"
)
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
@@ -306,13 +311,20 @@ class ContextCompressor(ContextEngine):
return "\n\n".join(parts)
def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]]) -> Optional[str]:
def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]], focus_topic: str = None) -> Optional[str]:
"""Generate a structured summary of conversation turns.
Uses a structured template (Goal, Progress, Decisions, Files, Next Steps)
inspired by Pi-mono and OpenCode. When a previous summary exists,
Uses a structured template (Goal, Progress, Decisions, Resolved/Pending
Questions, Files, Remaining Work) with explicit preamble telling the
summarizer not to answer questions. When a previous summary exists,
generates an iterative update instead of summarizing from scratch.
Args:
focus_topic: Optional focus string for guided compression. When
provided, the summariser prioritises preserving information
related to this topic and is more aggressive about compressing
everything else. Inspired by Claude Code's ``/compact``.
Returns None if all attempts fail — the caller should drop
the middle turns without a summary rather than inject a useless
placeholder.
@@ -328,60 +340,27 @@ class ContextCompressor(ContextEngine):
summary_budget = self._compute_summary_budget(turns_to_summarize)
content_to_summarize = self._serialize_for_summary(turns_to_summarize)
if self._previous_summary:
# Iterative update: preserve existing info, add new progress
prompt = f"""You are updating a context compaction summary. A previous compaction produced the summary below. New conversation turns have occurred since then and need to be incorporated.
# Preamble shared by both first-compaction and iterative-update prompts.
# Inspired by OpenCode's "do not respond to any questions" instruction
# and Codex's "another language model" framing.
_summarizer_preamble = (
"You are a summarization agent creating a context checkpoint. "
"Your output will be injected as reference material for a DIFFERENT "
"assistant that continues the conversation. "
"Do NOT respond to any questions or requests in the conversation — "
"only output the structured summary. "
"Do NOT include any preamble, greeting, or prefix."
)
PREVIOUS SUMMARY:
{self._previous_summary}
NEW TURNS TO INCORPORATE:
{content_to_summarize}
Update the summary using this exact structure. PRESERVE all existing information that is still relevant. ADD new progress. Move items from "In Progress" to "Done" when completed. Remove information only if it is clearly obsolete.
## Goal
[What the user is trying to accomplish — preserve from previous summary, update if goal evolved]
## Constraints & Preferences
[User preferences, coding style, constraints, important decisions — accumulate across compactions]
## Progress
### Done
[Completed work — include specific file paths, commands run, results obtained]
### In Progress
[Work currently underway]
### Blocked
[Any blockers or issues encountered]
## Key Decisions
[Important technical decisions and why they were made]
## Relevant Files
[Files read, modified, or created — with brief note on each. Accumulate across compactions.]
## Next Steps
[What needs to happen next to continue the work]
## Critical Context
[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation]
## Tools & Patterns
[Which tools were used, how they were used effectively, and any tool-specific discoveries. Accumulate across compactions.]
Target ~{summary_budget} tokens. Be specific — include file paths, command outputs, error messages, and concrete values rather than vague descriptions.
Write only the summary body. Do not include any preamble or prefix."""
else:
# First compaction: summarize from scratch
prompt = f"""Create a structured handoff summary for a later assistant that will continue this conversation after earlier turns are compacted.
TURNS TO SUMMARIZE:
{content_to_summarize}
Use this exact structure:
## Goal
# Shared structured template (used by both paths).
# Key changes vs v1:
# - "Pending User Asks" section (from Claude Code) explicitly tracks
# unanswered questions so the model knows what's resolved vs open
# - "Remaining Work" replaces "Next Steps" to avoid reading as active
# instructions
# - "Resolved Questions" makes it clear which questions were already
# answered (prevents model from re-answering them)
_template_sections = f"""## Goal
[What the user is trying to accomplish]
## Constraints & Preferences
@@ -398,22 +377,64 @@ Use this exact structure:
## Key Decisions
[Important technical decisions and why they were made]
## Resolved Questions
[Questions the user asked that were ALREADY answered — include the answer so the next assistant does not re-answer them]
## Pending User Asks
[Questions or requests from the user that have NOT yet been answered or fulfilled. If none, write "None."]
## Relevant Files
[Files read, modified, or created — with brief note on each]
## Next Steps
[What needs to happen next to continue the work]
## Remaining Work
[What remains to be done — framed as context, not instructions]
## Critical Context
[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation]
## Tools & Patterns
[Which tools were used, how they were used effectively, and any tool-specific discoveries (e.g., preferred flags, working invocations, successful command patterns)]
[Which tools were used, how they were used effectively, and any tool-specific discoveries]
Target ~{summary_budget} tokens. Be specific — include file paths, command outputs, error messages, and concrete values rather than vague descriptions. The goal is to prevent the next assistant from repeating work or losing important details.
Target ~{summary_budget} tokens. Be specific — include file paths, command outputs, error messages, and concrete values rather than vague descriptions.
Write only the summary body. Do not include any preamble or prefix."""
if self._previous_summary:
# Iterative update: preserve existing info, add new progress
prompt = f"""{_summarizer_preamble}
You are updating a context compaction summary. A previous compaction produced the summary below. New conversation turns have occurred since then and need to be incorporated.
PREVIOUS SUMMARY:
{self._previous_summary}
NEW TURNS TO INCORPORATE:
{content_to_summarize}
Update the summary using this exact structure. PRESERVE all existing information that is still relevant. ADD new progress. Move items from "In Progress" to "Done" when completed. Move answered questions to "Resolved Questions". Remove information only if it is clearly obsolete.
{_template_sections}"""
else:
# First compaction: summarize from scratch
prompt = f"""{_summarizer_preamble}
Create a structured handoff summary for a different assistant that will continue this conversation after earlier turns are compacted. The next assistant should be able to understand what happened without re-reading the original turns.
TURNS TO SUMMARIZE:
{content_to_summarize}
Use this exact structure:
{_template_sections}"""
# Inject focus topic guidance when the user provides one via /compress <focus>.
# This goes at the end of the prompt so it takes precedence.
if focus_topic:
prompt += f"""
FOCUS TOPIC: "{focus_topic}"
The user has requested that this compaction PRIORITISE preserving all information related to the focus topic above. For content related to "{focus_topic}", include full detail — exact values, file paths, command outputs, error messages, and decisions. For content NOT related to the focus topic, summarise more aggressively (brief one-liners or omit if truly irrelevant). The focus topic sections should receive roughly 60-70% of the summary token budget."""
try:
call_kwargs = {
"task": "compression",
@@ -631,7 +652,7 @@ Write only the summary body. Do not include any preamble or prefix."""
# Main compression entry point
# ------------------------------------------------------------------
def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None) -> List[Dict[str, Any]]:
def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, focus_topic: str = None) -> List[Dict[str, Any]]:
"""Compress conversation messages by summarizing middle turns.
Algorithm:
@@ -643,6 +664,12 @@ Write only the summary body. Do not include any preamble or prefix."""
After compression, orphaned tool_call / tool_result pairs are cleaned
up so the API never receives mismatched IDs.
Args:
focus_topic: Optional focus string for guided compression. When
provided, the summariser will prioritise preserving information
related to this topic and be more aggressive about compressing
everything else. Inspired by Claude Code's ``/compact``.
"""
n_messages = len(messages)
# Only need head + 3 tail messages minimum (token budget decides the real tail size)
@@ -700,7 +727,7 @@ Write only the summary body. Do not include any preamble or prefix."""
)
# Phase 3: Generate structured summary
summary = self._generate_summary(turns_to_summarize)
summary = self._generate_summary(turns_to_summarize, focus_topic=focus_topic)
# Phase 4: Assemble compressed message list
compressed = []
@@ -755,7 +782,12 @@ Write only the summary body. Do not include any preamble or prefix."""
msg = messages[i].copy()
if _merge_summary_into_tail and i == compress_end:
original = msg.get("content") or ""
msg["content"] = summary + "\n\n" + original
msg["content"] = (
summary
+ "\n\n--- END OF CONTEXT SUMMARY — "
"respond to the message below, not the summary above ---\n\n"
+ original
)
_merge_summary_into_tail = False
compressed.append(msg)
+17
View File
@@ -1128,6 +1128,23 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
elif provider == "openai-codex":
state = _load_provider_state(auth_store, "openai-codex")
tokens = state.get("tokens") if isinstance(state, dict) else None
# Fallback: import from Codex CLI (~/.codex/auth.json) if Hermes auth
# store has no tokens. This mirrors resolve_codex_runtime_credentials()
# so that load_pool() and list_authenticated_providers() detect tokens
# that only exist in the Codex CLI shared file.
if not (isinstance(tokens, dict) and tokens.get("access_token")):
try:
from hermes_cli.auth import _import_codex_cli_tokens, _save_codex_tokens
cli_tokens = _import_codex_cli_tokens()
if cli_tokens:
logger.info("Importing Codex CLI tokens into Hermes auth store.")
_save_codex_tokens(cli_tokens)
# Re-read state after import
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "openai-codex")
tokens = state.get("tokens") if isinstance(state, dict) else None
except Exception as exc:
logger.debug("Codex CLI token import failed: %s", exc)
if isinstance(tokens, dict) and tokens.get("access_token"):
active_sources.add("device_code")
changed |= _upsert_entry(
+103 -7
View File
@@ -1822,6 +1822,8 @@ class HermesCLI:
self._secret_deadline = 0
self._spinner_text: str = "" # thinking spinner text for TUI
self._tool_start_time: float = 0.0 # monotonic timestamp when current tool started (for live elapsed)
self._pending_tool_info: dict = {} # function_name -> list of (preview, args) for stacked scrollback
self._last_scrollback_tool: str = "" # last tool name printed to scrollback (for "new" dedup)
self._command_running = False
self._command_status = ""
self._attached_images: list[Path] = []
@@ -5242,9 +5244,33 @@ class HermesCLI:
context_length=ctx_len,
)
_cprint(" ✨ (◕‿◕)✨ Fresh start! Screen cleared and conversation reset.\n")
# Show a random tip on new session
try:
from hermes_cli.tips import get_random_tip
_tip = get_random_tip()
try:
from hermes_cli.skin_engine import get_active_skin
_tip_color = get_active_skin().get_color("banner_dim", "#B8860B")
except Exception:
_tip_color = "#B8860B"
cc.print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]")
except Exception:
pass
else:
self.show_banner()
print(" ✨ (◕‿◕)✨ Fresh start! Screen cleared and conversation reset.\n")
# Show a random tip on new session
try:
from hermes_cli.tips import get_random_tip
_tip = get_random_tip()
try:
from hermes_cli.skin_engine import get_active_skin
_tip_color = get_active_skin().get_color("banner_dim", "#B8860B")
except Exception:
_tip_color = "#B8860B"
self.console.print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]")
except Exception:
pass
elif canonical == "history":
self.show_history()
elif canonical == "title":
@@ -5344,7 +5370,7 @@ class HermesCLI:
elif canonical == "fast":
self._handle_fast_command(cmd_original)
elif canonical == "compress":
self._manual_compress()
self._manual_compress(cmd_original)
elif canonical == "usage":
self._show_usage()
elif canonical == "insights":
@@ -6201,8 +6227,14 @@ class HermesCLI:
self._reasoning_preview_buf = getattr(self, "_reasoning_preview_buf", "") + reasoning_text
self._flush_reasoning_preview(force=False)
def _manual_compress(self):
"""Manually trigger context compression on the current conversation."""
def _manual_compress(self, cmd_original: str = ""):
"""Manually trigger context compression on the current conversation.
Accepts an optional focus topic: ``/compress <focus>`` guides the
summariser to preserve information related to *focus* while being
more aggressive about discarding everything else. Inspired by
Claude Code's ``/compact <focus>`` feature.
"""
if not self.conversation_history or len(self.conversation_history) < 4:
print("(._.) Not enough conversation to compress (need at least 4 messages).")
return
@@ -6215,18 +6247,30 @@ class HermesCLI:
print("(._.) Compression is disabled in config.")
return
# Extract optional focus topic from the command (e.g. "/compress database schema")
focus_topic = ""
if cmd_original:
parts = cmd_original.strip().split(None, 1)
if len(parts) > 1:
focus_topic = parts[1].strip()
original_count = len(self.conversation_history)
try:
from agent.model_metadata import estimate_messages_tokens_rough
from agent.manual_compression_feedback import summarize_manual_compression
original_history = list(self.conversation_history)
approx_tokens = estimate_messages_tokens_rough(original_history)
print(f"🗜️ Compressing {original_count} messages (~{approx_tokens:,} tokens)...")
if focus_topic:
print(f"🗜️ Compressing {original_count} messages (~{approx_tokens:,} tokens), "
f"focus: \"{focus_topic}\"...")
else:
print(f"🗜️ Compressing {original_count} messages (~{approx_tokens:,} tokens)...")
compressed, _ = self.agent._compress_context(
original_history,
self.agent._cached_system_prompt or "",
approx_tokens=approx_tokens,
focus_topic=focus_topic or None,
)
self.conversation_history = compressed
new_tokens = estimate_messages_tokens_rough(self.conversation_history)
@@ -6542,10 +6586,36 @@ class HermesCLI:
On tool.started, records a monotonic timestamp so get_spinner_text()
can show a live elapsed timer (the TUI poll loop already invalidates
every ~0.15s, so the counter updates automatically).
When tool_progress_mode is "all" or "new", also prints a persistent
stacked line to scrollback on tool.completed so users can see the
full history of tool calls (not just the current one in the spinner).
"""
if event_type == "tool.completed":
import time as _time
self._tool_start_time = 0.0
# Print stacked scrollback line for "all" / "new" modes
if function_name and self.tool_progress_mode in ("all", "new"):
duration = kwargs.get("duration", 0.0)
is_error = kwargs.get("is_error", False)
# Pop stored args from tool.started for this function
stored = self._pending_tool_info.get(function_name)
stored_args = stored.pop(0) if stored else {}
if stored is not None and not stored:
del self._pending_tool_info[function_name]
# "new" mode: skip consecutive repeats of the same tool
if self.tool_progress_mode == "new" and function_name == self._last_scrollback_tool:
self._invalidate()
return
self._last_scrollback_tool = function_name
try:
from agent.display import get_cute_tool_message
line = get_cute_tool_message(function_name, stored_args, duration)
if is_error:
line = f"{line} [error]"
_cprint(f" {line}")
except Exception:
pass
self._invalidate()
return
if event_type != "tool.started":
@@ -6561,6 +6631,10 @@ class HermesCLI:
label = label[:_pl - 3] + "..."
self._spinner_text = f"{emoji} {label}"
self._tool_start_time = _time.monotonic()
# Store args for stacked scrollback line on completion
self._pending_tool_info.setdefault(function_name, []).append(
function_args if function_args is not None else {}
)
self._invalidate()
if not self._voice_mode:
@@ -8025,6 +8099,17 @@ class HermesCLI:
_welcome_text = "Welcome to Hermes Agent! Type your message or /help for commands."
_welcome_color = "#FFF8DC"
self.console.print(f"[{_welcome_color}]{_welcome_text}[/]")
# Show a random tip to help users discover features
try:
from hermes_cli.tips import get_random_tip
_tip = get_random_tip()
try:
_tip_color = _welcome_skin.get_color("banner_dim", "#B8860B")
except Exception:
_tip_color = "#B8860B"
self.console.print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]")
except Exception:
pass # Tips are non-critical — never break startup
if self.preloaded_skills and not self._startup_skills_line_shown:
skills_label = ", ".join(self.preloaded_skills)
self.console.print(
@@ -9300,9 +9385,14 @@ class HermesCLI:
from tools.process_registry import process_registry
if not process_registry.completion_queue.empty():
evt = process_registry.completion_queue.get_nowait()
_synth = _format_process_notification(evt)
if _synth:
self._pending_input.put(_synth)
# Skip if the agent already consumed this via wait/poll/log
_evt_sid = evt.get("session_id", "")
if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid):
pass # already delivered via tool result
else:
_synth = _format_process_notification(evt)
if _synth:
self._pending_input.put(_synth)
except Exception:
pass
continue
@@ -9401,6 +9491,8 @@ class HermesCLI:
self._agent_running = False
self._spinner_text = ""
self._tool_start_time = 0.0
self._pending_tool_info.clear()
self._last_scrollback_tool = ""
app.invalidate() # Refresh status line
@@ -9426,6 +9518,10 @@ class HermesCLI:
from tools.process_registry import process_registry
while not process_registry.completion_queue.empty():
evt = process_registry.completion_queue.get_nowait()
# Skip if the agent already consumed this via wait/poll/log
_evt_sid = evt.get("session_id", "")
if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid):
continue # already delivered via tool result
_synth = _format_process_notification(evt)
if _synth:
self._pending_input.put(_synth)
+44 -5
View File
@@ -219,14 +219,44 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
chat_id = target["chat_id"]
thread_id = target.get("thread_id")
# Diagnostic: log thread_id for topic-aware delivery debugging
origin = job.get("origin") or {}
origin_thread = origin.get("thread_id")
if origin_thread and not thread_id:
logger.warning(
"Job '%s': origin has thread_id=%s but delivery target lost it "
"(deliver=%s, target=%s)",
job["id"], origin_thread, job.get("deliver", "local"), target,
)
elif thread_id:
logger.debug(
"Job '%s': delivering to %s:%s thread_id=%s",
job["id"], platform_name, chat_id, thread_id,
)
from tools.send_message_tool import _send_to_platform
from gateway.config import load_gateway_config, Platform
# Accept any platform name — built-in names resolve to their enum
# member, plugin platform names create dynamic members via _missing_().
try:
platform = Platform(platform_name.lower())
except (ValueError, KeyError):
platform_map = {
"telegram": Platform.TELEGRAM,
"discord": Platform.DISCORD,
"slack": Platform.SLACK,
"whatsapp": Platform.WHATSAPP,
"signal": Platform.SIGNAL,
"matrix": Platform.MATRIX,
"mattermost": Platform.MATTERMOST,
"homeassistant": Platform.HOMEASSISTANT,
"dingtalk": Platform.DINGTALK,
"feishu": Platform.FEISHU,
"wecom": Platform.WECOM,
"wecom_callback": Platform.WECOM_CALLBACK,
"weixin": Platform.WEIXIN,
"email": Platform.EMAIL,
"sms": Platform.SMS,
"bluebubbles": Platform.BLUEBUBBLES,
}
platform = platform_map.get(platform_name.lower())
if not platform:
msg = f"unknown platform '{platform_name}'"
logger.warning("Job '%s': %s", job["id"], msg)
return msg
@@ -611,6 +641,15 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
except Exception as e:
logger.warning("Job '%s': failed to load config.yaml, using defaults: %s", job_id, e)
# Apply IPv4 preference if configured.
try:
from hermes_constants import apply_ipv4_preference
_net_cfg = _cfg.get("network", {})
if isinstance(_net_cfg, dict) and _net_cfg.get("force_ipv4"):
apply_ipv4_preference(force=True)
except Exception:
pass
# Reasoning config from config.yaml
from hermes_constants import parse_reasoning_effort
effort = str(_cfg.get("agent", {}).get("reasoning_effort", "")).strip()
+27
View File
@@ -5,6 +5,33 @@ set -e
HERMES_HOME="/opt/data"
INSTALL_DIR="/opt/hermes"
# --- Privilege dropping via gosu ---
# When started as root (the default), optionally remap the hermes user/group
# to match host-side ownership, fix volume permissions, then re-exec as hermes.
if [ "$(id -u)" = "0" ]; then
if [ -n "$HERMES_UID" ] && [ "$HERMES_UID" != "$(id -u hermes)" ]; then
echo "Changing hermes UID to $HERMES_UID"
usermod -u "$HERMES_UID" hermes
fi
if [ -n "$HERMES_GID" ] && [ "$HERMES_GID" != "$(id -g hermes)" ]; then
echo "Changing hermes GID to $HERMES_GID"
groupmod -g "$HERMES_GID" hermes
fi
actual_hermes_uid=$(id -u hermes)
if [ "$(stat -c %u "$HERMES_HOME" 2>/dev/null)" != "$actual_hermes_uid" ]; then
echo "$HERMES_HOME is not owned by $actual_hermes_uid, fixing"
chown -R hermes:hermes "$HERMES_HOME"
fi
echo "Dropping root privileges"
exec gosu hermes "$0" "$@"
fi
# --- Running as hermes from here ---
source "${INSTALL_DIR}/.venv/bin/activate"
# Create essential directory structure. Cache and platform directories
# (cache/images, cache/audio, platforms/whatsapp, etc.) are created on
# demand by the application — don't pre-create them here so new installs
+1 -1
View File
@@ -118,7 +118,7 @@ For executed migrations, the full report is saved to `~/.hermes/migration/opencl
## Troubleshooting
### "OpenClaw directory not found"
The migration looks for `~/.openclaw` by default, then tries `~/.clawdbot` and `~/.moldbot`. If your OpenClaw is installed elsewhere, use `--source`:
The migration looks for `~/.openclaw` by default, then tries `~/.clawdbot` and `~/.moltbot`. If your OpenClaw is installed elsewhere, use `--source`:
```bash
hermes claw migrate --source /path/to/.openclaw
```
-10
View File
@@ -86,16 +86,6 @@ def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
continue
platforms[plat_name] = _build_from_sessions(plat_name)
# Include plugin-registered platforms (dynamic enum members aren't in
# Platform.__members__, so the loop above misses them).
try:
from gateway.platform_registry import platform_registry
for entry in platform_registry.plugin_entries():
if entry.name not in _SKIP_SESSION_DISCOVERY and entry.name not in platforms:
platforms[entry.name] = _build_from_sessions(entry.name)
except Exception:
pass
directory = {
"updated_at": datetime.now().isoformat(),
"platforms": platforms,
+1 -40
View File
@@ -46,13 +46,7 @@ def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> st
class Platform(Enum):
"""Supported messaging platforms.
Built-in platforms have explicit members. Plugin platforms use dynamic
members created on-demand by ``_missing_()`` so that
``Platform("irc")`` works without modifying this enum. Dynamic members
are cached in ``_value2member_map_`` for identity-stable comparisons.
"""
"""Supported messaging platforms."""
LOCAL = "local"
TELEGRAM = "telegram"
DISCORD = "discord"
@@ -73,28 +67,6 @@ class Platform(Enum):
WEIXIN = "weixin"
BLUEBUBBLES = "bluebubbles"
@classmethod
def _missing_(cls, value):
"""Accept unknown platform names for plugin-registered adapters.
Creates a pseudo-member cached in ``_value2member_map_`` so that
``Platform("irc") is Platform("irc")`` holds True (identity-stable).
"""
if not isinstance(value, str) or not value.strip():
return None
# Normalise to lowercase to avoid case mismatches in config
value = value.strip().lower()
# Check cache first (another call may have created it already)
if value in cls._value2member_map_:
return cls._value2member_map_[value]
pseudo = object.__new__(cls)
pseudo._value_ = value
pseudo._name_ = value.upper().replace("-", "_").replace(" ", "_")
# Cache so future lookups return the same object
cls._value2member_map_[value] = pseudo
cls._member_map_[pseudo._name_] = pseudo
return pseudo
@dataclass
class HomeChannel:
@@ -331,17 +303,6 @@ class GatewayConfig:
# BlueBubbles uses extra dict for local server config
elif platform == Platform.BLUEBUBBLES and config.extra.get("server_url") and config.extra.get("password"):
connected.append(platform)
else:
# Plugin-registered platform — delegate validation to the
# registry entry's validate_config if available.
try:
from gateway.platform_registry import platform_registry
entry = platform_registry.get(platform.value)
if entry:
if entry.validate_config is None or entry.validate_config(config):
connected.append(platform)
except Exception:
pass # Registry not yet initialised during early import
return connected
def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]:
-196
View File
@@ -1,196 +0,0 @@
"""
Platform Adapter Registry
Allows platform adapters (built-in and plugin) to self-register so the gateway
can discover and instantiate them without hardcoded if/elif chains.
Built-in adapters continue to use the existing if/elif in _create_adapter()
for now. Plugin adapters register here via PluginContext.register_platform()
and are looked up first -- if nothing is found the gateway falls through to
the legacy code path.
Usage (plugin side):
from gateway.platform_registry import platform_registry, PlatformEntry
platform_registry.register(PlatformEntry(
name="irc",
label="IRC",
adapter_factory=lambda cfg: IRCAdapter(cfg),
check_fn=check_requirements,
validate_config=lambda cfg: bool(cfg.extra.get("server")),
required_env=["IRC_SERVER"],
install_hint="pip install irc",
))
Usage (gateway side):
adapter = platform_registry.create_adapter("irc", platform_config)
"""
import logging
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
logger = logging.getLogger(__name__)
@dataclass
class PlatformEntry:
"""Metadata and factory for a single platform adapter."""
# Identifier used in config.yaml (e.g. "irc", "viber").
name: str
# Human-readable label (e.g. "IRC", "Viber").
label: str
# Factory callable: receives a PlatformConfig, returns an adapter instance.
# Using a factory instead of a bare class lets plugins do custom init
# (e.g. passing extra kwargs, wrapping in try/except).
adapter_factory: Callable[[Any], Any]
# Returns True when the platform's dependencies are available.
check_fn: Callable[[], bool]
# Optional: given a PlatformConfig, is it properly configured?
# If None, the registry skips config validation and lets the adapter
# fail at connect() time with a descriptive error.
validate_config: Optional[Callable[[Any], bool]] = None
# Env vars this platform needs (for ``hermes setup`` display).
required_env: list = field(default_factory=list)
# Hint shown when check_fn returns False.
install_hint: str = ""
# "builtin" or "plugin"
source: str = "plugin"
# ── Auth env var names (for _is_user_authorized integration) ──
# E.g. "IRC_ALLOWED_USERS" — checked for comma-separated user IDs.
allowed_users_env: str = ""
# E.g. "IRC_ALLOW_ALL_USERS" — if truthy, all users authorized.
allow_all_env: str = ""
# ── Message limits ──
# Max message length for smart-chunking. 0 = no limit.
max_message_length: int = 0
# ── Privacy ──
# If True, session descriptions redact PII (phone numbers, etc.)
pii_safe: bool = False
# ── Display ──
# Emoji for CLI/gateway display (e.g. "💬")
emoji: str = "🔌"
# Whether this platform should appear in _UPDATE_ALLOWED_PLATFORMS
# (allows /update command from this platform).
allow_update_command: bool = True
# ── LLM guidance ──
# Platform hint injected into the system prompt (e.g. "You are on IRC.
# Do not use markdown."). Empty string = no hint.
platform_hint: str = ""
class PlatformRegistry:
"""Central registry of platform adapters.
Thread-safe for reads (dict lookups are atomic under GIL).
Writes happen at startup during sequential discovery.
"""
def __init__(self) -> None:
self._entries: dict[str, PlatformEntry] = {}
def register(self, entry: PlatformEntry) -> None:
"""Register a platform adapter entry.
If an entry with the same name exists, it is replaced (last writer
wins -- this lets plugins override built-in adapters if desired).
"""
if entry.name in self._entries:
prev = self._entries[entry.name]
logger.info(
"Platform '%s' re-registered (was %s, now %s)",
entry.name,
prev.source,
entry.source,
)
self._entries[entry.name] = entry
logger.debug("Registered platform adapter: %s (%s)", entry.name, entry.source)
def unregister(self, name: str) -> bool:
"""Remove a platform entry. Returns True if it existed."""
return self._entries.pop(name, None) is not None
def get(self, name: str) -> Optional[PlatformEntry]:
"""Look up a platform entry by name."""
return self._entries.get(name)
def all_entries(self) -> list[PlatformEntry]:
"""Return all registered platform entries."""
return list(self._entries.values())
def plugin_entries(self) -> list[PlatformEntry]:
"""Return only plugin-registered platform entries."""
return [e for e in self._entries.values() if e.source == "plugin"]
def is_registered(self, name: str) -> bool:
return name in self._entries
def create_adapter(self, name: str, config: Any) -> Optional[Any]:
"""Create an adapter instance for the given platform name.
Returns None if:
- No entry registered for *name*
- check_fn() returns False (missing deps)
- validate_config() returns False (misconfigured)
- The factory raises an exception
"""
entry = self._entries.get(name)
if entry is None:
return None
if not entry.check_fn():
hint = f" ({entry.install_hint})" if entry.install_hint else ""
logger.warning(
"Platform '%s' requirements not met%s",
entry.label,
hint,
)
return None
if entry.validate_config is not None:
try:
if not entry.validate_config(config):
logger.warning(
"Platform '%s' config validation failed",
entry.label,
)
return None
except Exception as e:
logger.warning(
"Platform '%s' config validation error: %s",
entry.label,
e,
)
return None
try:
adapter = entry.adapter_factory(config)
return adapter
except Exception as e:
logger.error(
"Failed to create adapter for platform '%s': %s",
entry.label,
e,
exc_info=True,
)
return None
# Module-level singleton
platform_registry = PlatformRegistry()
+4 -25
View File
@@ -1,30 +1,9 @@
# Adding a New Messaging Platform
There are two ways to add a platform to the Hermes gateway:
## Plugin Path (Recommended for Community/Third-Party)
Create a plugin directory in `~/.hermes/plugins/` with a `PLUGIN.yaml` and
`adapter.py`. The adapter inherits from `BasePlatformAdapter` and registers
via `ctx.register_platform()` in the `register(ctx)` entry point. This
requires **zero changes to core Hermes code**.
The plugin system automatically handles: adapter creation, config parsing,
user authorization, cron delivery, send_message routing, system prompt hints,
status display, gateway setup, and more.
See `plugins/platforms/irc/` for a complete reference implementation, and
`website/docs/developer-guide/adding-platform-adapters.md` for the full
plugin guide with code examples.
---
## Built-in Path (Core Contributors Only)
Checklist for integrating a platform directly into the Hermes core.
Use this as a reference when building a built-in adapter — every item here
is a real integration point. Missing any of them will cause broken
functionality, missing features, or inconsistent behavior.
Checklist for integrating a new messaging platform into the Hermes gateway.
Use this as a reference when building a new adapter — every item here is a
real integration point that exists in the codebase. Missing any of them will
cause broken functionality, missing features, or inconsistent behavior.
---
+19 -15
View File
@@ -186,21 +186,25 @@ class WebhookAdapter(BasePlatformAdapter):
if deliver_type == "github_comment":
return await self._deliver_github_comment(content, delivery)
# Cross-platform delivery — any platform with a gateway adapter.
# Check both built-in names and plugin-registered platforms.
_BUILTIN_DELIVER_PLATFORMS = {
"telegram", "discord", "slack", "signal", "sms", "whatsapp",
"matrix", "mattermost", "homeassistant", "email", "dingtalk",
"feishu", "wecom", "wecom_callback", "weixin", "bluebubbles",
}
_is_known_platform = deliver_type in _BUILTIN_DELIVER_PLATFORMS
if not _is_known_platform:
try:
from gateway.platform_registry import platform_registry
_is_known_platform = platform_registry.is_registered(deliver_type)
except Exception:
pass
if self.gateway_runner and _is_known_platform:
# Cross-platform delivery — any platform with a gateway adapter
if self.gateway_runner and deliver_type in (
"telegram",
"discord",
"slack",
"signal",
"sms",
"whatsapp",
"matrix",
"mattermost",
"homeassistant",
"email",
"dingtalk",
"feishu",
"wecom",
"wecom_callback",
"weixin",
"bluebubbles",
):
return await self._deliver_cross_platform(
deliver_type, content, delivery
)
+44 -2
View File
@@ -734,6 +734,42 @@ def _split_delivery_units_for_weixin(content: str) -> List[str]:
return [unit for unit in units if unit]
def _looks_like_chatty_line_for_weixin(line: str) -> bool:
"""Return True when a line looks like a standalone chat utterance."""
stripped = line.strip()
if not stripped:
return False
if len(stripped) > 48:
return False
if line.startswith((" ", "\t")):
return False
if stripped.startswith((">", "-", "*", "【")):
return False
if re.match(r"^\*\*[^*]+\*\*$", stripped):
return False
if re.match(r"^\d+\.\s", stripped):
return False
return True
def _looks_like_heading_line_for_weixin(line: str) -> bool:
"""Return True when a short line behaves like a plain-text heading."""
stripped = line.strip()
if not stripped:
return False
return len(stripped) <= 24 and stripped.endswith((":", ":"))
def _should_split_short_chat_block_for_weixin(block: str) -> bool:
"""Split only chat-like multiline blocks into separate bubbles."""
lines = [line for line in block.splitlines() if line.strip()]
if not 2 <= len(lines) <= 6:
return False
if _looks_like_heading_line_for_weixin(lines[0]):
return False
return all(_looks_like_chatty_line_for_weixin(line) for line in lines)
def _pack_markdown_blocks_for_weixin(content: str, max_length: int) -> List[str]:
if len(content) <= max_length:
return [content]
@@ -787,9 +823,15 @@ def _split_text_for_weixin_delivery(
chunks.extend(_pack_markdown_blocks_for_weixin(unit, max_length))
return chunks or [content]
# Compact (default): single message when under the limit.
# Compact (default): single message when under the limit — unless the
# content looks like a short chatty exchange, in which case split into
# separate bubbles for a more natural chat feel.
if len(content) <= max_length:
return [content]
return (
_split_delivery_units_for_weixin(content)
if _should_split_short_chat_block_for_weixin(content)
else [content]
)
return _pack_markdown_blocks_for_weixin(content, max_length) or [content]
+119 -82
View File
@@ -206,6 +206,15 @@ if _config_path.exists():
except Exception:
pass # Non-fatal; gateway can still run with .env values
# Apply IPv4 preference if configured (before any HTTP clients are created).
try:
from hermes_constants import apply_ipv4_preference
_network_cfg = (_cfg if '_cfg' in dir() else {}).get("network", {})
if isinstance(_network_cfg, dict) and _network_cfg.get("force_ipv4"):
apply_ipv4_preference(force=True)
except Exception:
pass
# Validate config structure early — log warnings so gateway operators see problems
try:
from hermes_cli.config import print_config_warnings
@@ -498,13 +507,6 @@ def _format_gateway_process_notification(evt: dict) -> "str | None":
return None
# Module-level weak reference to the active GatewayRunner instance.
# Used by tools (e.g. send_message) that need to route through a live
# adapter for plugin platforms. Set in GatewayRunner.__init__().
import weakref as _weakref
_gateway_runner_ref: _weakref.ref = lambda: None
class GatewayRunner:
"""
Main gateway controller.
@@ -528,10 +530,8 @@ class GatewayRunner:
_session_model_overrides: Dict[str, Dict[str, str]] = {}
def __init__(self, config: Optional[GatewayConfig] = None):
global _gateway_runner_ref
self.config = config or load_gateway_config()
self.adapters: Dict[Platform, BasePlatformAdapter] = {}
_gateway_runner_ref = _weakref.ref(self)
# Load ephemeral config from config.yaml / env vars.
# Both are injected at API-call time only and never persisted.
@@ -1521,17 +1521,7 @@ class GatewayRunner:
adapter = self._create_adapter(platform, platform_config)
if not adapter:
# Distinguish between missing builtin deps and missing plugin
_pval = platform.value
_builtin_names = {m.value for m in Platform.__members__.values()}
if _pval not in _builtin_names:
logger.warning(
"No adapter for '%s' — is the plugin installed? "
"(platform is enabled in config.yaml but no plugin registered it)",
_pval,
)
else:
logger.warning("No adapter available for %s", _pval)
logger.warning("No adapter available for %s", platform.value)
continue
# Set up message + fatal error handlers
@@ -2062,11 +2052,7 @@ class GatewayRunner:
platform: Platform,
config: Any
) -> Optional[BasePlatformAdapter]:
"""Create the appropriate adapter for a platform.
Checks the platform_registry first (plugin adapters), then falls
through to the built-in if/elif chain for core platforms.
"""
"""Create the appropriate adapter for a platform."""
if hasattr(config, "extra") and isinstance(config.extra, dict):
config.extra.setdefault(
"group_sessions_per_user",
@@ -2077,16 +2063,6 @@ class GatewayRunner:
getattr(self.config, "thread_sessions_per_user", False),
)
# ── Plugin-registered platforms (checked first) ──────────────
try:
from gateway.platform_registry import platform_registry
adapter = platform_registry.create_adapter(platform.value, config)
if adapter is not None:
return adapter
except Exception as e:
logger.debug("Platform registry lookup for '%s' failed: %s", platform.value, e)
# Fall through to built-in adapters below
if platform == Platform.TELEGRAM:
from gateway.platforms.telegram import TelegramAdapter, check_telegram_requirements
if not check_telegram_requirements():
@@ -2278,19 +2254,6 @@ class GatewayRunner:
Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOW_ALL_USERS",
}
# Plugin platforms: check the registry for auth env var names
if source.platform not in platform_env_map:
try:
from gateway.platform_registry import platform_registry
entry = platform_registry.get(source.platform.value)
if entry:
if entry.allowed_users_env:
platform_env_map[source.platform] = entry.allowed_users_env
if entry.allow_all_env:
platform_allow_all_map[source.platform] = entry.allow_all_env
except Exception:
pass
# Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)
platform_allow_all_var = platform_allow_all_map.get(source.platform, "")
if platform_allow_all_var and os.getenv(platform_allow_all_var, "").lower() in ("true", "1", "yes"):
@@ -4002,9 +3965,16 @@ class GatewayRunner:
except Exception:
pass
# Append a random tip to the reset message
try:
from hermes_cli.tips import get_random_tip
_tip_line = f"\n✦ Tip: {get_random_tip()}"
except Exception:
_tip_line = ""
if session_info:
return f"{header}\n\n{session_info}"
return header
return f"{header}\n\n{session_info}{_tip_line}"
return f"{header}{_tip_line}"
async def _handle_profile_command(self, event: MessageEvent) -> str:
"""Handle /profile — show active profile name and home directory."""
@@ -5762,7 +5732,12 @@ class GatewayRunner:
return f"{descriptions[new_mode]}\n_(could not save to config: {e})_"
async def _handle_compress_command(self, event: MessageEvent) -> str:
"""Handle /compress command -- manually compress conversation context."""
"""Handle /compress command -- manually compress conversation context.
Accepts an optional focus topic: ``/compress <focus>`` guides the
summariser to preserve information related to *focus* while being
more aggressive about discarding everything else.
"""
source = event.source
session_entry = self.session_store.get_or_create_session(source)
history = self.session_store.load_transcript(session_entry.session_id)
@@ -5770,6 +5745,9 @@ class GatewayRunner:
if not history or len(history) < 4:
return "Not enough conversation to compress (need at least 4 messages)."
# Extract optional focus topic from command args
focus_topic = (event.get_command_args() or "").strip() or None
try:
from run_agent import AIAgent
from agent.manual_compression_feedback import summarize_manual_compression
@@ -5811,7 +5789,7 @@ class GatewayRunner:
loop = asyncio.get_event_loop()
compressed, _ = await loop.run_in_executor(
None,
lambda: tmp_agent._compress_context(msgs, "", approx_tokens=approx_tokens)
lambda: tmp_agent._compress_context(msgs, "", approx_tokens=approx_tokens, focus_topic=focus_topic)
)
# _compress_context already calls end_session() on the old session
@@ -5835,7 +5813,10 @@ class GatewayRunner:
approx_tokens,
new_tokens,
)
lines = [f"🗜️ {summary['headline']}", summary["token_line"]]
lines = [f"🗜️ {summary['headline']}"]
if focus_topic:
lines.append(f"Focus: \"{focus_topic}\"")
lines.append(summary["token_line"])
if summary["note"]:
lines.append(summary["note"])
return "\n".join(lines)
@@ -6396,16 +6377,8 @@ class GatewayRunner:
# Block non-messaging platforms (API server, webhooks, ACP)
platform = event.source.platform
_allowed = self._UPDATE_ALLOWED_PLATFORMS
# Plugin platforms with allow_update_command=True are also allowed
if platform not in _allowed:
try:
from gateway.platform_registry import platform_registry
entry = platform_registry.get(platform.value)
if not entry or not entry.allow_update_command:
return "✗ /update is only available from messaging platforms. Run `hermes update` from the terminal."
except Exception:
return "✗ /update is only available from messaging platforms. Run `hermes update` from the terminal."
if platform not in self._UPDATE_ALLOWED_PLATFORMS:
return "✗ /update is only available from messaging platforms. Run `hermes update` from the terminal."
if is_managed():
return f"✗ {format_managed_message('update Hermes Agent')}"
@@ -7036,7 +7009,9 @@ class GatewayRunner:
if session.exited:
# --- Agent-triggered completion: inject synthetic message ---
if agent_notify:
# Skip if the agent already consumed the result via wait/poll/log
from tools.process_registry import process_registry as _pr_check
if agent_notify and not _pr_check.is_completion_consumed(session_id):
from tools.ansi_strip import strip_ansi
_out = strip_ansi(session.output_buffer[-2000:]) if session.output_buffer else ""
synth_text = (
@@ -8044,26 +8019,43 @@ class GatewayRunner:
tracking_task = asyncio.create_task(track_agent())
# Monitor for interrupts from the adapter (new messages arriving)
# Monitor for interrupts from the adapter (new messages arriving).
# This is the PRIMARY interrupt path for regular text messages —
# Level 1 (base.py) catches them before _handle_message() is reached,
# so the Level 2 running_agent.interrupt() path never fires.
# The inactivity poll loop below has a BACKUP check in case this
# task dies (no error handling = silent death = lost interrupts).
_interrupt_detected = asyncio.Event() # shared with backup check
async def monitor_for_interrupt():
adapter = self.adapters.get(source.platform)
if not adapter or not session_key:
if not session_key:
return
while True:
await asyncio.sleep(0.2) # Check every 200ms
# Check if adapter has a pending interrupt for this session.
# Must use session_key (build_session_key output) — NOT
# source.chat_id — because the adapter stores interrupt events
# under the full session key.
if hasattr(adapter, 'has_pending_interrupt') and adapter.has_pending_interrupt(session_key):
agent = agent_holder[0]
if agent:
pending_event = adapter.get_pending_message(session_key)
pending_text = pending_event.text if pending_event else None
logger.debug("Interrupt detected from adapter, signaling agent...")
agent.interrupt(pending_text)
break
try:
# Re-resolve adapter each iteration so reconnects don't
# leave us holding a stale reference.
_adapter = self.adapters.get(source.platform)
if not _adapter:
continue
# Check if adapter has a pending interrupt for this session.
# Must use session_key (build_session_key output) — NOT
# source.chat_id — because the adapter stores interrupt events
# under the full session key.
if hasattr(_adapter, 'has_pending_interrupt') and _adapter.has_pending_interrupt(session_key):
agent = agent_holder[0]
if agent:
pending_event = _adapter.get_pending_message(session_key)
pending_text = pending_event.text if pending_event else None
logger.debug("Interrupt detected from adapter, signaling agent...")
agent.interrupt(pending_text)
_interrupt_detected.set()
break
except asyncio.CancelledError:
raise
except Exception as _mon_err:
logger.debug("monitor_for_interrupt error (will retry): %s", _mon_err)
interrupt_monitor = asyncio.create_task(monitor_for_interrupt())
@@ -8128,8 +8120,34 @@ class GatewayRunner:
_POLL_INTERVAL = 5.0
if _agent_timeout is None:
# Unlimited — just await the result.
response = await _executor_task
# Unlimited — still poll periodically for backup interrupt
# detection in case monitor_for_interrupt() silently died.
response = None
while True:
done, _ = await asyncio.wait(
{_executor_task}, timeout=_POLL_INTERVAL
)
if done:
response = _executor_task.result()
break
# Backup interrupt check: if the monitor task died or
# missed the interrupt, catch it here.
if not _interrupt_detected.is_set() and session_key:
_backup_adapter = self.adapters.get(source.platform)
_backup_agent = agent_holder[0]
if (_backup_adapter and _backup_agent
and hasattr(_backup_adapter, 'has_pending_interrupt')
and _backup_adapter.has_pending_interrupt(session_key)):
_bp_event = _backup_adapter.get_pending_message(session_key)
_bp_text = _bp_event.text if _bp_event else None
logger.info(
"Backup interrupt detected for session %s "
"(monitor task state: %s)",
session_key[:20],
"done" if interrupt_monitor.done() else "running",
)
_backup_agent.interrupt(_bp_text)
_interrupt_detected.set()
else:
# Poll loop: check the agent's built-in activity tracker
# (updated by _touch_activity() on every tool call, API
@@ -8173,6 +8191,23 @@ class GatewayRunner:
if _idle_secs >= _agent_timeout:
_inactivity_timeout = True
break
# Backup interrupt check (same as unlimited path).
if not _interrupt_detected.is_set() and session_key:
_backup_adapter = self.adapters.get(source.platform)
_backup_agent = agent_holder[0]
if (_backup_adapter and _backup_agent
and hasattr(_backup_adapter, 'has_pending_interrupt')
and _backup_adapter.has_pending_interrupt(session_key)):
_bp_event = _backup_adapter.get_pending_message(session_key)
_bp_text = _bp_event.text if _bp_event else None
logger.info(
"Backup interrupt detected for session %s "
"(monitor task state: %s)",
session_key[:20],
"done" if interrupt_monitor.done() else "running",
)
_backup_agent.interrupt(_bp_text)
_interrupt_detected.set()
if _inactivity_timeout:
# Build a diagnostic summary from the agent's activity tracker.
@@ -8603,6 +8638,8 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
# verbosity=1 (-v): INFO and above
# verbosity=2+ (-vv/-vvv): DEBUG
if verbosity is not None:
from agent.redact import RedactingFormatter
_stderr_level = {0: logging.WARNING, 1: logging.INFO}.get(verbosity, logging.DEBUG)
_stderr_handler = logging.StreamHandler()
_stderr_handler.setLevel(_stderr_level)
+2 -12
View File
@@ -203,18 +203,8 @@ def build_session_context_prompt(
Platforms like Discord are excluded because mentions need real IDs.
Routing still uses the original values (they stay in SessionSource).
"""
# Only apply redaction on platforms where IDs aren't needed for mentions.
# Check both the hardcoded set (builtins) and the plugin registry.
_is_pii_safe = context.source.platform in _PII_SAFE_PLATFORMS
if not _is_pii_safe:
try:
from gateway.platform_registry import platform_registry
entry = platform_registry.get(context.source.platform.value)
if entry and entry.pii_safe:
_is_pii_safe = True
except Exception:
pass
redact_pii = redact_pii and _is_pii_safe
# Only apply redaction on platforms where IDs aren't needed for mentions
redact_pii = redact_pii and context.source.platform in _PII_SAFE_PLATFORMS
lines = [
"## Current Session Context",
"",
+10 -59
View File
@@ -50,7 +50,7 @@ _OPENCLAW_SCRIPT_INSTALLED = (
)
# Known OpenClaw directory names (current + legacy)
_OPENCLAW_DIR_NAMES = (".openclaw", ".clawdbot", ".moldbot")
_OPENCLAW_DIR_NAMES = (".openclaw", ".clawdbot", ".moltbot")
def _warn_if_gateway_running(auto_yes: bool) -> None:
"""Check if a Hermes gateway is running with connected platforms.
@@ -87,8 +87,8 @@ def _warn_if_gateway_running(auto_yes: bool) -> None:
print_info("Migration cancelled. Stop the gateway and try again.")
sys.exit(0)
# State files commonly found in OpenClaw workspace directories that cause
# confusion after migration (the agent discovers them and writes to them)
# State files commonly found in OpenClaw workspace directories — listed
# during cleanup to help the user decide whether to archive
_WORKSPACE_STATE_GLOBS = (
"*/todo.json",
"*/sessions/*",
@@ -133,7 +133,7 @@ def _find_openclaw_dirs() -> list[Path]:
def _scan_workspace_state(source_dir: Path) -> list[tuple[Path, str]]:
"""Scan an OpenClaw directory for workspace state files that cause confusion.
"""Scan an OpenClaw directory for workspace state files.
Returns a list of (path, description) tuples.
"""
@@ -216,7 +216,7 @@ def _cmd_migrate(args):
source_dir = Path.home() / ".openclaw"
if not source_dir.is_dir():
# Try legacy directory names
for legacy in (".clawdbot", ".moldbot"):
for legacy in (".clawdbot", ".moltbot"):
candidate = Path.home() / legacy
if candidate.is_dir():
source_dir = candidate
@@ -384,65 +384,16 @@ def _cmd_migrate(args):
# Print results
_print_migration_report(report, dry_run=False)
# After successful migration, offer to archive the source directory
if report.get("summary", {}).get("migrated", 0) > 0:
_offer_source_archival(source_dir, auto_yes)
def _offer_source_archival(source_dir: Path, auto_yes: bool = False):
"""After migration, offer to rename the source directory to prevent state fragmentation.
OpenClaw workspace directories contain state files (todo.json, sessions, etc.)
that the agent may discover and write to, causing confusion. Renaming the
directory prevents this.
"""
if not source_dir.is_dir():
return
# Scan for state files that could cause problems
state_files = _scan_workspace_state(source_dir)
print()
print_header("Post-Migration Cleanup")
print_info("The OpenClaw directory still exists and contains workspace state files")
print_info("that can confuse the agent (todo lists, sessions, logs).")
if state_files:
print()
print(color(" Found state files:", Colors.YELLOW))
# Show up to 10 most relevant findings
for path, desc in state_files[:10]:
print(f" {desc}")
if len(state_files) > 10:
print(f" ... and {len(state_files) - 10} more")
print()
print_info(f"Recommend: rename {source_dir.name}/ to {source_dir.name}.pre-migration/")
print_info("This prevents the agent from discovering old workspace directories.")
print_info("You can always rename it back if needed.")
print()
if not auto_yes and not sys.stdin.isatty():
print_info("Non-interactive session — skipping archival.")
print_info("Run later with: hermes claw cleanup")
return
if auto_yes or prompt_yes_no(f"Archive {source_dir} now?", default=True):
try:
archive_path = _archive_directory(source_dir)
print_success(f"Archived: {source_dir} → {archive_path}")
print_info("The original directory has been renamed, not deleted.")
print_info(f"To undo: mv {archive_path} {source_dir}")
except OSError as e:
print_error(f"Could not archive: {e}")
print_info(f"You can do it manually: mv {source_dir} {source_dir}.pre-migration")
else:
print_info("Skipped. You can archive later with: hermes claw cleanup")
# Source directory is left untouched — archiving is not the migration
# tool's responsibility. Users who want to clean up can run
# 'hermes claw cleanup' separately.
def _cmd_cleanup(args):
"""Archive leftover OpenClaw directories after migration.
Scans for OpenClaw directories that still exist after migration and offers
to rename them to .pre-migration to prevent state fragmentation.
to rename them to .pre-migration to free disk space.
"""
dry_run = getattr(args, "dry_run", False)
auto_yes = getattr(args, "yes", False)
@@ -517,7 +468,7 @@ def _cmd_cleanup(args):
if state_files:
print()
print(color(f" {len(state_files)} state file(s) that could cause confusion:", Colors.YELLOW))
print(color(f" {len(state_files)} state file(s) found:", Colors.YELLOW))
for path, desc in state_files[:8]:
print(f" {desc}")
if len(state_files) > 8:
+2 -1
View File
@@ -69,7 +69,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
args_hint="[name]"),
CommandDef("branch", "Branch the current session (explore a different path)", "Session",
aliases=("fork",), args_hint="[name]"),
CommandDef("compress", "Manually compress conversation context", "Session"),
CommandDef("compress", "Manually compress conversation context", "Session",
args_hint="[focus topic]"),
CommandDef("rollback", "List or restore filesystem checkpoints", "Session",
args_hint="[number]"),
CommandDef("stop", "Kill all running background processes", "Session"),
+8 -39
View File
@@ -45,8 +45,6 @@ _EXTRA_ENV_KEYS = frozenset({
"WEIXIN_HOME_CHANNEL", "WEIXIN_HOME_CHANNEL_NAME", "WEIXIN_DM_POLICY", "WEIXIN_GROUP_POLICY",
"WEIXIN_ALLOWED_USERS", "WEIXIN_GROUP_ALLOWED_USERS", "WEIXIN_ALLOW_ALL_USERS",
"BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD",
"IRC_SERVER", "IRC_PORT", "IRC_NICKNAME", "IRC_CHANNEL",
"IRC_USE_TLS", "IRC_SERVER_PASSWORD", "IRC_NICKSERV_PASSWORD",
"TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE",
@@ -708,6 +706,14 @@ DEFAULT_CONFIG = {
"backup_count": 3, # Number of rotated backup files to keep
},
# Network settings — workarounds for connectivity issues.
"network": {
# Force IPv4 connections. On servers with broken or unreachable IPv6,
# Python tries AAAA records first and hangs for the full TCP timeout
# before falling back to IPv4. Set to true to skip IPv6 entirely.
"force_ipv4": False,
},
# Config schema version - bump this when adding new required fields
"_config_version": 16,
}
@@ -1308,43 +1314,6 @@ OPTIONAL_ENV_VARS = {
"password": False,
"category": "messaging",
},
"IRC_SERVER": {
"description": "IRC server hostname (e.g. irc.libera.chat)",
"prompt": "IRC server",
"url": None,
"password": False,
"category": "messaging",
},
"IRC_CHANNEL": {
"description": "IRC channel to join (e.g. #hermes)",
"prompt": "IRC channel",
"url": None,
"password": False,
"category": "messaging",
},
"IRC_NICKNAME": {
"description": "Bot nickname on IRC (default: hermes-bot)",
"prompt": "IRC nickname",
"url": None,
"password": False,
"category": "messaging",
},
"IRC_SERVER_PASSWORD": {
"description": "IRC server password (if required)",
"prompt": "IRC server password",
"url": None,
"password": True,
"category": "messaging",
"advanced": True,
},
"IRC_NICKSERV_PASSWORD": {
"description": "NickServ password for nick identification",
"prompt": "NickServ password",
"url": None,
"password": True,
"category": "messaging",
"advanced": True,
},
"GATEWAY_ALLOW_ALL_USERS": {
"description": "Allow all users to interact with messaging bots (true/false). Default: false.",
"prompt": "Allow all users (true/false)",
+9 -35
View File
@@ -2448,53 +2448,27 @@ def gateway_setup():
print()
print_header("Messaging Platforms")
# Build menu from built-in platforms + plugin platforms
_plugin_entries = []
try:
from gateway.platform_registry import platform_registry
_plugin_entries = platform_registry.plugin_entries()
except Exception:
pass
menu_items = []
for plat in _PLATFORMS:
status = _platform_status(plat)
menu_items.append(f"{plat['label']} ({status})")
for pentry in _plugin_entries:
configured = pentry.check_fn()
status_str = "configured" if configured else "not configured"
menu_items.append(f"{pentry.emoji} {pentry.label} ({status_str}) [plugin]")
menu_items.append("Done")
_total_platforms = len(_PLATFORMS) + len(_plugin_entries)
choice = prompt_choice("Select a platform to configure:", menu_items, len(menu_items) - 1)
if choice == _total_platforms:
if choice == len(_PLATFORMS):
break
if choice < len(_PLATFORMS):
platform = _PLATFORMS[choice]
platform = _PLATFORMS[choice]
if platform["key"] == "whatsapp":
_setup_whatsapp()
elif platform["key"] == "signal":
_setup_signal()
elif platform["key"] == "weixin":
_setup_weixin()
else:
_setup_standard_platform(platform)
if platform["key"] == "whatsapp":
_setup_whatsapp()
elif platform["key"] == "signal":
_setup_signal()
elif platform["key"] == "weixin":
_setup_weixin()
else:
# Plugin platform — show env var setup instructions
pentry = _plugin_entries[choice - len(_PLATFORMS)]
print(f"\n {pentry.label} (plugin platform)")
if pentry.required_env:
print(f" Required env vars: {', '.join(pentry.required_env)}")
print(f" Set these in ~/.hermes/.env or config.yaml gateway.platforms.{pentry.name}.extra")
else:
print(f" Configure in config.yaml under gateway.platforms.{pentry.name}")
if pentry.install_hint:
print(f" {pentry.install_hint}")
print()
_setup_standard_platform(platform)
# ── Post-setup: offer to install/restart gateway ──
any_configured = any(
+12
View File
@@ -151,6 +151,18 @@ try:
except Exception:
pass # best-effort — don't crash the CLI if logging setup fails
# Apply IPv4 preference early, before any HTTP clients are created.
try:
from hermes_cli.config import load_config as _load_config_early
from hermes_constants import apply_ipv4_preference as _apply_ipv4
_early_cfg = _load_config_early()
_net = _early_cfg.get("network", {})
if isinstance(_net, dict) and _net.get("force_ipv4"):
_apply_ipv4(force=True)
del _early_cfg, _net
except Exception:
pass # best-effort — don't crash if config isn't available yet
import logging
import time as _time
from datetime import datetime
+37 -2
View File
@@ -839,8 +839,11 @@ def list_authenticated_providers(
if any(os.environ.get(ev) for ev in pcfg.api_key_env_vars):
has_creds = True
break
if not has_creds and overlay.auth_type in ("oauth_device_code", "oauth_external", "external_process"):
# These use auth stores, not env vars — check for auth.json entries
# Check auth store and credential pool for non-env-var credentials.
# This applies to OAuth providers AND api_key providers that also
# support OAuth (e.g. anthropic supports both API key and Claude Code
# OAuth via external credential files).
if not has_creds:
try:
from hermes_cli.auth import _load_auth_store
store = _load_auth_store()
@@ -853,6 +856,38 @@ def list_authenticated_providers(
has_creds = True
except Exception as exc:
logger.debug("Auth store check failed for %s: %s", pid, exc)
# Fallback: check the credential pool with full auto-seeding.
# This catches credentials that exist in external stores (e.g.
# Codex CLI ~/.codex/auth.json) which _seed_from_singletons()
# imports on demand but aren't in the raw auth.json yet.
if not has_creds:
try:
from agent.credential_pool import load_pool
pool = load_pool(hermes_slug)
if pool.has_credentials():
has_creds = True
except Exception as exc:
logger.debug("Credential pool check failed for %s: %s", hermes_slug, exc)
# Fallback: check external credential files directly.
# The credential pool gates anthropic behind
# is_provider_explicitly_configured() to prevent auxiliary tasks
# from silently consuming Claude Code tokens (PR #4210).
# But the /model picker is discovery-oriented — we WANT to show
# providers the user can switch to, even if they aren't currently
# configured.
if not has_creds and hermes_slug == "anthropic":
try:
from agent.anthropic_adapter import (
read_claude_code_credentials,
read_hermes_oauth_credentials,
)
hermes_creds = read_hermes_oauth_credentials()
cc_creds = read_claude_code_credentials()
if (hermes_creds and hermes_creds.get("accessToken")) or \
(cc_creds and cc_creds.get("accessToken")):
has_creds = True
except Exception as exc:
logger.debug("Anthropic external creds check failed: %s", exc)
if not has_creds:
continue
+2 -36
View File
@@ -41,40 +41,6 @@ PLATFORMS: OrderedDict[str, PlatformInfo] = OrderedDict([
def platform_label(key: str, default: str = "") -> str:
"""Return the display label for a platform key, or *default*.
Checks the static PLATFORMS dict first, then the plugin platform
registry for dynamically registered platforms.
"""
"""Return the display label for a platform key, or *default*."""
info = PLATFORMS.get(key)
if info is not None:
return info.label
# Check plugin registry
try:
from gateway.platform_registry import platform_registry
entry = platform_registry.get(key)
if entry:
return f"{entry.emoji} {entry.label}" if entry.emoji else entry.label
except Exception:
pass
return default
def get_all_platforms() -> "OrderedDict[str, PlatformInfo]":
"""Return PLATFORMS merged with any plugin-registered platforms.
Plugin platforms are appended after builtins. This is the function
that tools_config and skills_config should use for platform menus.
"""
merged = OrderedDict(PLATFORMS)
try:
from gateway.platform_registry import platform_registry
for entry in platform_registry.plugin_entries():
if entry.name not in merged:
merged[entry.name] = PlatformInfo(
label=f"{entry.emoji} {entry.label}" if entry.emoji else entry.label,
default_toolset=f"hermes-{entry.name}",
)
except Exception:
pass
return merged
return info.label if info is not None else default
-48
View File
@@ -244,53 +244,6 @@ class PluginContext:
self.manifest.name, engine.name,
)
# -- platform adapter registration ---------------------------------------
def register_platform(
self,
name: str,
label: str,
adapter_factory: Callable,
check_fn: Callable,
validate_config: Callable | None = None,
required_env: list | None = None,
install_hint: str = "",
) -> None:
"""Register a gateway platform adapter.
The adapter_factory receives a ``PlatformConfig`` and returns a
``BasePlatformAdapter`` subclass instance. The gateway calls
``check_fn()`` before instantiation to verify dependencies.
Example::
ctx.register_platform(
name="irc",
label="IRC",
adapter_factory=lambda cfg: IRCAdapter(cfg),
check_fn=lambda: True,
)
"""
from gateway.platform_registry import platform_registry, PlatformEntry
entry = PlatformEntry(
name=name,
label=label,
adapter_factory=adapter_factory,
check_fn=check_fn,
validate_config=validate_config,
required_env=required_env or [],
install_hint=install_hint,
source="plugin",
)
platform_registry.register(entry)
self._manager._plugin_platform_names.add(name)
logger.debug(
"Plugin %s registered platform: %s",
self.manifest.name,
name,
)
# -- hook registration --------------------------------------------------
def register_hook(self, hook_name: str, callback: Callable) -> None:
@@ -322,7 +275,6 @@ class PluginManager:
self._plugins: Dict[str, LoadedPlugin] = {}
self._hooks: Dict[str, List[Callable]] = {}
self._plugin_tool_names: Set[str] = set()
self._plugin_platform_names: Set[str] = set()
self._cli_commands: Dict[str, dict] = {}
self._context_engine = None # Set by a plugin via register_context_engine()
self._discovered: bool = False
-11
View File
@@ -320,17 +320,6 @@ def show_status(args):
status += f" (home: {home_channel})"
print(f" {name:<12} {check_mark(has_token)} {status}")
# Plugin-registered platforms
try:
from gateway.platform_registry import platform_registry
for entry in platform_registry.plugin_entries():
configured = entry.check_fn()
status_str = "configured" if configured else "not configured"
label = entry.label
print(f" {label:<12} {check_mark(configured)} {status_str} (plugin)")
except Exception:
pass
# =========================================================================
# Gateway Status
+351
View File
@@ -0,0 +1,351 @@
"""Random tips shown at CLI session start to help users discover features."""
import random
from typing import Optional
# ---------------------------------------------------------------------------
# Tip corpus — one-liners covering slash commands, CLI flags, config,
# keybindings, tools, gateway, skills, profiles, and workflow tricks.
# ---------------------------------------------------------------------------
TIPS = [
# --- Slash Commands ---
"/btw <question> asks a quick side question without tools or history — great for clarifications.",
"/background <prompt> runs a task in a separate session while your current one stays free.",
"/branch forks the current session so you can explore a different direction without losing progress.",
"/compress manually compresses conversation context when things get long.",
"/rollback lists filesystem checkpoints — restore files the agent modified to any prior state.",
"/rollback diff 2 previews what changed since checkpoint 2 without restoring anything.",
"/rollback 2 src/file.py restores a single file from a specific checkpoint.",
"/title \"my project\" names your session — resume it later with /resume or hermes -c.",
"/resume picks up where you left off in a previously named session.",
"/queue <prompt> queues a message for the next turn without interrupting the current one.",
"/undo removes the last user/assistant exchange from the conversation.",
"/retry resends your last message — useful when the agent's response wasn't quite right.",
"/verbose cycles tool progress display: off → new → all → verbose.",
"/reasoning high increases the model's thinking depth. /reasoning show displays the reasoning.",
"/fast toggles priority processing for faster API responses (provider-dependent).",
"/yolo skips all dangerous command approval prompts for the rest of the session.",
"/model lets you switch models mid-session — try /model sonnet or /model gpt-5.",
"/model --global changes your default model permanently.",
"/personality pirate sets a fun personality — 14 built-in options from kawaii to shakespeare.",
"/skin changes the CLI theme — try ares, mono, slate, poseidon, or charizard.",
"/statusbar toggles a persistent bar showing model, tokens, context fill %, cost, and duration.",
"/tools disable browser temporarily removes browser tools for the current session.",
"/browser connect attaches browser tools to your running Chrome instance via CDP.",
"/plugins lists installed plugins and their status.",
"/cron manages scheduled tasks — set up recurring prompts with delivery to any platform.",
"/reload-mcp hot-reloads MCP server configuration without restarting.",
"/usage shows token usage, cost breakdown, and session duration.",
"/insights shows usage analytics for the last 30 days.",
"/paste checks your clipboard for an image and attaches it to your next message.",
"/profile shows which profile is active and its home directory.",
"/config shows your current configuration at a glance.",
"/stop kills all running background processes spawned by the agent.",
# --- @ Context References ---
"@file:path/to/file.py injects file contents directly into your message.",
"@file:main.py:10-50 injects only lines 10-50 of a file.",
"@folder:src/ injects a directory tree listing.",
"@diff injects your unstaged git changes into the message.",
"@staged injects your staged git changes (git diff --staged).",
"@git:5 injects the last 5 commits with full patches.",
"@url:https://example.com fetches and injects a web page's content.",
"Typing @ triggers filesystem path completion — navigate to any file interactively.",
"Combine multiple references: \"Review @file:main.py and @file:test.py for consistency.\"",
# --- Keybindings ---
"Alt+Enter (or Ctrl+J) inserts a newline for multi-line input.",
"Ctrl+C interrupts the agent. Double-press within 2 seconds to force exit.",
"Ctrl+Z suspends Hermes to the background — run fg in your shell to resume.",
"Tab accepts auto-suggestion ghost text or autocompletes slash commands.",
"Type a new message while the agent is working to interrupt and redirect it.",
"Alt+V pastes an image from your clipboard into the conversation.",
"Pasting 5+ lines auto-saves to a file and inserts a compact reference instead.",
# --- CLI Flags ---
"hermes -c resumes your most recent CLI session. hermes -c \"project name\" resumes by title.",
"hermes -w creates an isolated git worktree — perfect for parallel agent workflows.",
"hermes -w -q \"Fix issue #42\" combines worktree isolation with a one-shot query.",
"hermes chat -t web,terminal enables only specific toolsets for a focused session.",
"hermes chat -s github-pr-workflow preloads a skill at launch.",
"hermes chat -q \"query\" runs a single non-interactive query and exits.",
"hermes chat --max-turns 200 overrides the default 90-iteration limit per turn.",
"hermes chat --checkpoints enables filesystem snapshots before every destructive file change.",
"hermes --yolo bypasses all dangerous command approval prompts for the entire session.",
"hermes chat --source telegram tags the session for filtering in hermes sessions list.",
"hermes -p work chat runs under a specific profile without changing your default.",
# --- CLI Subcommands ---
"hermes doctor --fix diagnoses and auto-repairs config and dependency issues.",
"hermes dump outputs a compact setup summary — great for bug reports.",
"hermes config set KEY VALUE auto-routes secrets to .env and everything else to config.yaml.",
"hermes config edit opens config.yaml in your default editor.",
"hermes config check scans for missing or stale configuration options.",
"hermes sessions browse opens an interactive session picker with search.",
"hermes sessions stats shows session counts by platform and database size.",
"hermes sessions prune --older-than 30 cleans up old sessions.",
"hermes skills search react --source skills-sh searches the skills.sh public directory.",
"hermes skills check scans installed hub skills for upstream updates.",
"hermes skills tap add myorg/skills-repo adds a custom GitHub skill source.",
"hermes skills snapshot export setup.json exports your skill configuration for backup or sharing.",
"hermes mcp add github --command npx adds MCP servers from the command line.",
"hermes mcp serve runs Hermes itself as an MCP server for other agents.",
"hermes auth add lets you add multiple API keys for credential pool rotation.",
"hermes completion bash >> ~/.bashrc enables tab completion for all commands and profiles.",
"hermes logs -f follows agent.log in real time. --level WARNING --since 1h filters output.",
"hermes backup creates a zip backup of your entire Hermes home directory.",
"hermes profile create coder creates an isolated profile that becomes its own command.",
"hermes profile create work --clone copies your current config and keys to a new profile.",
"hermes update syncs new bundled skills to ALL profiles automatically.",
"hermes gateway install sets up Hermes as a system service (systemd/launchd).",
"hermes memory setup lets you configure an external memory provider (Honcho, Mem0, etc.).",
"hermes webhook subscribe creates event-driven webhook routes with HMAC validation.",
# --- Configuration ---
"Set display.bell_on_complete: true in config.yaml to hear a bell when long tasks finish.",
"Set display.streaming: true to see tokens appear in real time as the model generates.",
"Set display.show_reasoning: true to watch the model's chain-of-thought reasoning.",
"Set display.compact: true to reduce whitespace in output for denser information.",
"Set display.busy_input_mode: queue to queue messages instead of interrupting the agent.",
"Set display.resume_display: minimal to skip the full conversation recap on session resume.",
"Set compression.threshold: 0.50 to control when auto-compression fires (default: 50% of context).",
"Set agent.max_turns: 200 to let the agent take more tool-calling steps per turn.",
"Set file_read_max_chars: 200000 to increase the max content per read_file call.",
"Set approvals.mode: smart to let an LLM auto-approve safe commands and auto-deny dangerous ones.",
"Set fallback_model in config.yaml to automatically fail over to a backup provider.",
"Set privacy.redact_pii: true to hash user IDs and phone numbers before sending to the LLM.",
"Set browser.record_sessions: true to auto-record browser sessions as WebM videos.",
"Set worktree: true in config.yaml to always create a git worktree (same as hermes -w).",
"Set security.website_blocklist.enabled: true to block specific domains from web tools.",
"Set cron.wrap_response: false to deliver raw agent output without the cron header/footer.",
"HERMES_TIMEZONE overrides the server timezone with any IANA timezone string.",
"Environment variable substitution works in config.yaml: use ${VAR_NAME} syntax.",
"Quick commands in config.yaml run shell commands instantly with zero token usage.",
"Custom personalities can be defined in config.yaml under agent.personalities.",
"provider_routing controls OpenRouter provider sorting, whitelisting, and blacklisting.",
# --- Tools & Capabilities ---
"execute_code runs Python scripts that call Hermes tools programmatically — results stay out of context.",
"delegate_task spawns up to 3 concurrent sub-agents with isolated contexts for parallel work.",
"web_extract works on PDF URLs — pass any PDF link and it converts to markdown.",
"search_files is ripgrep-backed and faster than grep — use it instead of terminal grep.",
"patch uses 9 fuzzy matching strategies so minor whitespace differences won't break edits.",
"patch supports V4A format for bulk multi-file edits in a single call.",
"read_file suggests similar filenames when a file isn't found.",
"read_file auto-deduplicates — re-reading an unchanged file returns a lightweight stub.",
"browser_vision takes a screenshot and analyzes it with AI — works for CAPTCHAs and visual content.",
"browser_console can evaluate JavaScript expressions in the page context.",
"image_generate creates images with FLUX 2 Pro and automatic 2x upscaling.",
"text_to_speech converts text to audio — plays as voice bubbles on Telegram.",
"send_message can reach any connected messaging platform from within a session.",
"The todo tool helps the agent track complex multi-step tasks during a session.",
"session_search performs full-text search across ALL past conversations.",
"The agent automatically saves preferences, corrections, and environment facts to memory.",
"mixture_of_agents routes hard problems through 4 frontier LLMs collaboratively.",
"Terminal commands support background mode with notify_on_complete for long-running tasks.",
"Terminal background processes support watch_patterns to alert on specific output lines.",
"The terminal tool supports 6 backends: local, Docker, SSH, Modal, Daytona, and Singularity.",
# --- Profiles ---
"Each profile gets its own config, API keys, memory, sessions, skills, and cron jobs.",
"Profile names become shell commands — 'hermes profile create coder' creates the 'coder' command.",
"hermes profile export coder -o backup.tar.gz creates a portable profile archive.",
"If two profiles accidentally share a bot token, the second gateway is blocked with a clear error.",
# --- Sessions ---
"Sessions auto-generate descriptive titles after the first exchange — no manual naming needed.",
"Session titles support lineage: \"my project\" → \"my project #2\" → \"my project #3\".",
"When exiting, Hermes prints a resume command with session ID and stats.",
"hermes sessions export backup.jsonl exports all sessions for backup or analysis.",
"hermes -r SESSION_ID resumes any specific past session by its ID.",
# --- Memory ---
"Memory is a frozen snapshot — changes appear in the system prompt only at next session start.",
"Memory entries are automatically scanned for prompt injection and exfiltration patterns.",
"The agent has two memory stores: personal notes (~2200 chars) and user profile (~1375 chars).",
"Corrections you give the agent (\"no, do it this way\") are often auto-saved to memory.",
# --- Skills ---
"Over 80 bundled skills covering github, creative, mlops, productivity, research, and more.",
"Every installed skill automatically becomes a slash command — type / to see them all.",
"hermes skills install official/security/1password installs optional skills from the repo.",
"Skills can restrict to specific OS platforms — some only load on macOS or Linux.",
"skills.external_dirs in config.yaml lets you load skills from custom directories.",
"The agent can create its own skills as procedural memory using skill_manage.",
"The plan skill saves markdown plans under .hermes/plans/ in the active workspace.",
# --- Cron & Scheduling ---
"Cron jobs can attach skills: hermes cron add --skill blogwatcher \"Check for new posts\".",
"Cron delivery targets include telegram, discord, slack, email, sms, and 12+ more platforms.",
"If a cron response starts with [SILENT], delivery is suppressed — useful for monitoring-only jobs.",
"Cron supports relative delays (30m), intervals (every 2h), cron expressions, and ISO timestamps.",
"Cron jobs run in completely fresh agent sessions — prompts must be self-contained.",
# --- Voice ---
"Voice mode works with zero API keys if faster-whisper is installed (free local speech-to-text).",
"Five TTS providers available: Edge TTS (free), ElevenLabs, OpenAI, NeuTTS (free local), MiniMax.",
"/voice on enables voice mode in the CLI. Ctrl+B toggles push-to-talk recording.",
"Streaming TTS plays sentences as they generate — you don't wait for the full response.",
"Voice messages on Telegram, Discord, WhatsApp, and Slack are auto-transcribed.",
# --- Gateway & Messaging ---
"Hermes runs on 18 platforms: Telegram, Discord, Slack, WhatsApp, Signal, Matrix, email, and more.",
"hermes gateway install sets it up as a system service that starts on boot.",
"DingTalk uses Stream Mode — no webhooks or public URL needed.",
"BlueBubbles brings iMessage to Hermes via a local macOS server.",
"Webhook routes support HMAC validation, rate limiting, and event filtering.",
"The API server exposes an OpenAI-compatible endpoint compatible with Open WebUI and LibreChat.",
"Discord voice channel mode: the bot joins VC, transcribes speech, and talks back.",
"group_sessions_per_user: true gives each person their own session in group chats.",
"/sethome marks a chat as the home channel for cron job deliveries.",
"The gateway supports inactivity-based timeouts — active agents can run indefinitely.",
# --- Security ---
"Dangerous command approval has 4 tiers: once, session, always (permanent allowlist), deny.",
"Smart approval mode uses an LLM to auto-approve safe commands and flag dangerous ones.",
"SSRF protection blocks private networks, loopback, link-local, and cloud metadata addresses.",
"Tirith pre-exec scanning detects homograph URL spoofing and pipe-to-interpreter patterns.",
"MCP subprocesses receive a filtered environment — only safe system vars pass through.",
"Context files (.hermes.md, AGENTS.md) are security-scanned for prompt injection before loading.",
"command_allowlist in config.yaml permanently approves specific shell command patterns.",
# --- Context & Compression ---
"Context auto-compresses when it reaches the threshold — memories are flushed and history summarized.",
"The status bar turns yellow, then orange, then red as context fills up.",
"SOUL.md at ~/.hermes/SOUL.md is the agent's primary identity — customize it to shape behavior.",
"Hermes loads project context from .hermes.md, AGENTS.md, CLAUDE.md, or .cursorrules (first match).",
"Subdirectory AGENTS.md files are discovered progressively as the agent navigates into folders.",
"Context files are capped at 20,000 characters with smart head/tail truncation.",
# --- Browser ---
"Five browser providers: local Chromium, Browserbase, Browser Use, Camofox, and Firecrawl.",
"Camofox is an anti-detection browser — Firefox fork with C++ fingerprint spoofing.",
"browser_navigate returns a page snapshot automatically — no need to call browser_snapshot after.",
"browser_vision with annotate=true overlays numbered labels on interactive elements.",
# --- MCP ---
"MCP servers are configured in config.yaml — both stdio and HTTP transports supported.",
"Per-server tool filtering: tools.include whitelists and tools.exclude blacklists specific tools.",
"MCP servers auto-generate toolsets at runtime — hermes tools can toggle them per platform.",
"MCP OAuth support: auth: oauth enables browser-based authorization with PKCE.",
# --- Checkpoints & Rollback ---
"Checkpoints have zero overhead when no files are modified — enabled by default.",
"A pre-rollback snapshot is saved automatically so you can undo the undo.",
"/rollback also undoes the conversation turn, so the agent doesn't remember rolled-back changes.",
"Checkpoints use shadow repos in ~/.hermes/checkpoints/ — your project's .git is never touched.",
# --- Batch & Data ---
"batch_runner.py processes hundreds of prompts in parallel for training data generation.",
"hermes chat -Q enables quiet mode for programmatic use — suppresses banner and spinner.",
"Trajectory saving (--save-trajectories) captures full tool-use traces for model training.",
# --- Plugins ---
"Three plugin types: general (tools/hooks), memory providers, and context engines.",
"hermes plugins install owner/repo installs plugins directly from GitHub.",
"8 external memory providers available: Honcho, OpenViking, Mem0, Hindsight, and more.",
"Plugin hooks include pre_tool_call, post_tool_call, pre_llm_call, and post_llm_call.",
# --- Miscellaneous ---
"Prompt caching (Anthropic) reduces costs by reusing cached system prompt prefixes.",
"The agent auto-generates session titles in a background thread — zero latency impact.",
"Smart model routing can auto-route simple queries to a cheaper model.",
"Slash commands support prefix matching: /h resolves to /help, /mod to /model.",
"Dragging a file path into the terminal auto-attaches images or sends as context.",
".worktreeinclude in your repo root lists gitignored files to copy into worktrees.",
"hermes acp runs Hermes as an ACP server for VS Code, Zed, and JetBrains integration.",
"Custom providers: save named endpoints in config.yaml under custom_providers.",
"HERMES_EPHEMERAL_SYSTEM_PROMPT injects a system prompt that's never persisted to history.",
"credential_pool_strategies supports fill_first, round_robin, least_used, and random rotation.",
"hermes login supports OAuth-based auth for Nous and OpenAI Codex providers.",
"The API server supports both Chat Completions and Responses API with server-side state.",
"tool_preview_length: 0 in config shows full file paths in the spinner's activity feed.",
"hermes status --deep runs deeper diagnostic checks across all components.",
# --- Hidden Gems & Power-User Tricks ---
"BOOT.md at ~/.hermes/BOOT.md runs automatically on every gateway start — use it for startup checks.",
"Cron jobs can attach a Python script (--script) whose stdout is injected into the prompt as context.",
"Cron scripts live in ~/.hermes/scripts/ and run before the agent — perfect for data collection pipelines.",
"prefill_messages_file in config.yaml injects few-shot examples into every API call, never saved to history.",
"SOUL.md completely replaces the agent's default identity — rewrite it to make Hermes your own.",
"SOUL.md is auto-seeded with a default personality on first run. Edit ~/.hermes/SOUL.md to customize.",
"/compress <focus topic> allocates 60-70% of the summary budget to your topic and aggressively trims the rest.",
"On second+ compression, the compressor updates the previous summary instead of starting from scratch.",
"Before a gateway session reset, Hermes auto-flushes important facts to memory in the background.",
"network.force_ipv4: true in config.yaml fixes hangs on servers with broken IPv6 — monkey-patches socket.",
"The terminal tool annotates common exit codes: grep returning 1 = 'No matches found (not an error)'.",
"Failed foreground terminal commands auto-retry up to 3 times with exponential backoff (2s, 4s, 8s).",
"Bare sudo commands are auto-rewritten to pipe SUDO_PASSWORD from .env — no interactive prompt needed.",
"execute_code has built-in helpers: json_parse() for tolerant parsing, shell_quote(), and retry() with backoff.",
"execute_code's 7 sandbox tools (web_search, terminal, read/write/search/patch) use RPC — never enter context.",
"Reading the same file region 3+ times triggers a warning. At 4+, it's hard-blocked to prevent loops.",
"write_file and patch detect if a file was externally modified since the last read and warn about staleness.",
"V4A patch format supports Add File, Delete File, and Move File directives — not just Update.",
"MCP servers can request LLM completions back via sampling — the agent becomes a tool for the server.",
"MCP servers send notifications/tools/list_changed to trigger automatic tool re-registration without restart.",
"delegate_task with acp_command: 'claude' spawns Claude Code as a child agent from any platform.",
"Delegation has a heartbeat thread — child activity propagates to the parent, preventing gateway timeouts.",
"When a provider returns HTTP 402 (payment required), the auxiliary client auto-falls back to the next one.",
"agent.tool_use_enforcement steers models that describe actions instead of calling tools — auto for GPT/Codex.",
"agent.restart_drain_timeout (default 60s) lets running agents finish before a gateway restart takes effect.",
"The gateway caches AIAgent instances per session — destroying this cache breaks Anthropic prompt caching.",
"Any website can expose skills via /.well-known/skills/index.json — the skills hub discovers them automatically.",
"The skills audit log at ~/.hermes/skills/.hub/audit.log tracks every install and removal operation.",
"Stale git worktrees are auto-cleaned: 24-72h old with no unpushed commits get pruned on startup.",
"Each profile gets its own subprocess HOME at HERMES_HOME/home/ — isolated git, ssh, npm, gh configs.",
"HERMES_HOME_MODE env var (octal, e.g. 0701) sets custom directory permissions for web server traversal.",
"Container mode: place .container-mode in HERMES_HOME and the host CLI auto-execs into the container.",
"Ctrl+C has 5 priority tiers: cancel recording → cancel prompts → cancel picker → interrupt agent → exit.",
"Every interrupt during an agent run is logged to ~/.hermes/interrupt_debug.log with timestamps.",
"BROWSER_CDP_URL connects browser tools to any running Chrome — accepts WebSocket, HTTP, or host:port.",
"BROWSERBASE_ADVANCED_STEALTH=true enables advanced anti-detection with custom Chromium (Scale Plan).",
"The CLI auto-switches to compact mode in terminals narrower than 80 columns.",
"Quick commands support two types: exec (run shell command directly) and alias (redirect to another command).",
"Per-task delegation model: delegation.model and delegation.provider in config route subagents to cheaper models.",
"delegation.reasoning_effort independently controls thinking depth for subagents.",
"display.platforms in config.yaml allows per-platform display overrides: {telegram: {tool_progress: all}}.",
"human_delay.mode in config simulates human typing speed — configurable min_ms/max_ms range.",
"Config version migrations run automatically on load — new config keys appear without manual intervention.",
"GPT and Codex models get special system prompt guidance for tool discipline and mandatory tool use.",
"Gemini models get tailored directives for absolute paths, parallel tool calls, and non-interactive commands.",
"context.engine in config.yaml can be set to a plugin name for alternative context management strategies.",
"Browser pages over 8000 tokens are auto-summarized by the auxiliary LLM before returning to the agent.",
"The compressor does a cheap pre-pass: tool outputs over 200 chars are replaced with placeholders before the LLM runs.",
"When compression fails, further attempts are paused for 10 minutes to avoid API hammering.",
"Long dangerous commands (>70 chars) get a 'view' option in the approval prompt to see the full text first.",
"Audio level visualization shows ▁▂▃▄▅▆▇ bars during voice recording based on microphone RMS levels.",
"Profile names cannot collide with existing PATH binaries — 'hermes profile create ls' would be rejected.",
"hermes profile create backup --clone-all copies everything (config, keys, SOUL.md, memories, skills, sessions).",
"The voice record key is configurable via voice.record_key in config.yaml — not just Ctrl+B.",
".cursorrules and .cursor/rules/*.mdc files are auto-detected and loaded as project context.",
"Context files support 10+ prompt injection patterns — invisible Unicode, 'ignore instructions', exfil attempts.",
"GPT-5 and Codex use 'developer' role instead of 'system' in the message format.",
"Per-task auxiliary overrides: auxiliary.vision.provider, auxiliary.compression.model, etc. in config.yaml.",
"The auxiliary client treats 'main' as a provider alias — resolves to your actual primary provider + model.",
"Smart routing can auto-route simple queries to a cheaper model — set smart_model_routing.enabled: true.",
"hermes claw migrate --dry-run previews OpenClaw migration without writing anything.",
"File paths pasted with quotes or escaped spaces are handled automatically — no manual cleanup needed.",
"Slash commands never trigger the large-paste collapse — /command with big arguments works correctly.",
"In interrupt mode, slash commands typed during agent execution bypass interrupt logic and run immediately.",
"HERMES_DEV=1 bypasses container mode detection for local development.",
"Each MCP server gets its own toolset (mcp-servername) that can be toggled independently via hermes tools.",
"MCP ${ENV_VAR} placeholders in config are resolved at server spawn — including vars from ~/.hermes/.env.",
"Skills from trusted repos (NousResearch) get a 'trusted' security level; community skills get extra scanning.",
"The skills quarantine at ~/.hermes/skills/.hub/quarantine/ holds skills pending security review.",
]
def get_random_tip(exclude_recent: int = 0) -> str:
"""Return a random tip string.
Args:
exclude_recent: not used currently; reserved for future
deduplication across sessions.
"""
return random.choice(TIPS)
def get_tip_count() -> int:
"""Return the total number of tips available."""
return len(TIPS)
+1 -6
View File
@@ -475,12 +475,7 @@ def _get_platform_tools(
toolset_names = platform_toolsets.get(platform)
if toolset_names is None or not isinstance(toolset_names, list):
plat_info = PLATFORMS.get(platform)
if plat_info:
default_ts = plat_info["default_toolset"]
else:
# Plugin platform — derive toolset name from platform key
default_ts = f"hermes-{platform}"
default_ts = PLATFORMS[platform]["default_toolset"]
toolset_names = [default_ts]
# YAML may parse bare numeric names (e.g. ``12306:``) as int.
+45
View File
@@ -216,6 +216,51 @@ def get_env_path() -> Path:
return get_hermes_home() / ".env"
# ─── Network Preferences ─────────────────────────────────────────────────────
def apply_ipv4_preference(force: bool = False) -> None:
"""Monkey-patch ``socket.getaddrinfo`` to prefer IPv4 connections.
On servers with broken or unreachable IPv6, Python tries AAAA records
first and hangs for the full TCP timeout before falling back to IPv4.
This affects httpx, requests, urllib, the OpenAI SDK — everything that
uses ``socket.getaddrinfo``.
When *force* is True, patches ``getaddrinfo`` so that calls with
``family=AF_UNSPEC`` (the default) resolve as ``AF_INET`` instead,
skipping IPv6 entirely. If no A record exists, falls back to the
original unfiltered resolution so pure-IPv6 hosts still work.
Safe to call multiple times — only patches once.
Set ``network.force_ipv4: true`` in ``config.yaml`` to enable.
"""
if not force:
return
import socket
# Guard against double-patching
if getattr(socket.getaddrinfo, "_hermes_ipv4_patched", False):
return
_original_getaddrinfo = socket.getaddrinfo
def _ipv4_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
if family == 0: # AF_UNSPEC — caller didn't request a specific family
try:
return _original_getaddrinfo(
host, port, socket.AF_INET, type, proto, flags
)
except socket.gaierror:
# No A record — fall back to full resolution (pure-IPv6 hosts)
return _original_getaddrinfo(host, port, family, type, proto, flags)
return _original_getaddrinfo(host, port, family, type, proto, flags)
_ipv4_getaddrinfo._hermes_ipv4_patched = True # type: ignore[attr-defined]
socket.getaddrinfo = _ipv4_getaddrinfo # type: ignore[assignment]
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models"
@@ -376,6 +376,24 @@ def backup_existing(path: Path, backup_root: Path) -> Optional[Path]:
return dest
# ── Brand rewriting ─────────────────────────────────────────
# Replace OpenClaw brand names with Hermes in migrated text so that
# memory entries, user profiles, SOUL.md, and workspace instructions
# read as self-referential to the new agent identity.
_REBRAND_PATTERNS: List[Tuple[re.Pattern, str]] = [
(re.compile(r'\bOpen[\s-]?Claw\b', re.IGNORECASE), 'Hermes'),
(re.compile(r'\bClawdBot\b', re.IGNORECASE), 'Hermes'),
(re.compile(r'\bMoltBot\b', re.IGNORECASE), 'Hermes'),
]
def rebrand_text(text: str) -> str:
"""Replace OpenClaw / ClawdBot / MoltBot brand names with Hermes."""
for pattern, replacement in _REBRAND_PATTERNS:
text = pattern.sub(replacement, text)
return text
def parse_existing_memory_entries(path: Path) -> List[str]:
if not path.exists():
return []
@@ -782,12 +800,13 @@ class Migrator:
path.write_text("\n".join(entries) + "\n", encoding="utf-8")
return path
def copy_file(self, source: Path, destination: Path, kind: str) -> None:
def copy_file(self, source: Path, destination: Path, kind: str,
transform: Optional[Any] = None) -> None:
if not source or not source.exists():
return
if destination.exists():
if sha256_file(source) == sha256_file(destination):
if not transform and sha256_file(source) == sha256_file(destination):
self.record(kind, source, destination, "skipped", "Target already matches source")
return
if not self.overwrite:
@@ -797,7 +816,13 @@ class Migrator:
if self.execute:
backup_path = self.maybe_backup(destination)
ensure_parent(destination)
shutil.copy2(source, destination)
if transform:
content = read_text(source)
content = transform(content)
destination.write_text(content, encoding="utf-8")
shutil.copystat(source, destination)
else:
shutil.copy2(source, destination)
self.record(kind, source, destination, "migrated", backup=str(backup_path) if backup_path else None)
else:
self.record(kind, source, destination, "migrated", "Would copy")
@@ -807,7 +832,7 @@ class Migrator:
if not source:
self.record("soul", None, self.target_root / "SOUL.md", "skipped", "No OpenClaw SOUL.md found")
return
self.copy_file(source, self.target_root / "SOUL.md", kind="soul")
self.copy_file(source, self.target_root / "SOUL.md", kind="soul", transform=rebrand_text)
def migrate_workspace_agents(self) -> None:
source = self.source_candidate(
@@ -821,7 +846,7 @@ class Migrator:
self.record("workspace-agents", source, None, "skipped", "No workspace target was provided")
return
destination = self.workspace_target / WORKSPACE_INSTRUCTIONS_FILENAME
self.copy_file(source, destination, kind="workspace-agents")
self.copy_file(source, destination, kind="workspace-agents", transform=rebrand_text)
def migrate_memory(self, source: Optional[Path], destination: Path, limit: int, kind: str) -> None:
if not source or not source.exists():
@@ -832,6 +857,7 @@ class Migrator:
if not incoming:
self.record(kind, source, destination, "skipped", "No importable entries found")
return
incoming = [rebrand_text(entry) for entry in incoming]
existing = parse_existing_memory_entries(destination)
merged, stats, overflowed = merge_entries(existing, incoming, limit)
@@ -927,7 +953,7 @@ class Migrator:
def load_openclaw_config(self) -> Dict[str, Any]:
# Check current name and legacy config filenames
for name in ("openclaw.json", "clawdbot.json", "moldbot.json"):
for name in ("openclaw.json", "clawdbot.json", "moltbot.json"):
config_path = self.source_root / name
if config_path.exists():
try:
@@ -997,7 +1023,17 @@ class Migrator:
.get("workspace")
)
if isinstance(workspace, str) and workspace.strip():
additions["MESSAGING_CWD"] = workspace.strip()
ws_path = workspace.strip()
# Skip if the workspace points inside the OpenClaw source directory —
# that path will be stale after migration and would cause the Hermes
# gateway to use the old OpenClaw workspace as its cwd, picking up
# OpenClaw's AGENTS.md, MEMORY.md, etc.
try:
inside_source = Path(ws_path).resolve().is_relative_to(self.source_root.resolve())
except (ValueError, OSError):
inside_source = False
if not inside_source:
additions["MESSAGING_CWD"] = ws_path
allowlist_path = self.source_root / "credentials" / "telegram-default-allowFrom.json"
if allowlist_path.exists():
@@ -1543,6 +1579,7 @@ class Migrator:
if not all_incoming:
self.record("daily-memory", source_dir, destination, "skipped", "No importable entries found in daily memory files")
return
all_incoming = [rebrand_text(entry) for entry in all_incoming]
existing = parse_existing_memory_entries(destination)
merged, stats, overflowed = merge_entries(existing, all_incoming, self.memory_limit)
-12
View File
@@ -1,12 +0,0 @@
name: irc-platform
version: 1.0.0
description: >
IRC gateway adapter for Hermes Agent.
Connects to an IRC server and relays messages between an IRC channel
(or DMs) and the Hermes agent. No external dependencies — uses
Python's stdlib asyncio for the IRC protocol.
author: Nous Research
requires_env:
- IRC_SERVER
- IRC_CHANNEL
- IRC_NICKNAME
-530
View File
@@ -1,530 +0,0 @@
"""
IRC Platform Adapter for Hermes Agent.
A plugin-based gateway adapter that connects to an IRC server and relays
messages to/from the Hermes agent. Zero external dependencies — uses
Python's stdlib asyncio for the IRC protocol.
Configuration in config.yaml::
gateway:
platforms:
irc:
enabled: true
extra:
server: irc.libera.chat
port: 6697
nickname: hermes-bot
channel: "#hermes"
use_tls: true
server_password: "" # optional server password
nickserv_password: "" # optional NickServ identification
allowed_users: [] # empty = allow all, or list of nicks
max_message_length: 450 # IRC line limit (safe default)
Or via environment variables (overrides config.yaml):
IRC_SERVER, IRC_PORT, IRC_NICKNAME, IRC_CHANNEL, IRC_USE_TLS,
IRC_SERVER_PASSWORD, IRC_NICKSERV_PASSWORD
"""
import asyncio
import logging
import os
import re
import ssl
import time
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Lazy import: BasePlatformAdapter and friends live in the main repo.
# We import at function/class level to avoid import errors when the plugin
# is discovered but the gateway hasn't been fully initialised yet.
# ---------------------------------------------------------------------------
from gateway.platforms.base import (
BasePlatformAdapter,
SendResult,
MessageEvent,
MessageType,
)
from gateway.session import SessionSource
from gateway.config import PlatformConfig, Platform
def _ensure_imports():
"""No-op — kept for backward compatibility with any call sites."""
pass
# ---------------------------------------------------------------------------
# IRC protocol helpers
# ---------------------------------------------------------------------------
def _parse_irc_message(raw: str) -> dict:
"""Parse a raw IRC protocol line into components.
Returns dict with keys: prefix, command, params.
"""
prefix = ""
trailing = ""
if raw.startswith(":"):
prefix, raw = raw[1:].split(" ", 1)
if " :" in raw:
raw, trailing = raw.split(" :", 1)
parts = raw.split()
command = parts[0] if parts else ""
params = parts[1:] if len(parts) > 1 else []
if trailing:
params.append(trailing)
return {"prefix": prefix, "command": command, "params": params}
def _extract_nick(prefix: str) -> str:
"""Extract nickname from IRC prefix (nick!user@host)."""
return prefix.split("!")[0] if "!" in prefix else prefix
# ---------------------------------------------------------------------------
# IRC Adapter
# ---------------------------------------------------------------------------
class IRCAdapter(BasePlatformAdapter):
"""Async IRC adapter implementing the BasePlatformAdapter interface.
This class is instantiated by the adapter_factory passed to
register_platform().
"""
def __init__(self, config, **kwargs):
platform = Platform("irc")
super().__init__(config=config, platform=platform)
extra = getattr(config, "extra", {}) or {}
# Connection settings (env vars override config.yaml)
self.server = os.getenv("IRC_SERVER") or extra.get("server", "")
self.port = int(os.getenv("IRC_PORT") or extra.get("port", 6697))
self.nickname = os.getenv("IRC_NICKNAME") or extra.get("nickname", "hermes-bot")
self.channel = os.getenv("IRC_CHANNEL") or extra.get("channel", "")
self.use_tls = (
os.getenv("IRC_USE_TLS", "").lower() in ("1", "true", "yes")
if os.getenv("IRC_USE_TLS")
else extra.get("use_tls", True)
)
self.server_password = os.getenv("IRC_SERVER_PASSWORD") or extra.get("server_password", "")
self.nickserv_password = os.getenv("IRC_NICKSERV_PASSWORD") or extra.get("nickserv_password", "")
# Auth
self.allowed_users: list = extra.get("allowed_users", [])
# IRC limits
self.max_message_length = int(extra.get("max_message_length", 450))
# Runtime state
self._reader: Optional[asyncio.StreamReader] = None
self._writer: Optional[asyncio.StreamWriter] = None
self._recv_task: Optional[asyncio.Task] = None
self._current_nick = self.nickname
self._registered = False # IRC registration complete
self._registration_event = asyncio.Event()
@property
def name(self) -> str:
return "IRC"
# ── Connection lifecycle ──────────────────────────────────────────────
async def connect(self) -> bool:
"""Connect to the IRC server, register, and join the channel."""
if not self.server or not self.channel:
logger.error("IRC: server and channel must be configured")
self._set_fatal_error(
"config_missing",
"IRC_SERVER and IRC_CHANNEL must be set",
retryable=False,
)
return False
# Prevent two profiles from using the same IRC identity
try:
from gateway.status import acquire_scoped_lock, release_scoped_lock
lock_key = f"{self.server}:{self.nickname}"
if not acquire_scoped_lock("irc", lock_key):
logger.error("IRC: %s@%s already in use by another profile", self.nickname, self.server)
self._set_fatal_error("lock_conflict", "IRC identity in use by another profile", retryable=False)
return False
self._lock_key = lock_key
except ImportError:
self._lock_key = None # status module not available (e.g. tests)
try:
ssl_ctx = None
if self.use_tls:
ssl_ctx = ssl.create_default_context()
self._reader, self._writer = await asyncio.wait_for(
asyncio.open_connection(self.server, self.port, ssl=ssl_ctx),
timeout=30.0,
)
except Exception as e:
logger.error("IRC: failed to connect to %s:%s — %s", self.server, self.port, e)
self._set_fatal_error("connect_failed", str(e), retryable=True)
return False
# IRC registration sequence
if self.server_password:
await self._send_raw(f"PASS {self.server_password}")
await self._send_raw(f"NICK {self.nickname}")
await self._send_raw(f"USER {self.nickname} 0 * :Hermes Agent")
# Start receive loop
self._recv_task = asyncio.create_task(self._receive_loop())
# Wait for registration (001 RPL_WELCOME) with timeout
try:
await asyncio.wait_for(self._registration_event.wait(), timeout=30.0)
except asyncio.TimeoutError:
logger.error("IRC: registration timed out")
await self.disconnect()
self._set_fatal_error("registration_timeout", "IRC server did not send RPL_WELCOME", retryable=True)
return False
# NickServ identification
if self.nickserv_password:
await self._send_raw(f"PRIVMSG NickServ :IDENTIFY {self.nickserv_password}")
await asyncio.sleep(2) # Give NickServ time to process
# Join channel
await self._send_raw(f"JOIN {self.channel}")
self._mark_connected()
logger.info("IRC: connected to %s:%s as %s, joined %s", self.server, self.port, self._current_nick, self.channel)
return True
async def disconnect(self) -> None:
"""Quit and close the connection."""
# Release the scoped lock so another profile can use this identity
if getattr(self, "_lock_key", None):
try:
from gateway.status import release_scoped_lock
release_scoped_lock("irc", self._lock_key)
except Exception:
pass
self._mark_disconnected()
if self._writer and not self._writer.is_closing():
try:
await self._send_raw("QUIT :Hermes Agent shutting down")
await asyncio.sleep(0.5)
except Exception:
pass
try:
self._writer.close()
await self._writer.wait_closed()
except Exception:
pass
if self._recv_task and not self._recv_task.done():
self._recv_task.cancel()
try:
await self._recv_task
except asyncio.CancelledError:
pass
self._reader = None
self._writer = None
self._registered = False
self._registration_event.clear()
# ── Sending ───────────────────────────────────────────────────────────
async def send(
self,
chat_id: str,
content: str,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
):
if not self._writer or self._writer.is_closing():
return SendResult(success=False, error="Not connected")
target = chat_id # channel name or nick for DMs
lines = self._split_message(content, target)
for line in lines:
try:
await self._send_raw(f"PRIVMSG {target} :{line}")
# Basic rate limiting to avoid excess flood
await asyncio.sleep(0.3)
except Exception as e:
return SendResult(success=False, error=str(e))
return SendResult(success=True, message_id=str(int(time.time() * 1000)))
async def send_typing(self, chat_id: str, metadata=None) -> None:
"""IRC has no typing indicator — no-op."""
pass
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
is_channel = chat_id.startswith("#") or chat_id.startswith("&")
return {
"name": chat_id,
"type": "group" if is_channel else "dm",
}
# ── Message splitting ─────────────────────────────────────────────────
def _split_message(self, content: str, target: str) -> List[str]:
"""Split a long message into IRC-safe chunks.
IRC has a ~512 byte line limit. After accounting for protocol
overhead (``PRIVMSG <target> :``), we split content into chunks.
"""
# Strip markdown formatting that doesn't render in IRC
content = self._strip_markdown(content)
overhead = len(f"PRIVMSG {target} :".encode("utf-8")) + 2 # +2 for \r\n
max_bytes = 510 - overhead
max_chars = min(self.max_message_length, max_bytes)
lines: List[str] = []
for paragraph in content.split("\n"):
if not paragraph.strip():
continue
while len(paragraph) > max_chars:
# Find a space to break at
split_at = paragraph.rfind(" ", 0, max_chars)
if split_at < max_chars // 3:
split_at = max_chars
lines.append(paragraph[:split_at])
paragraph = paragraph[split_at:].lstrip()
if paragraph.strip():
lines.append(paragraph)
return lines if lines else [""]
@staticmethod
def _strip_markdown(text: str) -> str:
"""Convert basic markdown to plain text for IRC."""
# Bold: **text** or __text__ → text
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
text = re.sub(r"__(.+?)__", r"\1", text)
# Italic: *text* or _text_ → text
text = re.sub(r"\*(.+?)\*", r"\1", text)
text = re.sub(r"(?<!\w)_(.+?)_(?!\w)", r"\1", text)
# Inline code: `text` → text
text = re.sub(r"`(.+?)`", r"\1", text)
# Code blocks: ```...``` → content
text = re.sub(r"```\w*\n?", "", text)
# Images: ![alt](url) → url (must come BEFORE links)
text = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", r"\2", text)
# Links: [text](url) → text (url)
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", text)
return text
# ── Raw IRC I/O ──────────────────────────────────────────────────────
async def _send_raw(self, line: str) -> None:
"""Send a raw IRC protocol line."""
if not self._writer or self._writer.is_closing():
return
encoded = (line + "\r\n").encode("utf-8")
self._writer.write(encoded)
await self._writer.drain()
async def _receive_loop(self) -> None:
"""Main receive loop — reads lines and dispatches them."""
buffer = b""
try:
while self._reader and not self._reader.at_eof():
data = await self._reader.read(4096)
if not data:
break
buffer += data
while b"\r\n" in buffer:
line, buffer = buffer.split(b"\r\n", 1)
try:
decoded = line.decode("utf-8", errors="replace")
await self._handle_line(decoded)
except Exception as e:
logger.warning("IRC: error handling line: %s", e)
except asyncio.CancelledError:
raise
except Exception as e:
logger.error("IRC: receive loop error: %s", e)
finally:
if self.is_connected:
logger.warning("IRC: connection lost, marking disconnected")
self._set_fatal_error("connection_lost", "IRC connection closed unexpectedly", retryable=True)
await self._notify_fatal_error()
async def _handle_line(self, raw: str) -> None:
"""Dispatch a single IRC protocol line."""
msg = _parse_irc_message(raw)
command = msg["command"]
params = msg["params"]
# PING/PONG keepalive
if command == "PING":
payload = params[0] if params else ""
await self._send_raw(f"PONG :{payload}")
return
# RPL_WELCOME (001) — registration complete
if command == "001":
self._registered = True
self._registration_event.set()
if params:
# Server may confirm our nick in the first param
self._current_nick = params[0]
return
# ERR_NICKNAMEINUSE (433) — nick collision during registration
if command == "433":
self._current_nick = self.nickname + "_"
await self._send_raw(f"NICK {self._current_nick}")
return
# PRIVMSG — incoming message (channel or DM)
if command == "PRIVMSG" and len(params) >= 2:
sender_nick = _extract_nick(msg["prefix"])
target = params[0]
text = params[1]
# Ignore our own messages
if sender_nick.lower() == self._current_nick.lower():
return
# CTCP ACTION (/me) — convert to text
if text.startswith("\x01ACTION ") and text.endswith("\x01"):
text = f"* {sender_nick} {text[8:-1]}"
# Ignore other CTCP
if text.startswith("\x01"):
return
# Determine if this is a channel message or DM
is_channel = target.startswith("#") or target.startswith("&")
chat_id = target if is_channel else sender_nick
chat_type = "group" if is_channel else "dm"
# In channels, only respond if addressed (nick: or nick,)
if is_channel:
addressed = False
for prefix in (f"{self._current_nick}:", f"{self._current_nick},",
f"{self._current_nick} "):
if text.lower().startswith(prefix.lower()):
text = text[len(prefix):].strip()
addressed = True
break
if not addressed:
return # Ignore unaddressed channel messages
# Auth check
if self.allowed_users and sender_nick not in self.allowed_users:
logger.debug("IRC: ignoring message from unauthorized user %s", sender_nick)
return
await self._dispatch_message(
text=text,
chat_id=chat_id,
chat_type=chat_type,
user_id=sender_nick,
user_name=sender_nick,
)
# NICK — track our own nick changes
if command == "NICK" and _extract_nick(msg["prefix"]).lower() == self._current_nick.lower():
if params:
self._current_nick = params[0]
async def _dispatch_message(
self,
text: str,
chat_id: str,
chat_type: str,
user_id: str,
user_name: str,
) -> None:
"""Build a MessageEvent and hand it to the base class handler."""
if not self._message_handler:
return
source = self.build_source(
chat_id=chat_id,
chat_name=chat_id,
chat_type=chat_type,
user_id=user_id,
user_name=user_name,
)
event = MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=source,
message_id=str(int(time.time() * 1000)),
timestamp=__import__("datetime").datetime.now(),
)
await self.handle_message(event)
# ---------------------------------------------------------------------------
# Plugin registration
# ---------------------------------------------------------------------------
def check_requirements() -> bool:
"""Check if IRC is configured.
Only requires the server and channel — no external pip packages needed.
"""
server = os.getenv("IRC_SERVER", "")
channel = os.getenv("IRC_CHANNEL", "")
# Also accept config.yaml-only configuration (no env vars).
# The gateway passes PlatformConfig; we just check env for the
# hermes setup / requirements check path.
return bool(server and channel)
def validate_config(config) -> bool:
"""Validate that the platform config has enough info to connect."""
extra = getattr(config, "extra", {}) or {}
server = os.getenv("IRC_SERVER") or extra.get("server", "")
channel = os.getenv("IRC_CHANNEL") or extra.get("channel", "")
return bool(server and channel)
def register(ctx):
"""Plugin entry point — called by the Hermes plugin system."""
ctx.register_platform(
name="irc",
label="IRC",
adapter_factory=lambda cfg: IRCAdapter(cfg),
check_fn=check_requirements,
validate_config=validate_config,
required_env=["IRC_SERVER", "IRC_CHANNEL", "IRC_NICKNAME"],
install_hint="No extra packages needed (stdlib only)",
# Auth env vars for _is_user_authorized() integration
allowed_users_env="IRC_ALLOWED_USERS",
allow_all_env="IRC_ALLOW_ALL_USERS",
# IRC line limit after protocol overhead
max_message_length=450,
# Display
emoji="💬",
# IRC doesn't have phone numbers to redact
pii_safe=False,
allow_update_command=True,
# LLM guidance
platform_hint=(
"You are chatting via IRC. IRC does not support markdown formatting "
"— use plain text only. Messages are limited to ~450 characters per "
"line (long messages are automatically split). In channels, users "
"address you by prefixing your nick. Keep responses concise and "
"conversational."
),
)
+9 -12
View File
@@ -3181,15 +3181,6 @@ class AIAgent:
platform_key = (self.platform or "").lower().strip()
if platform_key in PLATFORM_HINTS:
prompt_parts.append(PLATFORM_HINTS[platform_key])
elif platform_key:
# Check plugin registry for platform-specific LLM guidance
try:
from gateway.platform_registry import platform_registry
_entry = platform_registry.get(platform_key)
if _entry and _entry.platform_hint:
prompt_parts.append(_entry.platform_hint)
except Exception:
pass
return "\n\n".join(p.strip() for p in prompt_parts if p.strip())
@@ -6557,17 +6548,23 @@ class AIAgent:
if messages and messages[-1].get("_flush_sentinel") == _sentinel:
messages.pop()
def _compress_context(self, messages: list, system_message: str, *, approx_tokens: int = None, task_id: str = "default") -> tuple:
def _compress_context(self, messages: list, system_message: str, *, approx_tokens: int = None, task_id: str = "default", focus_topic: str = None) -> tuple:
"""Compress conversation context and split the session in SQLite.
Args:
focus_topic: Optional focus string for guided compression — the
summariser will prioritise preserving information related to
this topic. Inspired by Claude Code's ``/compact <focus>``.
Returns:
(compressed_messages, new_system_prompt) tuple
"""
_pre_msg_count = len(messages)
logger.info(
"context compression started: session=%s messages=%d tokens=~%s model=%s",
"context compression started: session=%s messages=%d tokens=~%s model=%s focus=%r",
self.session_id or "none", _pre_msg_count,
f"{approx_tokens:,}" if approx_tokens else "unknown", self.model,
focus_topic,
)
# Pre-compression memory flush: let the model save memories before they're lost
self.flush_memories(messages, min_turns=0)
@@ -6579,7 +6576,7 @@ class AIAgent:
except Exception:
pass
compressed = self.context_compressor.compress(messages, current_tokens=approx_tokens)
compressed = self.context_compressor.compress(messages, current_tokens=approx_tokens, focus_topic=focus_topic)
todo_snapshot = self._todo_store.format_for_injection()
if todo_snapshot:
+209 -303
View File
@@ -1,51 +1,29 @@
---
name: github-code-review
description: Review code changes by analyzing git diffs, leaving inline comments on PRs, and performing thorough pre-push review. Works with gh CLI or falls back to git + GitHub REST API via curl.
version: 1.1.0
description: Review code changes by analyzing git diffs, leaving inline comments on PRs, and performing thorough pre-push review. Uses GitHub MCP tools (mcp_github_*) as the primary interface, with git CLI for local diff operations.
version: 2.0.0
author: Hermes Agent
license: MIT
metadata:
hermes:
tags: [GitHub, Code-Review, Pull-Requests, Git, Quality]
tags: [GitHub, Code-Review, Pull-Requests, Git, Quality, MCP]
related_skills: [github-auth, github-pr-workflow]
---
# GitHub Code Review
Perform code reviews on local changes before pushing, or review open PRs on GitHub. Most of this skill uses plain `git` — the `gh`/`curl` split only matters for PR-level interactions.
Perform code reviews on local changes before pushing, or review open PRs on GitHub. This skill uses **GitHub MCP tools** (`mcp_github_*`) as the primary interface for all GitHub API interactions, with plain `git` for local diff operations.
## Prerequisites
- Authenticated with GitHub (see `github-auth` skill)
- Inside a git repository
### Setup (for PR interactions)
```bash
if command -v gh &>/dev/null && gh auth status &>/dev/null; then
AUTH="gh"
else
AUTH="git"
if [ -z "$GITHUB_TOKEN" ]; then
if [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then
GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r')
elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then
GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|')
fi
fi
fi
REMOTE_URL=$(git remote get-url origin)
OWNER_REPO=$(echo "$REMOTE_URL" | sed -E 's|.*github\.com[:/]||; s|\.git$||')
OWNER=$(echo "$OWNER_REPO" | cut -d/ -f1)
REPO=$(echo "$OWNER_REPO" | cut -d/ -f2)
```
- GitHub MCP server configured (provides `mcp_github_*` tools)
- Inside a git repository (for local diff operations)
---
## 1. Reviewing Local Changes (Pre-Push)
This is pure `git` — works everywhere, no API needed.
Local diffs use plain `git` — no API needed.
### Get the Diff
@@ -122,158 +100,206 @@ When reviewing local changes, present findings in this structure:
---
## 2. Reviewing a Pull Request on GitHub
## 2. Reviewing a Pull Request on GitHub (MCP Tools)
### View PR Details
### Step 1: Gather PR Context
**With gh:**
Use MCP tools to get PR metadata, description, and changed files:
```bash
gh pr view 123
gh pr diff 123
gh pr diff 123 --name-only
```
# Get PR details (title, author, description, branch, status)
mcp_github_pull_request_read(method="get", owner=OWNER, repo=REPO, pullNumber=PR_NUMBER)
# Get the diff
mcp_github_pull_request_read(method="get_diff", owner=OWNER, repo=REPO, pullNumber=PR_NUMBER)
# Get list of changed files with additions/deletions
mcp_github_pull_request_read(method="get_files", owner=OWNER, repo=REPO, pullNumber=PR_NUMBER)
# Get CI/CD status
mcp_github_pull_request_read(method="get_status", owner=OWNER, repo=REPO, pullNumber=PR_NUMBER)
# Get check runs (individual CI jobs)
mcp_github_pull_request_read(method="get_check_runs", owner=OWNER, repo=REPO, pullNumber=PR_NUMBER)
```
**With git + curl:**
### Step 2: Read File Contents for Context
```bash
PR_NUMBER=123
For each changed file, read the full file to understand the surrounding context:
# Get PR details
curl -s \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/pulls/$PR_NUMBER \
| python3 -c "
import sys, json
pr = json.load(sys.stdin)
print(f\"Title: {pr['title']}\")
print(f\"Author: {pr['user']['login']}\")
print(f\"Branch: {pr['head']['ref']} -> {pr['base']['ref']}\")
print(f\"State: {pr['state']}\")
print(f\"Body:\n{pr['body']}\")"
# List changed files
curl -s \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/pulls/$PR_NUMBER/files \
| python3 -c "
import sys, json
for f in json.load(sys.stdin):
print(f\"{f['status']:10} +{f['additions']:-4} -{f['deletions']:-4} {f['filename']}\")"
```
# Read specific files from the PR branch
mcp_github_get_file_contents(owner=OWNER, repo=REPO, path="src/auth/login.py", ref="refs/pull/PR_NUMBER/head")
```
### Check Out PR Locally for Full Review
### Step 3: Check Out Locally (Optional — for running tests)
This works with plain `git` — no `gh` needed:
If you need to run tests or linters locally:
```bash
# Fetch the PR branch and check it out
git fetch origin pull/123/head:pr-123
git checkout pr-123
git fetch origin pull/PR_NUMBER/head:pr-PR_NUMBER
git checkout pr-PR_NUMBER
# Now you can use read_file, search_files, run tests, etc.
# Run tests
python -m pytest 2>&1 | tail -20
# View diff against the base branch
git diff main...pr-123
# Run linter
ruff check . 2>&1 | head -30
```
**With gh (shortcut):**
### Step 4: Get Existing Review Comments
Check what's already been discussed:
```
# Get review threads (grouped comments on code locations)
mcp_github_pull_request_read(method="get_review_comments", owner=OWNER, repo=REPO, pullNumber=PR_NUMBER)
# Get general PR comments
mcp_github_pull_request_read(method="get_comments", owner=OWNER, repo=REPO, pullNumber=PR_NUMBER)
# Get formal reviews (approvals, change requests)
mcp_github_pull_request_read(method="get_reviews", owner=OWNER, repo=REPO, pullNumber=PR_NUMBER)
```
### Step 5: Apply the Review Checklist (Section 3)
Go through each category systematically.
### Step 6: Submit a Formal Review with Inline Comments
Use the MCP review tools to submit findings:
**Create a pending review, add inline comments, then submit:**
```
# Step A: Create a pending review (omit "event" to keep it pending)
mcp_github_pull_request_review_write(
method="create",
owner=OWNER,
repo=REPO,
pullNumber=PR_NUMBER
)
# Step B: Add inline comments to the pending review
mcp_github_add_comment_to_pending_review(
owner=OWNER,
repo=REPO,
pullNumber=PR_NUMBER,
path="src/auth.py",
line=45,
body="🔴 **Critical:** User input passed directly to SQL query — use parameterized queries.",
subjectType="LINE",
side="RIGHT"
)
mcp_github_add_comment_to_pending_review(
owner=OWNER,
repo=REPO,
pullNumber=PR_NUMBER,
path="src/models/user.py",
line=23,
body="⚠️ **Warning:** Password stored without hashing. Use bcrypt or argon2.",
subjectType="LINE",
side="RIGHT"
)
# Step C: Submit the pending review
mcp_github_pull_request_review_write(
method="submit_pending",
owner=OWNER,
repo=REPO,
pullNumber=PR_NUMBER,
event="REQUEST_CHANGES", # or "APPROVE" or "COMMENT"
body="## Hermes Agent Review\n\nFound 2 issues. See inline comments."
)
```
**Or submit a review directly (no pending step):**
```
# Approve
mcp_github_pull_request_review_write(
method="create",
owner=OWNER,
repo=REPO,
pullNumber=PR_NUMBER,
event="APPROVE",
body="LGTM! Code looks clean — good test coverage, no security concerns."
)
# Request changes
mcp_github_pull_request_review_write(
method="create",
owner=OWNER,
repo=REPO,
pullNumber=PR_NUMBER,
event="REQUEST_CHANGES",
body="Found a few issues — see inline comments."
)
```
### Step 7: Post a Summary Comment
Leave a top-level summary so the PR author gets the full picture:
```
mcp_github_add_issue_comment(
owner=OWNER,
repo=REPO,
issue_number=PR_NUMBER,
body="""## Code Review Summary
**Verdict: Changes Requested** (2 issues, 1 suggestion)
### 🔴 Critical
- **src/auth.py:45** — SQL injection vulnerability
### ⚠️ Warnings
- **src/models.py:23** — Plaintext password storage
### 💡 Suggestions
- **src/utils.py:8** — Duplicated logic, consider consolidating
### ✅ Looks Good
- Clean API design
- Good error handling in the middleware layer
---
*Reviewed by Hermes Agent*"""
)
```
### Step 8: Reply to Existing Comments
If the PR author responds to your review:
```
# Reply to a specific review comment
mcp_github_add_reply_to_pull_request_comment(
owner=OWNER,
repo=REPO,
pullNumber=PR_NUMBER,
commentId=COMMENT_ID,
body="Good point! That approach works too."
)
```
### Step 9: Request Copilot Review (Optional)
For automated AI feedback before your review:
```
mcp_github_request_copilot_review(owner=OWNER, repo=REPO, pullNumber=PR_NUMBER)
```
### Step 10: Clean Up (if checked out locally)
```bash
gh pr checkout 123
git checkout main
git branch -D pr-PR_NUMBER
```
### Leave Comments on a PR
**General PR comment — with gh:**
```bash
gh pr comment 123 --body "Overall looks good, a few suggestions below."
```
**General PR comment — with curl:**
```bash
curl -s -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/issues/$PR_NUMBER/comments \
-d '{"body": "Overall looks good, a few suggestions below."}'
```
### Leave Inline Review Comments
**Single inline comment — with gh (via API):**
```bash
HEAD_SHA=$(gh pr view 123 --json headRefOid --jq '.headRefOid')
gh api repos/$OWNER/$REPO/pulls/123/comments \
--method POST \
-f body="This could be simplified with a list comprehension." \
-f path="src/auth/login.py" \
-f commit_id="$HEAD_SHA" \
-f line=45 \
-f side="RIGHT"
```
**Single inline comment — with curl:**
```bash
# Get the head commit SHA
HEAD_SHA=$(curl -s \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/pulls/$PR_NUMBER \
| python3 -c "import sys,json; print(json.load(sys.stdin)['head']['sha'])")
curl -s -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/pulls/$PR_NUMBER/comments \
-d "{
\"body\": \"This could be simplified with a list comprehension.\",
\"path\": \"src/auth/login.py\",
\"commit_id\": \"$HEAD_SHA\",
\"line\": 45,
\"side\": \"RIGHT\"
}"
```
### Submit a Formal Review (Approve / Request Changes)
**With gh:**
```bash
gh pr review 123 --approve --body "LGTM!"
gh pr review 123 --request-changes --body "See inline comments."
gh pr review 123 --comment --body "Some suggestions, nothing blocking."
```
**With curl — multi-comment review submitted atomically:**
```bash
HEAD_SHA=$(curl -s \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/pulls/$PR_NUMBER \
| python3 -c "import sys,json; print(json.load(sys.stdin)['head']['sha'])")
curl -s -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$OWNER/$REPO/pulls/$PR_NUMBER/reviews \
-d "{
\"commit_id\": \"$HEAD_SHA\",
\"event\": \"COMMENT\",
\"body\": \"Code review from Hermes Agent\",
\"comments\": [
{\"path\": \"src/auth.py\", \"line\": 45, \"body\": \"Use parameterized queries to prevent SQL injection.\"},
{\"path\": \"src/models/user.py\", \"line\": 23, \"body\": \"Hash passwords with bcrypt before storing.\"},
{\"path\": \"tests/test_auth.py\", \"line\": 1, \"body\": \"Add test for expired token edge case.\"}
]
}"
```
Event values: `"APPROVE"`, `"REQUEST_CHANGES"`, `"COMMENT"`
The `line` field refers to the line number in the *new* version of the file. For deleted lines, use `"side": "LEFT"`.
---
## 3. Review Checklist
@@ -290,6 +316,7 @@ When performing a code review (local or PR), systematically check:
- Input validation on user-facing inputs
- No SQL injection, XSS, or path traversal
- Auth/authz checks where needed
- Use `mcp_github_run_secret_scanning` on changed files for automated secret detection
### Code Quality
- Clear naming (variables, functions, classes)
@@ -327,151 +354,30 @@ When the user asks you to "review the code" or "check before pushing":
---
## 5. PR Review Workflow (End-to-End)
## 5. PR Review Workflow (End-to-End with MCP Tools)
When the user asks you to "review PR #N", "look at this PR", or gives you a PR URL, follow this recipe:
When the user asks you to "review PR #N", "look at this PR", or gives you a PR URL:
### Step 1: Set up environment
### Quick Reference
```bash
source ~/.hermes/skills/github/github-auth/scripts/gh-env.sh
# Or run the inline setup block from the top of this skill
```
### Step 2: Gather PR context
Get the PR metadata, description, and list of changed files to understand scope before diving into code.
**With gh:**
```bash
gh pr view 123
gh pr diff 123 --name-only
gh pr checks 123
```
**With curl:**
```bash
PR_NUMBER=123
# PR details (title, author, description, branch)
curl -s -H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$GH_OWNER/$GH_REPO/pulls/$PR_NUMBER
# Changed files with line counts
curl -s -H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$GH_OWNER/$GH_REPO/pulls/$PR_NUMBER/files
```
### Step 3: Check out the PR locally
This gives you full access to `read_file`, `search_files`, and the ability to run tests.
```bash
git fetch origin pull/$PR_NUMBER/head:pr-$PR_NUMBER
git checkout pr-$PR_NUMBER
```
### Step 4: Read the diff and understand changes
```bash
# Full diff against the base branch
git diff main...HEAD
# Or file-by-file for large PRs
git diff main...HEAD --name-only
# Then for each file:
git diff main...HEAD -- path/to/file.py
```
For each changed file, use `read_file` to see full context around the changes — diffs alone can miss issues visible only with surrounding code.
### Step 5: Run automated checks locally (if applicable)
```bash
# Run tests if there's a test suite
python -m pytest 2>&1 | tail -20
# or: npm test, cargo test, go test ./..., etc.
# Run linter if configured
ruff check . 2>&1 | head -30
# or: eslint, clippy, etc.
```
### Step 6: Apply the review checklist (Section 3)
Go through each category: Correctness, Security, Code Quality, Testing, Performance, Documentation.
### Step 7: Post the review to GitHub
Collect your findings and submit them as a formal review with inline comments.
**With gh:**
```bash
# If no issues — approve
gh pr review $PR_NUMBER --approve --body "Reviewed by Hermes Agent. Code looks clean — good test coverage, no security concerns."
# If issues found — request changes with inline comments
gh pr review $PR_NUMBER --request-changes --body "Found a few issues — see inline comments."
```
**With curl — atomic review with multiple inline comments:**
```bash
HEAD_SHA=$(curl -s -H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$GH_OWNER/$GH_REPO/pulls/$PR_NUMBER \
| python3 -c "import sys,json; print(json.load(sys.stdin)['head']['sha'])")
# Build the review JSON — event is APPROVE, REQUEST_CHANGES, or COMMENT
curl -s -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$GH_OWNER/$GH_REPO/pulls/$PR_NUMBER/reviews \
-d "{
\"commit_id\": \"$HEAD_SHA\",
\"event\": \"REQUEST_CHANGES\",
\"body\": \"## Hermes Agent Review\n\nFound 2 issues, 1 suggestion. See inline comments.\",
\"comments\": [
{\"path\": \"src/auth.py\", \"line\": 45, \"body\": \"🔴 **Critical:** User input passed directly to SQL query — use parameterized queries.\"},
{\"path\": \"src/models.py\", \"line\": 23, \"body\": \"⚠️ **Warning:** Password stored without hashing.\"},
{\"path\": \"src/utils.py\", \"line\": 8, \"body\": \"💡 **Suggestion:** This duplicates logic in core/utils.py:34.\"}
]
}"
```
### Step 8: Also post a summary comment
In addition to inline comments, leave a top-level summary so the PR author gets the full picture at a glance. Use the review output format from `references/review-output-template.md`.
**With gh:**
```bash
gh pr comment $PR_NUMBER --body "$(cat <<'EOF'
## Code Review Summary
**Verdict: Changes Requested** (2 issues, 1 suggestion)
### 🔴 Critical
- **src/auth.py:45** — SQL injection vulnerability
### ⚠️ Warnings
- **src/models.py:23** — Plaintext password storage
### 💡 Suggestions
- **src/utils.py:8** — Duplicated logic, consider consolidating
### ✅ Looks Good
- Clean API design
- Good error handling in the middleware layer
---
*Reviewed by Hermes Agent*
EOF
)"
```
### Step 9: Clean up
```bash
git checkout main
git branch -D pr-$PR_NUMBER
```
| Task | MCP Tool |
|------|----------|
| Get PR details | `mcp_github_pull_request_read(method="get")` |
| Get PR diff | `mcp_github_pull_request_read(method="get_diff")` |
| Get changed files | `mcp_github_pull_request_read(method="get_files")` |
| Get CI status | `mcp_github_pull_request_read(method="get_status")` |
| Get check runs | `mcp_github_pull_request_read(method="get_check_runs")` |
| Read file contents | `mcp_github_get_file_contents(ref="refs/pull/N/head")` |
| Get review threads | `mcp_github_pull_request_read(method="get_review_comments")` |
| Get PR comments | `mcp_github_pull_request_read(method="get_comments")` |
| Get reviews | `mcp_github_pull_request_read(method="get_reviews")` |
| Create pending review | `mcp_github_pull_request_review_write(method="create")` |
| Add inline comment | `mcp_github_add_comment_to_pending_review()` |
| Submit review | `mcp_github_pull_request_review_write(method="submit_pending")` |
| Add PR comment | `mcp_github_add_issue_comment()` |
| Reply to comment | `mcp_github_add_reply_to_pull_request_comment()` |
| Scan for secrets | `mcp_github_run_secret_scanning()` |
| Request Copilot review | `mcp_github_request_copilot_review()` |
### Decision: Approve vs Request Changes vs Comment
+139
View File
@@ -0,0 +1,139 @@
"""Tests for focus_topic flowing through the compressor.
Verifies that _generate_summary and compress accept and use the focus_topic
parameter correctly. Inspired by Claude Code's /compact <focus>.
"""
from unittest.mock import MagicMock, patch
from agent.context_compressor import ContextCompressor
def _make_compressor():
"""Create a ContextCompressor with minimal state for testing."""
compressor = ContextCompressor.__new__(ContextCompressor)
compressor.protect_first_n = 2
compressor.protect_last_n = 5
compressor.tail_token_budget = 20000
compressor.context_length = 200000
compressor.threshold_percent = 0.80
compressor.threshold_tokens = 160000
compressor.max_summary_tokens = 10000
compressor.quiet_mode = True
compressor.compression_count = 0
compressor.last_prompt_tokens = 0
compressor._previous_summary = None
compressor._summary_failure_cooldown_until = 0.0
compressor.summary_model = None
return compressor
def test_focus_topic_injected_into_summary_prompt():
"""When focus_topic is provided, the LLM prompt includes focus guidance."""
compressor = _make_compressor()
turns = [
{"role": "user", "content": "Tell me about the database schema"},
{"role": "assistant", "content": "The schema has tables: users, orders, products."},
]
captured_prompt = {}
def mock_call_llm(**kwargs):
captured_prompt["messages"] = kwargs["messages"]
resp = MagicMock()
resp.choices = [MagicMock()]
resp.choices[0].message.content = "## Goal\nUnderstand DB schema."
return resp
with patch("agent.context_compressor.call_llm", mock_call_llm):
result = compressor._generate_summary(turns, focus_topic="database schema")
assert result is not None
prompt_text = captured_prompt["messages"][0]["content"]
assert 'FOCUS TOPIC: "database schema"' in prompt_text
assert "PRIORITISE" in prompt_text
assert "60-70%" in prompt_text
def test_no_focus_topic_no_injection():
"""Without focus_topic, the prompt doesn't contain focus guidance."""
compressor = _make_compressor()
turns = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi"},
]
captured_prompt = {}
def mock_call_llm(**kwargs):
captured_prompt["messages"] = kwargs["messages"]
resp = MagicMock()
resp.choices = [MagicMock()]
resp.choices[0].message.content = "## Goal\nGreeting."
return resp
with patch("agent.context_compressor.call_llm", mock_call_llm):
result = compressor._generate_summary(turns)
prompt_text = captured_prompt["messages"][0]["content"]
assert "FOCUS TOPIC" not in prompt_text
def test_compress_passes_focus_to_generate_summary():
"""compress() passes focus_topic through to _generate_summary."""
compressor = _make_compressor()
# Track what _generate_summary receives
received_kwargs = {}
original_generate = compressor._generate_summary
def tracking_generate(turns, **kwargs):
received_kwargs.update(kwargs)
return "## Goal\nTest."
compressor._generate_summary = tracking_generate
messages = [
{"role": "system", "content": "System prompt"},
{"role": "user", "content": "first"},
{"role": "assistant", "content": "reply1"},
{"role": "user", "content": "second"},
{"role": "assistant", "content": "reply2"},
{"role": "user", "content": "third"},
{"role": "assistant", "content": "reply3"},
{"role": "user", "content": "fourth"},
{"role": "assistant", "content": "reply4"},
]
compressor.compress(messages, current_tokens=100000, focus_topic="authentication flow")
assert received_kwargs.get("focus_topic") == "authentication flow"
def test_compress_none_focus_by_default():
"""compress() passes None focus_topic by default."""
compressor = _make_compressor()
received_kwargs = {}
def tracking_generate(turns, **kwargs):
received_kwargs.update(kwargs)
return "## Goal\nTest."
compressor._generate_summary = tracking_generate
messages = [
{"role": "system", "content": "System prompt"},
{"role": "user", "content": "first"},
{"role": "assistant", "content": "reply1"},
{"role": "user", "content": "second"},
{"role": "assistant", "content": "reply2"},
{"role": "user", "content": "third"},
{"role": "assistant", "content": "reply3"},
{"role": "user", "content": "fourth"},
{"role": "assistant", "content": "reply4"},
]
compressor.compress(messages, current_tokens=100000)
assert received_kwargs.get("focus_topic") is None
+118
View File
@@ -0,0 +1,118 @@
"""Tests for /compress <focus> — guided compression with focus topic.
Inspired by Claude Code's /compact <focus> feature.
"""
from unittest.mock import MagicMock, patch
from tests.cli.test_cli_init import _make_cli
def _make_history() -> list[dict[str, str]]:
return [
{"role": "user", "content": "one"},
{"role": "assistant", "content": "two"},
{"role": "user", "content": "three"},
{"role": "assistant", "content": "four"},
]
def test_focus_topic_extracted_and_passed(capsys):
"""Focus topic is extracted from the command and passed to _compress_context."""
shell = _make_cli()
history = _make_history()
compressed = [history[0], history[-1]]
shell.conversation_history = history
shell.agent = MagicMock()
shell.agent.compression_enabled = True
shell.agent._cached_system_prompt = ""
shell.agent._compress_context.return_value = (compressed, "")
def _estimate(messages):
if messages is history:
return 100
return 50
with patch("agent.model_metadata.estimate_messages_tokens_rough", side_effect=_estimate):
shell._manual_compress("/compress database schema")
output = capsys.readouterr().out
assert 'focus: "database schema"' in output
# Verify focus_topic was passed through
shell.agent._compress_context.assert_called_once()
call_kwargs = shell.agent._compress_context.call_args
assert call_kwargs.kwargs.get("focus_topic") == "database schema"
def test_no_focus_topic_when_bare_command(capsys):
"""When no focus topic is provided, None is passed."""
shell = _make_cli()
history = _make_history()
shell.conversation_history = history
shell.agent = MagicMock()
shell.agent.compression_enabled = True
shell.agent._cached_system_prompt = ""
shell.agent._compress_context.return_value = (list(history), "")
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
shell._manual_compress("/compress")
shell.agent._compress_context.assert_called_once()
call_kwargs = shell.agent._compress_context.call_args
assert call_kwargs.kwargs.get("focus_topic") is None
def test_empty_focus_after_command_treated_as_none(capsys):
"""Trailing whitespace after /compress does not produce a focus topic."""
shell = _make_cli()
history = _make_history()
shell.conversation_history = history
shell.agent = MagicMock()
shell.agent.compression_enabled = True
shell.agent._cached_system_prompt = ""
shell.agent._compress_context.return_value = (list(history), "")
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
shell._manual_compress("/compress ")
shell.agent._compress_context.assert_called_once()
call_kwargs = shell.agent._compress_context.call_args
assert call_kwargs.kwargs.get("focus_topic") is None
def test_focus_topic_printed_in_compression_banner(capsys):
"""The focus topic shows in the compression progress banner."""
shell = _make_cli()
history = _make_history()
compressed = [history[0], history[-1]]
shell.conversation_history = history
shell.agent = MagicMock()
shell.agent.compression_enabled = True
shell.agent._cached_system_prompt = ""
shell.agent._compress_context.return_value = (compressed, "")
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
shell._manual_compress("/compress API endpoints")
output = capsys.readouterr().out
assert 'focus: "API endpoints"' in output
def test_no_focus_prints_standard_banner(capsys):
"""Without focus, the standard banner (no focus: line) is printed."""
shell = _make_cli()
history = _make_history()
compressed = [history[0], history[-1]]
shell.conversation_history = history
shell.agent = MagicMock()
shell.agent.compression_enabled = True
shell.agent._cached_system_prompt = ""
shell.agent._compress_context.return_value = (compressed, "")
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
shell._manual_compress("/compress")
output = capsys.readouterr().out
assert "focus:" not in output
assert "Compressing" in output
+189
View File
@@ -0,0 +1,189 @@
"""Tests for stacked tool progress scrollback lines in the CLI TUI.
When tool_progress_mode is "all" or "new", _on_tool_progress should print
persistent lines to scrollback on tool.completed, restoring the stacked
tool history that was lost when the TUI switched to a single-line spinner.
"""
import os
import sys
import importlib
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# Module-level reference to the cli module (set by _make_cli on first call)
_cli_mod = None
def _make_cli(tool_progress="all"):
"""Create a HermesCLI instance with minimal mocking."""
global _cli_mod
_clean_config = {
"model": {
"default": "anthropic/claude-opus-4.6",
"base_url": "https://openrouter.ai/api/v1",
"provider": "auto",
},
"display": {"compact": False, "tool_progress": tool_progress},
"agent": {},
"terminal": {"env_type": "local"},
}
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
prompt_toolkit_stubs = {
"prompt_toolkit": MagicMock(),
"prompt_toolkit.history": MagicMock(),
"prompt_toolkit.styles": MagicMock(),
"prompt_toolkit.patch_stdout": MagicMock(),
"prompt_toolkit.application": MagicMock(),
"prompt_toolkit.layout": MagicMock(),
"prompt_toolkit.layout.processors": MagicMock(),
"prompt_toolkit.filters": MagicMock(),
"prompt_toolkit.layout.dimension": MagicMock(),
"prompt_toolkit.layout.menus": MagicMock(),
"prompt_toolkit.widgets": MagicMock(),
"prompt_toolkit.key_binding": MagicMock(),
"prompt_toolkit.completion": MagicMock(),
"prompt_toolkit.formatted_text": MagicMock(),
"prompt_toolkit.auto_suggest": MagicMock(),
}
with patch.dict(sys.modules, prompt_toolkit_stubs), \
patch.dict("os.environ", clean_env, clear=False):
import cli as mod
mod = importlib.reload(mod)
_cli_mod = mod
with patch.object(mod, "get_tool_definitions", return_value=[]), \
patch.dict(mod.__dict__, {"CLI_CONFIG": _clean_config}):
return mod.HermesCLI()
class TestToolProgressScrollback:
"""Stacked scrollback lines for 'all' and 'new' modes."""
def test_all_mode_prints_scrollback_on_completed(self):
"""In 'all' mode, tool.completed prints a stacked line."""
cli = _make_cli(tool_progress="all")
# Simulate tool.started
cli._on_tool_progress("tool.started", "terminal", "git log", {"command": "git log"})
# Simulate tool.completed
with patch.object(_cli_mod, "_cprint") as mock_print:
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=1.5, is_error=False)
mock_print.assert_called_once()
line = mock_print.call_args[0][0]
# Should contain tool info (the cute message format has "git log" for terminal)
assert "git log" in line or "$" in line
def test_all_mode_prints_every_call(self):
"""In 'all' mode, consecutive calls to the same tool each get a line."""
cli = _make_cli(tool_progress="all")
with patch.object(_cli_mod, "_cprint") as mock_print:
# First call
cli._on_tool_progress("tool.started", "read_file", "cli.py", {"path": "cli.py"})
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.1, is_error=False)
# Second call (same tool)
cli._on_tool_progress("tool.started", "read_file", "run_agent.py", {"path": "run_agent.py"})
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.2, is_error=False)
assert mock_print.call_count == 2
def test_new_mode_skips_consecutive_repeats(self):
"""In 'new' mode, consecutive calls to the same tool only print once."""
cli = _make_cli(tool_progress="new")
with patch.object(_cli_mod, "_cprint") as mock_print:
cli._on_tool_progress("tool.started", "read_file", "cli.py", {"path": "cli.py"})
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.1, is_error=False)
cli._on_tool_progress("tool.started", "read_file", "run_agent.py", {"path": "run_agent.py"})
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.2, is_error=False)
assert mock_print.call_count == 1 # Only the first read_file
def test_new_mode_prints_when_tool_changes(self):
"""In 'new' mode, a different tool name triggers a new line."""
cli = _make_cli(tool_progress="new")
with patch.object(_cli_mod, "_cprint") as mock_print:
cli._on_tool_progress("tool.started", "read_file", "cli.py", {"path": "cli.py"})
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.1, is_error=False)
cli._on_tool_progress("tool.started", "search_files", "pattern", {"pattern": "test"})
cli._on_tool_progress("tool.completed", "search_files", None, None, duration=0.3, is_error=False)
cli._on_tool_progress("tool.started", "read_file", "run_agent.py", {"path": "run_agent.py"})
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.2, is_error=False)
# read_file, search_files, read_file (3rd prints because search_files broke the streak)
assert mock_print.call_count == 3
def test_off_mode_no_scrollback(self):
"""In 'off' mode, no stacked lines are printed."""
cli = _make_cli(tool_progress="off")
with patch.object(_cli_mod, "_cprint") as mock_print:
cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"})
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=False)
mock_print.assert_not_called()
def test_error_suffix_on_failed_tool(self):
"""When is_error=True, the stacked line includes [error]."""
cli = _make_cli(tool_progress="all")
cli._on_tool_progress("tool.started", "terminal", "bad cmd", {"command": "bad cmd"})
with patch.object(_cli_mod, "_cprint") as mock_print:
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=True)
line = mock_print.call_args[0][0]
assert "[error]" in line
def test_spinner_still_updates_on_started(self):
"""tool.started still updates the spinner text for live display."""
cli = _make_cli(tool_progress="all")
cli._on_tool_progress("tool.started", "terminal", "git status", {"command": "git status"})
assert "git status" in cli._spinner_text
def test_spinner_timer_clears_on_completed(self):
"""tool.completed still clears the tool timer."""
cli = _make_cli(tool_progress="all")
cli._on_tool_progress("tool.started", "terminal", "git status", {"command": "git status"})
assert cli._tool_start_time > 0
with patch.object(_cli_mod, "_cprint"):
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=False)
assert cli._tool_start_time == 0.0
def test_concurrent_tools_produce_stacked_lines(self):
"""Multiple tool.started followed by multiple tool.completed all produce lines."""
cli = _make_cli(tool_progress="all")
with patch.object(_cli_mod, "_cprint") as mock_print:
# All start first (concurrent pattern)
cli._on_tool_progress("tool.started", "web_search", "query 1", {"query": "test 1"})
cli._on_tool_progress("tool.started", "web_search", "query 2", {"query": "test 2"})
# All complete
cli._on_tool_progress("tool.completed", "web_search", None, None, duration=1.0, is_error=False)
cli._on_tool_progress("tool.completed", "web_search", None, None, duration=1.5, is_error=False)
assert mock_print.call_count == 2
def test_verbose_mode_no_duplicate_scrollback(self):
"""In 'verbose' mode, scrollback lines are NOT printed (run_agent handles verbose output)."""
cli = _make_cli(tool_progress="verbose")
with patch.object(_cli_mod, "_cprint") as mock_print:
cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"})
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=False)
mock_print.assert_not_called()
def test_pending_info_stores_on_started(self):
"""tool.started stores args for later use by tool.completed."""
cli = _make_cli(tool_progress="all")
cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"})
assert "terminal" in cli._pending_tool_info
assert len(cli._pending_tool_info["terminal"]) == 1
assert cli._pending_tool_info["terminal"][0] == {"command": "ls"}
def test_pending_info_consumed_on_completed(self):
"""tool.completed consumes stored args (FIFO for concurrent)."""
cli = _make_cli(tool_progress="all")
cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"})
cli._on_tool_progress("tool.started", "terminal", "pwd", {"command": "pwd"})
assert len(cli._pending_tool_info["terminal"]) == 2
with patch.object(_cli_mod, "_cprint"):
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.1, is_error=False)
# First entry consumed, second remains
assert len(cli._pending_tool_info.get("terminal", [])) == 1
assert cli._pending_tool_info["terminal"][0] == {"command": "pwd"}
+1 -3
View File
@@ -578,9 +578,7 @@ class TestDeliverResultErrorReturns:
with patch("gateway.config.load_gateway_config"):
result = _deliver_result(job, "Output.")
assert result is not None
# With the plugin platform registry, unknown names are accepted
# as potential plugins but fail at delivery if no adapter exists.
assert "fax" in result.lower()
assert "unknown platform" in result
def test_returns_error_when_platform_disabled(self):
from gateway.config import Platform
+118
View File
@@ -0,0 +1,118 @@
"""Tests for gateway /compress <focus> — focus topic on the gateway side."""
from datetime import datetime
from unittest.mock import MagicMock, patch
import pytest
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import MessageEvent
from gateway.session import SessionEntry, SessionSource, build_session_key
def _make_source() -> SessionSource:
return SessionSource(
platform=Platform.TELEGRAM,
user_id="u1",
chat_id="c1",
user_name="tester",
chat_type="dm",
)
def _make_event(text: str = "/compress") -> MessageEvent:
return MessageEvent(text=text, source=_make_source(), message_id="m1")
def _make_history() -> list[dict[str, str]]:
return [
{"role": "user", "content": "one"},
{"role": "assistant", "content": "two"},
{"role": "user", "content": "three"},
{"role": "assistant", "content": "four"},
]
def _make_runner(history: list[dict[str, str]]):
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}
)
session_entry = SessionEntry(
session_key=build_session_key(_make_source()),
session_id="sess-1",
created_at=datetime.now(),
updated_at=datetime.now(),
platform=Platform.TELEGRAM,
chat_type="dm",
)
runner.session_store = MagicMock()
runner.session_store.get_or_create_session.return_value = session_entry
runner.session_store.load_transcript.return_value = history
runner.session_store.rewrite_transcript = MagicMock()
runner.session_store.update_session = MagicMock()
runner.session_store._save = MagicMock()
return runner
@pytest.mark.asyncio
async def test_compress_focus_topic_passed_to_agent():
"""Focus topic from /compress <focus> is passed through to _compress_context."""
history = _make_history()
compressed = [history[0], history[-1]]
runner = _make_runner(history)
agent_instance = MagicMock()
agent_instance.context_compressor.protect_first_n = 0
agent_instance.context_compressor._align_boundary_forward.return_value = 0
agent_instance.context_compressor._find_tail_cut_by_tokens.return_value = 2
agent_instance.session_id = "sess-1"
agent_instance._compress_context.return_value = (compressed, "")
def _estimate(messages):
return 100
with (
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "***"}),
patch("gateway.run._resolve_gateway_model", return_value="test-model"),
patch("run_agent.AIAgent", return_value=agent_instance),
patch("agent.model_metadata.estimate_messages_tokens_rough", side_effect=_estimate),
):
result = await runner._handle_compress_command(_make_event("/compress database schema"))
# Verify focus_topic was passed
agent_instance._compress_context.assert_called_once()
call_kwargs = agent_instance._compress_context.call_args
assert call_kwargs.kwargs.get("focus_topic") == "database schema"
# Verify focus is mentioned in response
assert 'Focus: "database schema"' in result
@pytest.mark.asyncio
async def test_compress_no_focus_passes_none():
"""Bare /compress passes focus_topic=None."""
history = _make_history()
runner = _make_runner(history)
agent_instance = MagicMock()
agent_instance.context_compressor.protect_first_n = 0
agent_instance.context_compressor._align_boundary_forward.return_value = 0
agent_instance.context_compressor._find_tail_cut_by_tokens.return_value = 2
agent_instance.session_id = "sess-1"
agent_instance._compress_context.return_value = (list(history), "")
with (
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "***"}),
patch("gateway.run._resolve_gateway_model", return_value="test-model"),
patch("run_agent.AIAgent", return_value=agent_instance),
patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100),
):
result = await runner._handle_compress_command(_make_event("/compress"))
agent_instance._compress_context.assert_called_once()
call_kwargs = agent_instance._compress_context.call_args
assert call_kwargs.kwargs.get("focus_topic") is None
# No focus line in response
assert "Focus:" not in result
+14 -16
View File
@@ -285,11 +285,11 @@ class TestAuthorizationMaps(unittest.TestCase):
class TestSendMessageToolRouting(unittest.TestCase):
"""Verify email routing in send_message_tool."""
def test_email_platform_resolves(self):
"""Email platform resolves via Platform() — no hardcoded map needed."""
from gateway.config import Platform
p = Platform("email")
self.assertEqual(p, Platform.EMAIL)
def test_email_in_platform_map(self):
import tools.send_message_tool as smt
import inspect
source = inspect.getsource(smt._handle_send)
self.assertIn('"email"', source)
def test_send_to_platform_has_email_branch(self):
import tools.send_message_tool as smt
@@ -301,12 +301,11 @@ class TestSendMessageToolRouting(unittest.TestCase):
class TestCronDelivery(unittest.TestCase):
"""Verify email in cron scheduler platform_map."""
def test_email_resolves_for_cron(self):
"""Email platform resolves via Platform() for cron delivery."""
from gateway.config import Platform
p = Platform("email")
self.assertEqual(p, Platform.EMAIL)
self.assertEqual(p.value, "email")
def test_email_in_cron_platform_map(self):
import cron.scheduler
import inspect
source = inspect.getsource(cron.scheduler)
self.assertIn('"email"', source)
class TestToolset(unittest.TestCase):
@@ -335,11 +334,10 @@ class TestChannelDirectory(unittest.TestCase):
"""Verify email in channel directory session-based discovery."""
def test_email_in_session_discovery(self):
"""Email is discovered via Platform enum iteration in build_channel_directory."""
from gateway.config import Platform
# Email is a built-in Platform member, so it's included in
# `for plat in Platform:` iteration inside build_channel_directory.
self.assertIn(Platform.EMAIL, Platform.__members__.values())
import gateway.channel_directory
import inspect
source = inspect.getsource(gateway.channel_directory.build_channel_directory)
self.assertIn('"email"', source)
class TestGatewaySetup(unittest.TestCase):
@@ -28,12 +28,16 @@ class _FakeRegistry:
def __init__(self, sessions):
self._sessions = list(sessions)
self._completion_consumed: set = set()
def get(self, session_id):
if self._sessions:
return self._sessions.pop(0)
return None
def is_completion_consumed(self, session_id):
return session_id in self._completion_consumed
def _build_runner(monkeypatch, tmp_path) -> GatewayRunner:
"""Create a GatewayRunner with notifications set to 'all'."""
-380
View File
@@ -1,380 +0,0 @@
"""Tests for the IRC platform adapter plugin."""
import asyncio
import os
import sys
import pytest
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
# Ensure the plugins directory is on sys.path for direct import
_REPO_ROOT = Path(__file__).resolve().parents[2]
_IRC_PLUGIN_DIR = _REPO_ROOT / "plugins" / "platforms" / "irc"
if str(_IRC_PLUGIN_DIR) not in sys.path:
sys.path.insert(0, str(_IRC_PLUGIN_DIR))
# ── IRC protocol helpers ─────────────────────────────────────────────────
from adapter import _parse_irc_message, _extract_nick
class TestIRCProtocolHelpers:
def test_parse_simple_command(self):
msg = _parse_irc_message("PING :server.example.com")
assert msg["command"] == "PING"
assert msg["params"] == ["server.example.com"]
assert msg["prefix"] == ""
def test_parse_prefixed_message(self):
msg = _parse_irc_message(":nick!user@host PRIVMSG #channel :Hello world")
assert msg["prefix"] == "nick!user@host"
assert msg["command"] == "PRIVMSG"
assert msg["params"] == ["#channel", "Hello world"]
def test_parse_numeric_reply(self):
msg = _parse_irc_message(":server 001 hermes-bot :Welcome to IRC")
assert msg["prefix"] == "server"
assert msg["command"] == "001"
assert msg["params"] == ["hermes-bot", "Welcome to IRC"]
def test_parse_nick_collision(self):
msg = _parse_irc_message(":server 433 * hermes-bot :Nickname is already in use")
assert msg["command"] == "433"
def test_extract_nick_full_prefix(self):
assert _extract_nick("nick!user@host") == "nick"
def test_extract_nick_bare(self):
assert _extract_nick("server.example.com") == "server.example.com"
# ── IRC Adapter ──────────────────────────────────────────────────────────
from adapter import IRCAdapter, check_requirements, validate_config
class TestIRCAdapterInit:
def test_init_from_env(self, monkeypatch):
monkeypatch.setenv("IRC_SERVER", "irc.test.net")
monkeypatch.setenv("IRC_PORT", "6667")
monkeypatch.setenv("IRC_NICKNAME", "testbot")
monkeypatch.setenv("IRC_CHANNEL", "#test")
monkeypatch.setenv("IRC_USE_TLS", "false")
from gateway.config import PlatformConfig
cfg = PlatformConfig(enabled=True)
adapter = IRCAdapter(cfg)
assert adapter.server == "irc.test.net"
assert adapter.port == 6667
assert adapter.nickname == "testbot"
assert adapter.channel == "#test"
assert adapter.use_tls is False
def test_init_from_config_extra(self, monkeypatch):
# Clear any env vars
for key in ("IRC_SERVER", "IRC_PORT", "IRC_NICKNAME", "IRC_CHANNEL", "IRC_USE_TLS"):
monkeypatch.delenv(key, raising=False)
from gateway.config import PlatformConfig
cfg = PlatformConfig(
enabled=True,
extra={
"server": "irc.libera.chat",
"port": 6697,
"nickname": "hermes",
"channel": "#hermes-dev",
"use_tls": True,
},
)
adapter = IRCAdapter(cfg)
assert adapter.server == "irc.libera.chat"
assert adapter.port == 6697
assert adapter.nickname == "hermes"
assert adapter.channel == "#hermes-dev"
assert adapter.use_tls is True
def test_env_overrides_config(self, monkeypatch):
monkeypatch.setenv("IRC_SERVER", "env-server.net")
from gateway.config import PlatformConfig
cfg = PlatformConfig(
enabled=True,
extra={"server": "config-server.net", "channel": "#ch"},
)
adapter = IRCAdapter(cfg)
assert adapter.server == "env-server.net"
class TestIRCAdapterSend:
@pytest.fixture
def adapter(self, monkeypatch):
for key in ("IRC_SERVER", "IRC_PORT", "IRC_NICKNAME", "IRC_CHANNEL", "IRC_USE_TLS"):
monkeypatch.delenv(key, raising=False)
from gateway.config import PlatformConfig
cfg = PlatformConfig(
enabled=True,
extra={
"server": "localhost",
"port": 6667,
"nickname": "testbot",
"channel": "#test",
"use_tls": False,
},
)
return IRCAdapter(cfg)
@pytest.mark.asyncio
async def test_send_not_connected(self, adapter):
result = await adapter.send("#test", "hello")
assert result.success is False
assert "Not connected" in result.error
@pytest.mark.asyncio
async def test_send_success(self, adapter):
writer = MagicMock()
writer.is_closing = MagicMock(return_value=False)
writer.write = MagicMock()
writer.drain = AsyncMock()
adapter._writer = writer
result = await adapter.send("#test", "hello world")
assert result.success is True
assert result.message_id is not None
# Verify PRIVMSG was sent
writer.write.assert_called()
sent_data = writer.write.call_args[0][0]
assert b"PRIVMSG #test :hello world" in sent_data
@pytest.mark.asyncio
async def test_send_splits_long_messages(self, adapter):
writer = MagicMock()
writer.is_closing = MagicMock(return_value=False)
writer.write = MagicMock()
writer.drain = AsyncMock()
adapter._writer = writer
long_msg = "x" * 1000
result = await adapter.send("#test", long_msg)
assert result.success is True
# Should have been split into multiple PRIVMSG calls
assert writer.write.call_count > 1
class TestIRCAdapterMessageParsing:
@pytest.fixture
def adapter(self, monkeypatch):
for key in ("IRC_SERVER", "IRC_PORT", "IRC_NICKNAME", "IRC_CHANNEL", "IRC_USE_TLS"):
monkeypatch.delenv(key, raising=False)
from gateway.config import PlatformConfig
cfg = PlatformConfig(
enabled=True,
extra={
"server": "localhost",
"port": 6667,
"nickname": "hermes",
"channel": "#test",
"use_tls": False,
},
)
a = IRCAdapter(cfg)
a._current_nick = "hermes"
a._registered = True
return a
@pytest.mark.asyncio
async def test_handle_ping(self, adapter):
writer = MagicMock()
writer.is_closing = MagicMock(return_value=False)
writer.write = MagicMock()
writer.drain = AsyncMock()
adapter._writer = writer
await adapter._handle_line("PING :test-server")
sent = writer.write.call_args[0][0]
assert b"PONG :test-server" in sent
@pytest.mark.asyncio
async def test_handle_welcome(self, adapter):
adapter._registered = False
adapter._registration_event = asyncio.Event()
await adapter._handle_line(":server 001 hermes :Welcome to IRC")
assert adapter._registered is True
assert adapter._registration_event.is_set()
@pytest.mark.asyncio
async def test_handle_nick_collision(self, adapter):
writer = MagicMock()
writer.is_closing = MagicMock(return_value=False)
writer.write = MagicMock()
writer.drain = AsyncMock()
adapter._writer = writer
await adapter._handle_line(":server 433 * hermes :Nickname in use")
assert adapter._current_nick == "hermes_"
sent = writer.write.call_args[0][0]
assert b"NICK hermes_" in sent
@pytest.mark.asyncio
async def test_handle_addressed_channel_message(self, adapter):
"""Messages addressed to the bot (nick: msg) should be dispatched."""
handler = AsyncMock(return_value="response")
adapter._message_handler = handler
# Mock handle_message to capture the event
dispatched = []
original_dispatch = adapter._dispatch_message
async def capture_dispatch(**kwargs):
dispatched.append(kwargs)
adapter._dispatch_message = capture_dispatch
await adapter._handle_line(":user!u@host PRIVMSG #test :hermes: hello there")
assert len(dispatched) == 1
assert dispatched[0]["text"] == "hello there"
assert dispatched[0]["chat_id"] == "#test"
@pytest.mark.asyncio
async def test_ignores_unaddressed_channel_message(self, adapter):
dispatched = []
async def capture_dispatch(**kwargs):
dispatched.append(kwargs)
adapter._dispatch_message = capture_dispatch
adapter._message_handler = AsyncMock()
await adapter._handle_line(":user!u@host PRIVMSG #test :just talking")
assert len(dispatched) == 0
@pytest.mark.asyncio
async def test_handle_dm(self, adapter):
"""DMs (target == bot nick) should always be dispatched."""
dispatched = []
async def capture_dispatch(**kwargs):
dispatched.append(kwargs)
adapter._dispatch_message = capture_dispatch
adapter._message_handler = AsyncMock()
await adapter._handle_line(":user!u@host PRIVMSG hermes :private message")
assert len(dispatched) == 1
assert dispatched[0]["text"] == "private message"
assert dispatched[0]["chat_type"] == "dm"
assert dispatched[0]["chat_id"] == "user"
@pytest.mark.asyncio
async def test_ignores_own_messages(self, adapter):
dispatched = []
async def capture_dispatch(**kwargs):
dispatched.append(kwargs)
adapter._dispatch_message = capture_dispatch
adapter._message_handler = AsyncMock()
await adapter._handle_line(":hermes!bot@host PRIVMSG #test :my own msg")
assert len(dispatched) == 0
@pytest.mark.asyncio
async def test_ctcp_action_converted(self, adapter):
"""CTCP ACTION (/me) should be converted to text."""
dispatched = []
async def capture_dispatch(**kwargs):
dispatched.append(kwargs)
adapter._dispatch_message = capture_dispatch
adapter._message_handler = AsyncMock()
await adapter._handle_line(":user!u@host PRIVMSG hermes :\x01ACTION waves\x01")
assert len(dispatched) == 1
assert dispatched[0]["text"] == "* user waves"
class TestIRCAdapterMarkdown:
def test_strip_bold(self):
assert IRCAdapter._strip_markdown("**bold**") == "bold"
def test_strip_italic(self):
assert IRCAdapter._strip_markdown("*italic*") == "italic"
def test_strip_code(self):
assert IRCAdapter._strip_markdown("`code`") == "code"
def test_strip_link(self):
result = IRCAdapter._strip_markdown("[click here](https://example.com)")
assert result == "click here (https://example.com)"
def test_strip_image(self):
result = IRCAdapter._strip_markdown("![alt](https://example.com/img.png)")
assert result == "https://example.com/img.png"
# ── Requirements / validation ────────────────────────────────────────────
class TestIRCRequirements:
def test_check_requirements_with_env(self, monkeypatch):
monkeypatch.setenv("IRC_SERVER", "irc.test.net")
monkeypatch.setenv("IRC_CHANNEL", "#test")
assert check_requirements() is True
def test_check_requirements_missing_server(self, monkeypatch):
monkeypatch.delenv("IRC_SERVER", raising=False)
monkeypatch.setenv("IRC_CHANNEL", "#test")
assert check_requirements() is False
def test_check_requirements_missing_channel(self, monkeypatch):
monkeypatch.setenv("IRC_SERVER", "irc.test.net")
monkeypatch.delenv("IRC_CHANNEL", raising=False)
assert check_requirements() is False
def test_validate_config_from_extra(self, monkeypatch):
for key in ("IRC_SERVER", "IRC_CHANNEL"):
monkeypatch.delenv(key, raising=False)
from gateway.config import PlatformConfig
cfg = PlatformConfig(extra={"server": "irc.test.net", "channel": "#test"})
assert validate_config(cfg) is True
def test_validate_config_missing(self, monkeypatch):
for key in ("IRC_SERVER", "IRC_CHANNEL"):
monkeypatch.delenv(key, raising=False)
from gateway.config import PlatformConfig
cfg = PlatformConfig(extra={})
assert validate_config(cfg) is False
# ── Plugin registration ──────────────────────────────────────────────────
class TestIRCPluginRegistration:
"""Test the register() entry point."""
def test_register_adds_to_registry(self, monkeypatch):
monkeypatch.setenv("IRC_SERVER", "irc.test.net")
monkeypatch.setenv("IRC_CHANNEL", "#test")
from gateway.platform_registry import platform_registry
# Clean up if already registered
platform_registry.unregister("irc")
from adapter import register
ctx = MagicMock()
register(ctx)
ctx.register_platform.assert_called_once()
call_kwargs = ctx.register_platform.call_args
assert call_kwargs[1]["name"] == "irc" or call_kwargs[0][0] == "irc" if call_kwargs[0] else call_kwargs[1]["name"] == "irc"
-377
View File
@@ -1,377 +0,0 @@
"""Tests for the platform adapter registry and dynamic Platform enum."""
import os
import pytest
from unittest.mock import MagicMock, patch
from dataclasses import dataclass
from gateway.platform_registry import PlatformRegistry, PlatformEntry, platform_registry
from gateway.config import Platform, PlatformConfig, GatewayConfig
# ── Platform enum dynamic members ─────────────────────────────────────────
class TestPlatformEnumDynamic:
"""Test that Platform enum accepts unknown values for plugin platforms."""
def test_builtin_members_still_work(self):
assert Platform.TELEGRAM.value == "telegram"
assert Platform("telegram") is Platform.TELEGRAM
def test_dynamic_member_created(self):
p = Platform("irc")
assert p.value == "irc"
assert p.name == "IRC"
def test_dynamic_member_identity_stable(self):
"""Same value returns same object (cached)."""
a = Platform("irc")
b = Platform("irc")
assert a is b
def test_dynamic_member_case_normalised(self):
"""Mixed case normalised to lowercase."""
a = Platform("IRC")
b = Platform("irc")
assert a is b
assert a.value == "irc"
def test_dynamic_member_with_hyphens(self):
p = Platform("my-platform")
assert p.value == "my-platform"
assert p.name == "MY_PLATFORM"
def test_dynamic_member_rejects_non_string(self):
with pytest.raises(ValueError):
Platform(123)
def test_dynamic_member_rejects_empty(self):
with pytest.raises(ValueError):
Platform("")
def test_dynamic_member_rejects_whitespace_only(self):
with pytest.raises(ValueError):
Platform(" ")
# ── PlatformRegistry ──────────────────────────────────────────────────────
class TestPlatformRegistry:
"""Test the PlatformRegistry itself."""
def _make_entry(self, name="test", check_ok=True, validate_ok=True, factory_ok=True):
adapter_mock = MagicMock()
return PlatformEntry(
name=name,
label=name.title(),
adapter_factory=lambda cfg, _m=adapter_mock: _m if factory_ok else (_ for _ in ()).throw(RuntimeError("factory error")),
check_fn=lambda: check_ok,
validate_config=lambda cfg: validate_ok,
required_env=[],
source="plugin",
), adapter_mock
def test_register_and_get(self):
reg = PlatformRegistry()
entry, _ = self._make_entry("alpha")
reg.register(entry)
assert reg.get("alpha") is entry
assert reg.is_registered("alpha")
def test_get_unknown_returns_none(self):
reg = PlatformRegistry()
assert reg.get("nonexistent") is None
def test_unregister(self):
reg = PlatformRegistry()
entry, _ = self._make_entry("beta")
reg.register(entry)
assert reg.unregister("beta") is True
assert reg.get("beta") is None
assert reg.unregister("beta") is False # already gone
def test_create_adapter_success(self):
reg = PlatformRegistry()
entry, mock_adapter = self._make_entry("gamma")
reg.register(entry)
result = reg.create_adapter("gamma", MagicMock())
assert result is mock_adapter
def test_create_adapter_unknown_name(self):
reg = PlatformRegistry()
assert reg.create_adapter("unknown", MagicMock()) is None
def test_create_adapter_check_fails(self):
reg = PlatformRegistry()
entry, _ = self._make_entry("delta", check_ok=False)
reg.register(entry)
assert reg.create_adapter("delta", MagicMock()) is None
def test_create_adapter_validate_fails(self):
reg = PlatformRegistry()
entry, _ = self._make_entry("epsilon", validate_ok=False)
reg.register(entry)
assert reg.create_adapter("epsilon", MagicMock()) is None
def test_create_adapter_factory_exception(self):
reg = PlatformRegistry()
entry = PlatformEntry(
name="broken",
label="Broken",
adapter_factory=lambda cfg: (_ for _ in ()).throw(RuntimeError("boom")),
check_fn=lambda: True,
validate_config=None,
source="plugin",
)
reg.register(entry)
# factory raises → create_adapter returns None instead of propagating
assert reg.create_adapter("broken", MagicMock()) is None
def test_create_adapter_no_validate(self):
"""When validate_config is None, skip validation."""
reg = PlatformRegistry()
mock_adapter = MagicMock()
entry = PlatformEntry(
name="novalidate",
label="NoValidate",
adapter_factory=lambda cfg: mock_adapter,
check_fn=lambda: True,
validate_config=None,
source="plugin",
)
reg.register(entry)
assert reg.create_adapter("novalidate", MagicMock()) is mock_adapter
def test_all_entries(self):
reg = PlatformRegistry()
e1, _ = self._make_entry("one")
e2, _ = self._make_entry("two")
reg.register(e1)
reg.register(e2)
names = {e.name for e in reg.all_entries()}
assert names == {"one", "two"}
def test_plugin_entries(self):
reg = PlatformRegistry()
plugin_entry, _ = self._make_entry("plugged")
builtin_entry = PlatformEntry(
name="core",
label="Core",
adapter_factory=lambda cfg: MagicMock(),
check_fn=lambda: True,
source="builtin",
)
reg.register(plugin_entry)
reg.register(builtin_entry)
plugin_names = {e.name for e in reg.plugin_entries()}
assert plugin_names == {"plugged"}
def test_re_register_replaces(self):
reg = PlatformRegistry()
entry1, mock1 = self._make_entry("dup")
entry2 = PlatformEntry(
name="dup",
label="Dup v2",
adapter_factory=lambda cfg: "v2",
check_fn=lambda: True,
source="plugin",
)
reg.register(entry1)
reg.register(entry2)
assert reg.get("dup").label == "Dup v2"
# ── GatewayConfig integration ────────────────────────────────────────────
class TestGatewayConfigPluginPlatform:
"""Test that GatewayConfig parses and validates plugin platforms."""
def test_from_dict_accepts_plugin_platform(self):
data = {
"platforms": {
"telegram": {"enabled": True, "token": "test-token"},
"irc": {"enabled": True, "extra": {"server": "irc.libera.chat"}},
}
}
cfg = GatewayConfig.from_dict(data)
platform_values = {p.value for p in cfg.platforms}
assert "telegram" in platform_values
assert "irc" in platform_values
def test_get_connected_platforms_includes_registered_plugin(self):
"""Plugin platform with registry entry passes get_connected_platforms."""
# Register a fake plugin platform
from gateway.platform_registry import platform_registry as _reg
test_entry = PlatformEntry(
name="testplat",
label="TestPlat",
adapter_factory=lambda cfg: MagicMock(),
check_fn=lambda: True,
validate_config=lambda cfg: bool(cfg.extra.get("token")),
source="plugin",
)
_reg.register(test_entry)
try:
data = {
"platforms": {
"testplat": {"enabled": True, "extra": {"token": "abc"}},
}
}
cfg = GatewayConfig.from_dict(data)
connected = cfg.get_connected_platforms()
connected_values = {p.value for p in connected}
assert "testplat" in connected_values
finally:
_reg.unregister("testplat")
def test_get_connected_platforms_excludes_unregistered_plugin(self):
"""Plugin platform without registry entry is excluded."""
data = {
"platforms": {
"unknown_plugin": {"enabled": True, "extra": {"token": "abc"}},
}
}
cfg = GatewayConfig.from_dict(data)
connected = cfg.get_connected_platforms()
connected_values = {p.value for p in connected}
assert "unknown_plugin" not in connected_values
def test_get_connected_platforms_excludes_invalid_config(self):
"""Plugin platform with failing validate_config is excluded."""
from gateway.platform_registry import platform_registry as _reg
test_entry = PlatformEntry(
name="badconfig",
label="BadConfig",
adapter_factory=lambda cfg: MagicMock(),
check_fn=lambda: True,
validate_config=lambda cfg: False, # always fails
source="plugin",
)
_reg.register(test_entry)
try:
data = {
"platforms": {
"badconfig": {"enabled": True, "extra": {}},
}
}
cfg = GatewayConfig.from_dict(data)
connected = cfg.get_connected_platforms()
connected_values = {p.value for p in connected}
assert "badconfig" not in connected_values
finally:
_reg.unregister("badconfig")
# ── Extended PlatformEntry fields ─────────────────────────────────────
class TestPlatformEntryExtendedFields:
"""Test the auth, message length, and display fields on PlatformEntry."""
def test_default_field_values(self):
entry = PlatformEntry(
name="test",
label="Test",
adapter_factory=lambda cfg: None,
check_fn=lambda: True,
)
assert entry.allowed_users_env == ""
assert entry.allow_all_env == ""
assert entry.max_message_length == 0
assert entry.pii_safe is False
assert entry.emoji == "🔌"
assert entry.allow_update_command is True
def test_custom_auth_fields(self):
entry = PlatformEntry(
name="irc",
label="IRC",
adapter_factory=lambda cfg: None,
check_fn=lambda: True,
allowed_users_env="IRC_ALLOWED_USERS",
allow_all_env="IRC_ALLOW_ALL_USERS",
max_message_length=450,
pii_safe=False,
emoji="💬",
)
assert entry.allowed_users_env == "IRC_ALLOWED_USERS"
assert entry.allow_all_env == "IRC_ALLOW_ALL_USERS"
assert entry.max_message_length == 450
assert entry.emoji == "💬"
# ── Cron platform resolution ─────────────────────────────────────────
class TestCronPlatformResolution:
"""Test that cron delivery accepts plugin platform names."""
def test_builtin_platform_resolves(self):
"""Built-in platform names resolve via Platform() call."""
p = Platform("telegram")
assert p is Platform.TELEGRAM
def test_plugin_platform_resolves(self):
"""Plugin platform names create dynamic enum members."""
p = Platform("irc")
assert p.value == "irc"
def test_invalid_platform_type_rejected(self):
"""Non-string values are still rejected."""
with pytest.raises(ValueError):
Platform(None)
# ── platforms.py integration ──────────────────────────────────────────
class TestPlatformsMerge:
"""Test get_all_platforms() merges with registry."""
def test_get_all_platforms_includes_builtins(self):
from hermes_cli.platforms import get_all_platforms, PLATFORMS
merged = get_all_platforms()
for key in PLATFORMS:
assert key in merged
def test_get_all_platforms_includes_plugin(self):
from hermes_cli.platforms import get_all_platforms
from gateway.platform_registry import platform_registry as _reg
_reg.register(PlatformEntry(
name="testmerge",
label="TestMerge",
adapter_factory=lambda cfg: None,
check_fn=lambda: True,
source="plugin",
emoji="🧪",
))
try:
merged = get_all_platforms()
assert "testmerge" in merged
assert "TestMerge" in merged["testmerge"].label
finally:
_reg.unregister("testmerge")
def test_platform_label_plugin_fallback(self):
from hermes_cli.platforms import platform_label
from gateway.platform_registry import platform_registry as _reg
_reg.register(PlatformEntry(
name="labeltest",
label="LabelTest",
adapter_factory=lambda cfg: None,
check_fn=lambda: True,
source="plugin",
emoji="🏷️",
))
try:
label = platform_label("labeltest")
assert "LabelTest" in label
finally:
_reg.unregister("labeltest")
@@ -132,6 +132,39 @@ async def test_runner_records_connected_platform_state_on_success(monkeypatch, t
assert state["platforms"]["discord"]["error_message"] is None
@pytest.mark.asyncio
async def test_start_gateway_verbosity_imports_redacting_formatter(monkeypatch, tmp_path):
"""Verbosity != None must not crash with NameError on RedactingFormatter (#8044)."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
class _CleanExitRunner:
def __init__(self, config):
self.config = config
self.should_exit_cleanly = True
self.exit_reason = None
self.adapters = {}
async def start(self):
return True
async def stop(self):
return None
monkeypatch.setattr("gateway.status.get_running_pid", lambda: None)
monkeypatch.setattr("tools.skills_sync.sync_skills", lambda quiet=True: None)
monkeypatch.setattr("hermes_logging.setup_logging", lambda hermes_home, mode: tmp_path)
monkeypatch.setattr("hermes_logging._add_rotating_handler", lambda *args, **kwargs: None)
monkeypatch.setattr("gateway.run.GatewayRunner", _CleanExitRunner)
from gateway.run import start_gateway
# verbosity=1 triggers the code path that uses RedactingFormatter.
# Before the fix this raised NameError.
ok = await start_gateway(config=GatewayConfig(), replace=False, verbosity=1)
assert ok is True
@pytest.mark.asyncio
async def test_start_gateway_replace_force_uses_terminate_pid(monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+3 -6
View File
@@ -83,12 +83,9 @@ class TestSessionSourceRoundtrip:
assert restored.chat_topic is None
assert restored.chat_type == "dm"
def test_unknown_platform_accepted_for_plugins(self):
"""Unknown platform names are now accepted (dynamic enum members for
plugin platforms), so from_dict should succeed rather than raise."""
source = SessionSource.from_dict({"platform": "nonexistent", "chat_id": "1"})
assert source.platform.value == "nonexistent"
assert source.chat_id == "1"
def test_invalid_platform_raises(self):
with pytest.raises((ValueError, KeyError)):
SessionSource.from_dict({"platform": "nonexistent", "chat_id": "1"})
class TestSessionSourceDescription:
+33 -2
View File
@@ -64,13 +64,44 @@ class TestWeixinFormatting:
class TestWeixinChunking:
def test_split_text_keeps_short_multiline_message_in_single_chunk(self):
def test_split_text_splits_short_chatty_replies_into_separate_bubbles(self):
adapter = _make_adapter()
content = adapter.format_message("第一行\n第二行\n第三行")
chunks = adapter._split_text(content)
assert chunks == ["第一行\n第二行\n第三行"]
assert chunks == ["第一行", "第二行", "第三行"]
def test_split_text_keeps_structured_table_block_together(self):
adapter = _make_adapter()
content = adapter.format_message(
"- Setting: Timeout\n Value: 30s\n- Setting: Retries\n Value: 3"
)
chunks = adapter._split_text(content)
assert chunks == ["- Setting: Timeout\n Value: 30s\n- Setting: Retries\n Value: 3"]
def test_split_text_keeps_four_line_structured_blocks_together(self):
adapter = _make_adapter()
content = adapter.format_message(
"今天结论:\n"
"- 留存下降 3%\n"
"- 转化上涨 8%\n"
"- 主要问题在首日激活"
)
chunks = adapter._split_text(content)
assert chunks == ["今天结论:\n- 留存下降 3%\n- 转化上涨 8%\n- 主要问题在首日激活"]
def test_split_text_keeps_heading_with_body_together(self):
adapter = _make_adapter()
content = adapter.format_message("## 结论\n这是正文")
chunks = adapter._split_text(content)
assert chunks == ["**结论**\n这是正文"]
def test_split_text_keeps_short_reformatted_table_in_single_chunk(self):
adapter = _make_adapter()
+6 -110
View File
@@ -58,13 +58,13 @@ class TestFindOpenclawDirs:
def test_finds_legacy_dirs(self, tmp_path):
clawdbot = tmp_path / ".clawdbot"
clawdbot.mkdir()
moldbot = tmp_path / ".moldbot"
moldbot.mkdir()
moltbot = tmp_path / ".moltbot"
moltbot.mkdir()
with patch("pathlib.Path.home", return_value=tmp_path):
found = claw_mod._find_openclaw_dirs()
assert len(found) == 2
assert clawdbot in found
assert moldbot in found
assert moltbot in found
def test_returns_empty_when_none_exist(self, tmp_path):
with patch("pathlib.Path.home", return_value=tmp_path):
@@ -297,7 +297,6 @@ class TestCmdMigrate:
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
patch.object(claw_mod, "get_config_path", return_value=config_path),
patch.object(claw_mod, "prompt_yes_no", return_value=True),
patch.object(claw_mod, "_offer_source_archival"),
patch("sys.stdin", mock_stdin),
):
claw_mod._cmd_migrate(args)
@@ -306,43 +305,8 @@ class TestCmdMigrate:
assert "Migration Results" in captured.out
assert "Migration complete!" in captured.out
def test_execute_offers_archival_on_success(self, tmp_path, capsys):
"""After successful migration, _offer_source_archival should be called."""
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
fake_mod = ModuleType("openclaw_to_hermes")
fake_mod.resolve_selected_options = MagicMock(return_value={"soul"})
fake_migrator = MagicMock()
fake_migrator.migrate.return_value = {
"summary": {"migrated": 3, "skipped": 0, "conflict": 0, "error": 0},
"items": [
{"kind": "soul", "status": "migrated", "destination": str(tmp_path / "SOUL.md")},
],
}
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
args = Namespace(
source=str(openclaw_dir),
dry_run=False, preset="full", overwrite=False,
migrate_secrets=False, workspace_target=None,
skill_conflict="skip", yes=True,
)
with (
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
patch.object(claw_mod, "save_config"),
patch.object(claw_mod, "load_config", return_value={}),
patch.object(claw_mod, "_offer_source_archival") as mock_archival,
):
claw_mod._cmd_migrate(args)
mock_archival.assert_called_once_with(openclaw_dir, True)
def test_dry_run_skips_archival(self, tmp_path, capsys):
"""Dry run should not offer archival."""
def test_dry_run_does_not_touch_source(self, tmp_path, capsys):
"""Dry run should not modify the source directory."""
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
@@ -369,11 +333,10 @@ class TestCmdMigrate:
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
patch.object(claw_mod, "save_config"),
patch.object(claw_mod, "load_config", return_value={}),
patch.object(claw_mod, "_offer_source_archival") as mock_archival,
):
claw_mod._cmd_migrate(args)
mock_archival.assert_not_called()
assert openclaw_dir.is_dir() # Source untouched
def test_execute_cancelled_by_user(self, tmp_path, capsys):
openclaw_dir = tmp_path / ".openclaw"
@@ -506,73 +469,6 @@ class TestCmdMigrate:
assert call_kwargs["migrate_secrets"] is True
# ---------------------------------------------------------------------------
# _offer_source_archival
# ---------------------------------------------------------------------------
class TestOfferSourceArchival:
"""Test the post-migration archival offer."""
def test_archives_with_auto_yes(self, tmp_path, capsys):
source = tmp_path / ".openclaw"
source.mkdir()
(source / "workspace").mkdir()
(source / "workspace" / "todo.json").write_text("{}")
claw_mod._offer_source_archival(source, auto_yes=True)
captured = capsys.readouterr()
assert "Archived" in captured.out
assert not source.exists()
assert (tmp_path / ".openclaw.pre-migration").is_dir()
def test_skips_when_user_declines(self, tmp_path, capsys):
source = tmp_path / ".openclaw"
source.mkdir()
mock_stdin = MagicMock()
mock_stdin.isatty.return_value = True
with (
patch.object(claw_mod, "prompt_yes_no", return_value=False),
patch("sys.stdin", mock_stdin),
):
claw_mod._offer_source_archival(source, auto_yes=False)
captured = capsys.readouterr()
assert "Skipped" in captured.out
assert source.is_dir() # Still exists
def test_noop_when_source_missing(self, tmp_path, capsys):
claw_mod._offer_source_archival(tmp_path / "nonexistent", auto_yes=True)
captured = capsys.readouterr()
assert captured.out == "" # No output
def test_shows_state_files(self, tmp_path, capsys):
source = tmp_path / ".openclaw"
source.mkdir()
ws = source / "workspace"
ws.mkdir()
(ws / "todo.json").write_text("{}")
with patch.object(claw_mod, "prompt_yes_no", return_value=False):
claw_mod._offer_source_archival(source, auto_yes=False)
captured = capsys.readouterr()
assert "todo.json" in captured.out
def test_handles_archive_error(self, tmp_path, capsys):
source = tmp_path / ".openclaw"
source.mkdir()
with patch.object(claw_mod, "_archive_directory", side_effect=OSError("permission denied")):
claw_mod._offer_source_archival(source, auto_yes=True)
captured = capsys.readouterr()
assert "Could not archive" in captured.out
# ---------------------------------------------------------------------------
# _cmd_cleanup
# ---------------------------------------------------------------------------
@@ -0,0 +1,241 @@
"""Regression test: openai-codex must appear in /model picker when
credentials are only in the Codex CLI shared file (~/.codex/auth.json)
and haven't been migrated to the Hermes auth store yet.
Root cause: list_authenticated_providers() checked the raw Hermes auth
store but didn't know about the Codex CLI fallback import path.
Fix: _seed_from_singletons() now imports from the Codex CLI when the
Hermes auth store has no openai-codex tokens, and
list_authenticated_providers() falls back to load_pool() for OAuth
providers.
"""
import base64
import json
import os
import sys
import time
from pathlib import Path
from unittest.mock import patch
import pytest
def _make_fake_jwt(expiry_offset: int = 3600) -> str:
"""Build a fake JWT with a future expiry."""
header = base64.urlsafe_b64encode(b'{"alg":"RS256"}').rstrip(b"=").decode()
exp = int(time.time()) + expiry_offset
payload_bytes = json.dumps({"exp": exp, "sub": "test"}).encode()
payload = base64.urlsafe_b64encode(payload_bytes).rstrip(b"=").decode()
return f"{header}.{payload}.fakesig"
@pytest.fixture()
def codex_cli_only_env(tmp_path, monkeypatch):
"""Set up an environment where Codex tokens exist only in ~/.codex/auth.json,
NOT in the Hermes auth store."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
codex_home = tmp_path / ".codex"
codex_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("CODEX_HOME", str(codex_home))
# Empty Hermes auth store
(hermes_home / "auth.json").write_text(
json.dumps({"version": 2, "providers": {}})
)
# Valid Codex CLI tokens
fake_jwt = _make_fake_jwt()
(codex_home / "auth.json").write_text(
json.dumps({
"tokens": {
"access_token": fake_jwt,
"refresh_token": "fake-refresh-token",
}
})
)
# Clear provider env vars so only OAuth is a detection path
for var in [
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
"NOUS_API_KEY", "DEEPSEEK_API_KEY", "COPILOT_GITHUB_TOKEN",
"GH_TOKEN", "GEMINI_API_KEY",
]:
monkeypatch.delenv(var, raising=False)
return hermes_home
def test_codex_cli_tokens_detected_by_model_picker(codex_cli_only_env):
"""openai-codex should appear when tokens only exist in ~/.codex/auth.json."""
from hermes_cli.model_switch import list_authenticated_providers
providers = list_authenticated_providers(
current_provider="openai-codex",
max_models=10,
)
slugs = [p["slug"] for p in providers]
assert "openai-codex" in slugs, (
f"openai-codex not found in /model picker providers: {slugs}"
)
codex = next(p for p in providers if p["slug"] == "openai-codex")
assert codex["is_current"] is True
assert codex["total_models"] > 0
def test_codex_cli_tokens_migrated_after_detection(codex_cli_only_env):
"""After the /model picker detects Codex CLI tokens, they should be
migrated into the Hermes auth store for subsequent fast lookups."""
from hermes_cli.model_switch import list_authenticated_providers
# First call triggers migration
list_authenticated_providers(current_provider="openai-codex")
# Verify tokens are now in Hermes auth store
auth_path = codex_cli_only_env / "auth.json"
store = json.loads(auth_path.read_text())
providers = store.get("providers", {})
assert "openai-codex" in providers, (
f"openai-codex not migrated to Hermes auth store: {list(providers.keys())}"
)
tokens = providers["openai-codex"].get("tokens", {})
assert tokens.get("access_token"), "access_token missing after migration"
assert tokens.get("refresh_token"), "refresh_token missing after migration"
@pytest.fixture()
def hermes_auth_only_env(tmp_path, monkeypatch):
"""Tokens already in Hermes auth store (no Codex CLI needed)."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# Point CODEX_HOME to nonexistent dir to prove it's not needed
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no_codex"))
(hermes_home / "auth.json").write_text(json.dumps({
"version": 2,
"providers": {
"openai-codex": {
"tokens": {
"access_token": _make_fake_jwt(),
"refresh_token": "fake-refresh",
},
"last_refresh": "2026-04-12T00:00:00Z",
}
},
}))
for var in [
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
"NOUS_API_KEY", "DEEPSEEK_API_KEY",
]:
monkeypatch.delenv(var, raising=False)
return hermes_home
def test_normal_path_still_works(hermes_auth_only_env):
"""openai-codex appears when tokens are already in Hermes auth store."""
from hermes_cli.model_switch import list_authenticated_providers
providers = list_authenticated_providers(
current_provider="openai-codex",
max_models=10,
)
slugs = [p["slug"] for p in providers]
assert "openai-codex" in slugs
@pytest.fixture()
def claude_code_only_env(tmp_path, monkeypatch):
"""Set up an environment where Anthropic credentials only exist in
~/.claude/.credentials.json (Claude Code) — not in env vars or Hermes
auth store."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# No Codex CLI
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no_codex"))
(hermes_home / "auth.json").write_text(
json.dumps({"version": 2, "providers": {}})
)
# Claude Code credentials in the correct format
claude_dir = tmp_path / ".claude"
claude_dir.mkdir()
(claude_dir / ".credentials.json").write_text(json.dumps({
"claudeAiOauth": {
"accessToken": _make_fake_jwt(),
"refreshToken": "fake-refresh",
"expiresAt": int(time.time() * 1000) + 3_600_000,
}
}))
# Patch Path.home() so the adapter finds the file
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
for var in [
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
"ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN",
"NOUS_API_KEY", "DEEPSEEK_API_KEY",
]:
monkeypatch.delenv(var, raising=False)
return hermes_home
def test_claude_code_file_detected_by_model_picker(claude_code_only_env):
"""anthropic should appear when credentials only exist in ~/.claude/.credentials.json."""
from hermes_cli.model_switch import list_authenticated_providers
providers = list_authenticated_providers(
current_provider="anthropic",
max_models=10,
)
slugs = [p["slug"] for p in providers]
assert "anthropic" in slugs, (
f"anthropic not found in /model picker providers: {slugs}"
)
anthropic = next(p for p in providers if p["slug"] == "anthropic")
assert anthropic["is_current"] is True
assert anthropic["total_models"] > 0
def test_no_codex_when_no_credentials(tmp_path, monkeypatch):
"""openai-codex should NOT appear when no credentials exist anywhere."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no_codex"))
(hermes_home / "auth.json").write_text(
json.dumps({"version": 2, "providers": {}})
)
for var in [
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
"NOUS_API_KEY", "DEEPSEEK_API_KEY", "COPILOT_GITHUB_TOKEN",
"GH_TOKEN", "GEMINI_API_KEY",
]:
monkeypatch.delenv(var, raising=False)
from hermes_cli.model_switch import list_authenticated_providers
providers = list_authenticated_providers(
current_provider="openrouter",
max_models=10,
)
slugs = [p["slug"] for p in providers]
assert "openai-codex" not in slugs, (
"openai-codex should not appear without any credentials"
)
+77
View File
@@ -0,0 +1,77 @@
"""Tests for hermes_cli/tips.py — random tip display at session start."""
import pytest
from hermes_cli.tips import TIPS, get_random_tip, get_tip_count
class TestTipsCorpus:
"""Validate the tip corpus itself."""
def test_has_at_least_200_tips(self):
assert len(TIPS) >= 200, f"Expected 200+ tips, got {len(TIPS)}"
def test_no_duplicates(self):
assert len(TIPS) == len(set(TIPS)), "Duplicate tips found"
def test_all_tips_are_strings(self):
for i, tip in enumerate(TIPS):
assert isinstance(tip, str), f"Tip {i} is not a string: {type(tip)}"
def test_no_empty_tips(self):
for i, tip in enumerate(TIPS):
assert tip.strip(), f"Tip {i} is empty or whitespace-only"
def test_max_length_reasonable(self):
"""Tips should fit on a single terminal line (~120 chars max)."""
for i, tip in enumerate(TIPS):
assert len(tip) <= 150, (
f"Tip {i} too long ({len(tip)} chars): {tip[:60]}..."
)
def test_no_leading_trailing_whitespace(self):
for i, tip in enumerate(TIPS):
assert tip == tip.strip(), f"Tip {i} has leading/trailing whitespace"
class TestGetRandomTip:
"""Validate the get_random_tip() function."""
def test_returns_string(self):
tip = get_random_tip()
assert isinstance(tip, str)
assert len(tip) > 0
def test_returns_tip_from_corpus(self):
tip = get_random_tip()
assert tip in TIPS
def test_randomness(self):
"""Multiple calls should eventually return different tips."""
seen = set()
for _ in range(50):
seen.add(get_random_tip())
# With 200+ tips and 50 draws, we should see at least 10 unique
assert len(seen) >= 10, f"Only got {len(seen)} unique tips in 50 draws"
class TestGetTipCount:
def test_matches_corpus_length(self):
assert get_tip_count() == len(TIPS)
class TestTipIntegrationInCLI:
"""Test that the tip display code in cli.py works correctly."""
def test_tip_import_works(self):
"""The import used in cli.py must succeed."""
from hermes_cli.tips import get_random_tip
assert callable(get_random_tip)
def test_tip_display_format(self):
"""Verify the Rich markup format doesn't break."""
tip = get_random_tip()
color = "#B8860B"
markup = f"[dim {color}]✦ Tip: {tip}[/]"
# Should not contain nested/broken Rich tags
assert markup.count("[/]") == 1
assert "[dim #B8860B]" in markup
+127
View File
@@ -185,6 +185,38 @@ def test_migrator_optionally_imports_supported_secrets_and_messaging_settings(tm
assert "TELEGRAM_BOT_TOKEN=123:abc" in env_text
def test_messaging_cwd_skipped_when_inside_source(tmp_path: Path):
"""MESSAGING_CWD pointing inside the OpenClaw source dir should be skipped."""
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
target.mkdir()
# Workspace path is inside the source directory
ws_path = str(source / "workspace")
(source / "credentials").mkdir(parents=True)
(source / "openclaw.json").write_text(
json.dumps({"agents": {"defaults": {"workspace": ws_path}}}),
encoding="utf-8",
)
migrator = mod.Migrator(
source_root=source,
target_root=target,
execute=True,
workspace_target=None,
overwrite=False,
migrate_secrets=True,
output_dir=target / "migration-report",
selected_options={"messaging-settings"},
)
migrator.migrate()
env_path = target / ".env"
if env_path.exists():
assert "MESSAGING_CWD" not in env_path.read_text(encoding="utf-8")
def test_migrator_can_execute_only_selected_categories(tmp_path: Path):
mod = load_module()
source = tmp_path / ".openclaw"
@@ -722,3 +754,98 @@ def test_skill_installs_cleanly_under_skills_guard():
KNOWN_FALSE_POSITIVES = {"agent_config_mod", "python_os_environ", "hermes_config_mod"}
for f in result.findings:
assert f.pattern_id in KNOWN_FALSE_POSITIVES, f"Unexpected finding: {f}"
# ── rebrand_text tests ────────────────────────────────────────
def test_rebrand_text_replaces_openclaw_variants():
mod = load_module()
assert mod.rebrand_text("OpenClaw prefers Python 3.11") == "Hermes prefers Python 3.11"
assert mod.rebrand_text("I told Open Claw to use dark mode") == "I told Hermes to use dark mode"
assert mod.rebrand_text("Open-Claw config is great") == "Hermes config is great"
assert mod.rebrand_text("openclaw should always respond concisely") == "Hermes should always respond concisely"
assert mod.rebrand_text("OPENCLAW uses tools well") == "Hermes uses tools well"
def test_rebrand_text_replaces_legacy_bot_names():
mod = load_module()
assert mod.rebrand_text("ClawdBot remembers my timezone") == "Hermes remembers my timezone"
assert mod.rebrand_text("clawdbot prefers tabs") == "Hermes prefers tabs"
assert mod.rebrand_text("MoltBot was configured for Spanish") == "Hermes was configured for Spanish"
assert mod.rebrand_text("moltbot uses Python") == "Hermes uses Python"
def test_rebrand_text_preserves_unrelated_content():
mod = load_module()
text = "User prefers dark mode and lives in Las Vegas"
assert mod.rebrand_text(text) == text
def test_rebrand_text_handles_multiple_replacements():
mod = load_module()
text = "OpenClaw said to ask ClawdBot about MoltBot settings"
assert mod.rebrand_text(text) == "Hermes said to ask Hermes about Hermes settings"
def test_migrate_memory_rebrands_entries(tmp_path):
mod = load_module()
source_root = tmp_path / "openclaw"
source_root.mkdir()
workspace = source_root / "workspace"
workspace.mkdir()
memory_md = workspace / "MEMORY.md"
memory_md.write_text(
"# Memory\n\n- OpenClaw should use Python 3.11\n- ClawdBot prefers dark mode\n",
encoding="utf-8",
)
target_root = tmp_path / "hermes"
target_root.mkdir()
(target_root / "memories").mkdir()
migrator = mod.Migrator(
source_root=source_root,
target_root=target_root,
execute=True,
workspace_target=None,
overwrite=False,
migrate_secrets=False,
output_dir=tmp_path / "report",
selected_options={"memory"},
)
migrator.migrate()
result = (target_root / "memories" / "MEMORY.md").read_text(encoding="utf-8")
assert "OpenClaw" not in result
assert "ClawdBot" not in result
assert "Hermes" in result
def test_migrate_soul_rebrands_content(tmp_path):
mod = load_module()
source_root = tmp_path / "openclaw"
source_root.mkdir()
workspace = source_root / "workspace"
workspace.mkdir()
soul_md = workspace / "SOUL.md"
soul_md.write_text("You are OpenClaw, an AI assistant made by SparkLab.", encoding="utf-8")
target_root = tmp_path / "hermes"
target_root.mkdir()
migrator = mod.Migrator(
source_root=source_root,
target_root=target_root,
execute=True,
workspace_target=None,
overwrite=False,
migrate_secrets=False,
output_dir=tmp_path / "report",
selected_options={"soul"},
)
migrator.migrate()
result = (target_root / "SOUL.md").read_text(encoding="utf-8")
assert "OpenClaw" not in result
assert "You are Hermes" in result
+114
View File
@@ -0,0 +1,114 @@
"""Tests for network.force_ipv4 — the socket.getaddrinfo monkey-patch."""
import importlib
import socket
from unittest.mock import patch, MagicMock
import pytest
def _reload_constants():
"""Reload hermes_constants to get a fresh apply_ipv4_preference."""
import hermes_constants
importlib.reload(hermes_constants)
return hermes_constants
class TestApplyIPv4Preference:
"""Tests for apply_ipv4_preference()."""
def setup_method(self):
"""Save the original getaddrinfo before each test."""
self._original = socket.getaddrinfo
def teardown_method(self):
"""Restore the original getaddrinfo after each test."""
socket.getaddrinfo = self._original
def test_noop_when_force_false(self):
"""No patch when force=False."""
from hermes_constants import apply_ipv4_preference
original = socket.getaddrinfo
apply_ipv4_preference(force=False)
assert socket.getaddrinfo is original
def test_patches_getaddrinfo_when_forced(self):
"""Patches socket.getaddrinfo when force=True."""
from hermes_constants import apply_ipv4_preference
original = socket.getaddrinfo
apply_ipv4_preference(force=True)
assert socket.getaddrinfo is not original
assert getattr(socket.getaddrinfo, "_hermes_ipv4_patched", False) is True
def test_double_patch_is_safe(self):
"""Calling apply twice doesn't double-wrap."""
from hermes_constants import apply_ipv4_preference
apply_ipv4_preference(force=True)
first_patch = socket.getaddrinfo
apply_ipv4_preference(force=True)
assert socket.getaddrinfo is first_patch
def test_af_unspec_becomes_af_inet(self):
"""AF_UNSPEC (default) calls get rewritten to AF_INET."""
from hermes_constants import apply_ipv4_preference
calls = []
original = socket.getaddrinfo
def mock_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
calls.append(family)
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 80))]
socket.getaddrinfo = mock_getaddrinfo
apply_ipv4_preference(force=True)
# Call with default family (AF_UNSPEC = 0)
socket.getaddrinfo("example.com", 80)
assert calls[-1] == socket.AF_INET, "AF_UNSPEC should be rewritten to AF_INET"
def test_explicit_family_preserved(self):
"""Explicit AF_INET6 requests are not intercepted."""
from hermes_constants import apply_ipv4_preference
calls = []
original = socket.getaddrinfo
def mock_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
calls.append(family)
return [(family, socket.SOCK_STREAM, 6, "", ("::1", 80))]
socket.getaddrinfo = mock_getaddrinfo
apply_ipv4_preference(force=True)
socket.getaddrinfo("example.com", 80, family=socket.AF_INET6)
assert calls[-1] == socket.AF_INET6, "Explicit AF_INET6 should pass through"
def test_fallback_on_gaierror(self):
"""Falls back to AF_UNSPEC if AF_INET resolution fails."""
from hermes_constants import apply_ipv4_preference
call_families = []
def mock_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
call_families.append(family)
if family == socket.AF_INET:
raise socket.gaierror("No A record")
# AF_UNSPEC fallback returns IPv6
return [(socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("::1", 80))]
socket.getaddrinfo = mock_getaddrinfo
apply_ipv4_preference(force=True)
result = socket.getaddrinfo("ipv6only.example.com", 80)
# Should have tried AF_INET first, then fallen back to AF_UNSPEC
assert call_families == [socket.AF_INET, 0]
assert result[0][0] == socket.AF_INET6
class TestConfigDefault:
"""Verify network section exists in DEFAULT_CONFIG."""
def test_network_section_in_default_config(self):
from hermes_cli.config import DEFAULT_CONFIG
assert "network" in DEFAULT_CONFIG
assert DEFAULT_CONFIG["network"]["force_ipv4"] is False
+59
View File
@@ -289,3 +289,62 @@ class TestCodeExecutionBlocked:
def test_notify_on_complete_blocked_in_sandbox(self):
from tools.code_execution_tool import _TERMINAL_BLOCKED_PARAMS
assert "notify_on_complete" in _TERMINAL_BLOCKED_PARAMS
# =========================================================================
# Completion consumed suppression
# =========================================================================
class TestCompletionConsumed:
"""Test that wait/poll/log suppress redundant completion notifications."""
def test_wait_marks_completion_consumed(self, registry):
"""wait() returning exited status marks session as consumed."""
s = _make_session(sid="proc_wait", notify_on_complete=True, output="done")
s.exited = True
s.exit_code = 0
registry._running[s.id] = s
with patch.object(registry, "_write_checkpoint"):
registry._move_to_finished(s)
# Notification is in the queue
assert not registry.completion_queue.empty()
assert not registry.is_completion_consumed("proc_wait")
# Agent calls wait() — gets the result directly
result = registry.wait("proc_wait", timeout=1)
assert result["status"] == "exited"
# Now the completion is marked as consumed
assert registry.is_completion_consumed("proc_wait")
def test_poll_marks_completion_consumed(self, registry):
"""poll() returning exited status marks session as consumed."""
s = _make_session(sid="proc_poll", notify_on_complete=True, output="done")
s.exited = True
s.exit_code = 0
registry._finished[s.id] = s
result = registry.poll("proc_poll")
assert result["status"] == "exited"
assert registry.is_completion_consumed("proc_poll")
def test_log_marks_completion_consumed(self, registry):
"""read_log() on exited session marks as consumed."""
s = _make_session(sid="proc_log", notify_on_complete=True, output="line1\nline2")
s.exited = True
s.exit_code = 0
registry._finished[s.id] = s
result = registry.read_log("proc_log")
assert result["status"] == "exited"
assert registry.is_completion_consumed("proc_log")
def test_running_process_not_consumed(self, registry):
"""poll() on a still-running process does not mark as consumed."""
s = _make_session(sid="proc_running", notify_on_complete=True, output="partial")
registry._running[s.id] = s
result = registry.poll("proc_running")
assert result["status"] == "running"
assert not registry.is_completion_consumed("proc_running")
+11 -2
View File
@@ -6,12 +6,15 @@ Compatibility wrappers remain for direct Python callers and legacy tests.
"""
import json
import logging
import os
import re
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# Import from cron module (will be available when properly installed)
sys.path.insert(0, str(Path(__file__).parent.parent))
@@ -68,11 +71,17 @@ def _origin_from_env() -> Optional[Dict[str, str]]:
origin_platform = get_session_env("HERMES_SESSION_PLATFORM")
origin_chat_id = get_session_env("HERMES_SESSION_CHAT_ID")
if origin_platform and origin_chat_id:
thread_id = get_session_env("HERMES_SESSION_THREAD_ID") or None
if thread_id:
logger.debug(
"Cron origin captured thread_id=%s for %s:%s",
thread_id, origin_platform, origin_chat_id,
)
return {
"platform": origin_platform,
"chat_id": origin_chat_id,
"chat_name": get_session_env("HERMES_SESSION_CHAT_NAME") or None,
"thread_id": get_session_env("HERMES_SESSION_THREAD_ID") or None,
"thread_id": thread_id,
}
return None
@@ -456,7 +465,7 @@ Important safety rule: cron-run sessions should not recursively schedule more cr
},
"deliver": {
"type": "string",
"description": "Delivery target: origin, local, telegram, discord, slack, whatsapp, signal, weixin, matrix, mattermost, homeassistant, dingtalk, feishu, wecom, wecom_callback, email, sms, bluebubbles, or platform:chat_id or platform:chat_id:thread_id for Telegram topics. Examples: 'origin', 'local', 'telegram', 'telegram:-1001234567890:17585', 'discord:#engineering'"
"description": "Omit this parameter to auto-deliver back to the current chat and topic (recommended). Auto-detection preserves thread/topic context. Only set explicitly when the user asks to deliver somewhere OTHER than the current conversation. Values: 'origin' (same as omitting), 'local' (no delivery, save only), or platform:chat_id:thread_id for a specific destination. Examples: 'telegram:-1001234567890:17585', 'discord:#engineering'. WARNING: 'platform:chat_id' without :thread_id loses topic targeting."
},
"skills": {
"type": "array",
+18 -3
View File
@@ -25,6 +25,8 @@ import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Dict, List, Optional
from toolsets import TOOLSETS
# Tools that children must never have access to
DELEGATE_BLOCKED_TOOLS = frozenset([
@@ -35,6 +37,18 @@ DELEGATE_BLOCKED_TOOLS = frozenset([
"execute_code", # children should reason step-by-step, not write scripts
])
# Build a description fragment listing toolsets available for subagents.
# Excludes toolsets where ALL tools are blocked, composite/platform toolsets
# (hermes-* prefixed), and scenario toolsets.
_EXCLUDED_TOOLSET_NAMES = frozenset({"debugging", "safe", "delegation", "moa", "rl"})
_SUBAGENT_TOOLSETS = sorted(
name for name, defn in TOOLSETS.items()
if name not in _EXCLUDED_TOOLSET_NAMES
and not name.startswith("hermes-")
and not all(t in DELEGATE_BLOCKED_TOOLS for t in defn.get("tools", []))
)
_TOOLSET_LIST_STR = ", ".join(f"'{n}'" for n in _SUBAGENT_TOOLSETS)
_DEFAULT_MAX_CONCURRENT_CHILDREN = 3
MAX_DEPTH = 2 # parent (0) -> child (1) -> grandchild rejected (2)
@@ -999,9 +1013,10 @@ DELEGATE_TASK_SCHEMA = {
"description": (
"Toolsets to enable for this subagent. "
"Default: inherits your enabled toolsets. "
f"Available toolsets: {_TOOLSET_LIST_STR}. "
"Common patterns: ['terminal', 'file'] for code work, "
"['web'] for research, ['terminal', 'file', 'web'] for "
"full-stack tasks."
"['web'] for research, ['browser'] for web interaction, "
"['terminal', 'file', 'web'] for full-stack tasks."
),
},
"tasks": {
@@ -1014,7 +1029,7 @@ DELEGATE_TASK_SCHEMA = {
"toolsets": {
"type": "array",
"items": {"type": "string"},
"description": "Toolsets for this specific task. Use 'web' for network access, 'terminal' for shell.",
"description": f"Toolsets for this specific task. Available: {_TOOLSET_LIST_STR}. Use 'web' for network access, 'terminal' for shell, 'browser' for web interaction.",
},
"acp_command": {
"type": "string",
+14 -1
View File
@@ -136,6 +136,10 @@ class ProcessRegistry:
import queue as _queue_mod
self.completion_queue: _queue_mod.Queue = _queue_mod.Queue()
# Track sessions whose completion was already consumed by the agent
# via wait/poll/log. Drain loops skip notifications for these.
self._completion_consumed: set = set()
@staticmethod
def _clean_shell_noise(text: str) -> str:
"""Strip shell startup warnings from the beginning of output."""
@@ -613,6 +617,10 @@ class ProcessRegistry:
# ----- Query Methods -----
def is_completion_consumed(self, session_id: str) -> bool:
"""Check if a completion notification was already consumed via wait/poll/log."""
return session_id in self._completion_consumed
def get(self, session_id: str) -> Optional[ProcessSession]:
"""Get a session by ID (running or finished)."""
with self._lock:
@@ -640,6 +648,7 @@ class ProcessRegistry:
}
if session.exited:
result["exit_code"] = session.exit_code
self._completion_consumed.add(session_id)
if session.detached:
result["detached"] = True
result["note"] = "Process recovered after restart -- output history unavailable"
@@ -665,13 +674,16 @@ class ProcessRegistry:
else:
selected = lines[offset:offset + limit]
return {
result = {
"session_id": session.id,
"status": "exited" if session.exited else "running",
"output": "\n".join(selected),
"total_lines": total_lines,
"showing": f"{len(selected)} lines",
}
if session.exited:
self._completion_consumed.add(session_id)
return result
def wait(self, session_id: str, timeout: int = None) -> dict:
"""
@@ -714,6 +726,7 @@ class ProcessRegistry:
while time.monotonic() < deadline:
session = self._refresh_detached_session(session)
if session.exited:
self._completion_consumed.add(session_id)
result = {
"status": "exited",
"exit_code": session.exit_code,
+23 -40
View File
@@ -145,12 +145,28 @@ def _handle_send(args):
except Exception as e:
return json.dumps(_error(f"Failed to load gateway config: {e}"))
# Accept any platform name — built-in names resolve to their enum
# member, plugin platform names create dynamic members via _missing_().
try:
platform = Platform(platform_name)
except (ValueError, KeyError):
return tool_error(f"Unknown platform: {platform_name}")
platform_map = {
"telegram": Platform.TELEGRAM,
"discord": Platform.DISCORD,
"slack": Platform.SLACK,
"whatsapp": Platform.WHATSAPP,
"signal": Platform.SIGNAL,
"bluebubbles": Platform.BLUEBUBBLES,
"matrix": Platform.MATRIX,
"mattermost": Platform.MATTERMOST,
"homeassistant": Platform.HOMEASSISTANT,
"dingtalk": Platform.DINGTALK,
"feishu": Platform.FEISHU,
"wecom": Platform.WECOM,
"wecom_callback": Platform.WECOM_CALLBACK,
"weixin": Platform.WEIXIN,
"email": Platform.EMAIL,
"sms": Platform.SMS,
}
platform = platform_map.get(platform_name)
if not platform:
avail = ", ".join(platform_map.keys())
return tool_error(f"Unknown platform: {platform_name}. Available: {avail}")
pconfig = config.platforms.get(platform)
if not pconfig or not pconfig.enabled:
@@ -298,27 +314,6 @@ def _maybe_skip_cron_duplicate_send(platform_name: str, chat_id: str, thread_id:
}
async def _send_via_adapter(platform, pconfig, chat_id, chunk):
"""Send a message via a live gateway adapter (for plugin platforms).
Falls back to error if no adapter is connected for this platform.
"""
try:
from gateway.run import _gateway_runner_ref
runner = _gateway_runner_ref()
if runner:
adapter = runner.adapters.get(platform)
if adapter:
from gateway.platforms.base import SendResult
result = await adapter.send(chat_id=chat_id, content=chunk)
if result.success:
return {"success": True, "message_id": result.message_id}
return {"error": f"Adapter send failed: {result.error}"}
except Exception as e:
return {"error": f"Plugin platform send failed: {e}"}
return {"error": f"No live adapter for platform '{platform.value}'. Is the gateway running with this platform connected?"}
async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, media_files=None):
"""Route a message to the appropriate platform sender.
@@ -357,16 +352,6 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
if _feishu_available:
_MAX_LENGTHS[Platform.FEISHU] = FeishuAdapter.MAX_MESSAGE_LENGTH
# Check plugin registry for max_message_length
if platform not in _MAX_LENGTHS:
try:
from gateway.platform_registry import platform_registry
entry = platform_registry.get(platform.value)
if entry and entry.max_message_length > 0:
_MAX_LENGTHS[platform] = entry.max_message_length
except Exception:
pass
# Smart-chunk the message to fit within platform limits.
# For short messages or platforms without a known limit this is a no-op.
max_len = _MAX_LENGTHS.get(platform)
@@ -440,9 +425,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
elif platform == Platform.BLUEBUBBLES:
result = await _send_bluebubbles(pconfig.extra, chat_id, chunk)
else:
# Plugin platform — route through the gateway's live adapter
# if available, otherwise report the error.
result = await _send_via_adapter(platform, pconfig, chat_id, chunk)
result = {"error": f"Direct sending not yet implemented for {platform.value}"}
if isinstance(result, dict) and result.get("error"):
return result
-22
View File
@@ -452,28 +452,6 @@ def resolve_toolset(name: str, visited: Set[str] = None) -> List[str]:
return [e.name for e in registry._tools.values() if e.toolset == name]
except Exception:
pass
# Auto-generate a toolset for plugin platforms (hermes-<name>).
# Gives them _HERMES_CORE_TOOLS plus any tools the plugin registered
# into a toolset matching the platform name.
if name.startswith("hermes-"):
platform_name = name[len("hermes-"):]
try:
from gateway.platform_registry import platform_registry
if platform_registry.is_registered(platform_name):
plugin_tools = set(_HERMES_CORE_TOOLS)
try:
from tools.registry import registry
plugin_tools.update(
e.name for e in registry._tools.values()
if e.toolset == platform_name
)
except Exception:
pass
return list(plugin_tools)
except Exception:
pass
return []
# Collect direct tools
@@ -7,9 +7,7 @@ sidebar_position: 9
This guide covers adding a new messaging platform to the Hermes gateway. A platform adapter connects Hermes to an external messaging service (Telegram, Discord, WeCom, etc.) so users can interact with the agent through that service.
:::tip
There are two ways to add a platform:
- **Plugin** (recommended for community/third-party): Drop a plugin directory into `~/.hermes/plugins/` — zero core code changes needed. See [Plugin Path](#plugin-path-recommended) below.
- **Built-in**: Modify 20+ files across code, config, and docs. Use the [Built-in Checklist](#step-by-step-checklist) below.
Adding a platform adapter touches 20+ files across code, config, and docs. Use this guide as a checklist — the adapter file itself is typically only 40% of the work.
:::
## Architecture Overview
@@ -28,152 +26,7 @@ Every adapter extends `BasePlatformAdapter` from `gateway/platforms/base.py` and
Inbound messages are received by the adapter and forwarded via `self.handle_message(event)`, which the base class routes to the gateway runner.
## Plugin Path (Recommended)
The plugin system lets you add a platform adapter without modifying any core Hermes code. Your plugin is a directory with two files:
```
~/.hermes/plugins/my-platform/
PLUGIN.yaml # Plugin metadata
adapter.py # Adapter class + register() entry point
```
### PLUGIN.yaml
```yaml
name: my-platform
version: 1.0.0
description: My custom messaging platform adapter
requires_env:
- MY_PLATFORM_TOKEN
- MY_PLATFORM_CHANNEL
```
### adapter.py
```python
import os
from gateway.platforms.base import (
BasePlatformAdapter, SendResult, MessageEvent, MessageType,
)
from gateway.config import Platform, PlatformConfig
class MyPlatformAdapter(BasePlatformAdapter):
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform("my_platform"))
extra = config.extra or {}
self.token = os.getenv("MY_PLATFORM_TOKEN") or extra.get("token", "")
async def connect(self) -> bool:
# Connect to the platform API, start listeners
self._mark_connected()
return True
async def disconnect(self) -> None:
self._mark_disconnected()
async def send(self, chat_id, content, reply_to=None, metadata=None):
# Send message via platform API
return SendResult(success=True, message_id="...")
async def get_chat_info(self, chat_id):
return {"name": chat_id, "type": "dm"}
def check_requirements() -> bool:
return bool(os.getenv("MY_PLATFORM_TOKEN"))
def validate_config(config) -> bool:
extra = getattr(config, "extra", {}) or {}
return bool(os.getenv("MY_PLATFORM_TOKEN") or extra.get("token"))
def register(ctx):
"""Plugin entry point — called by the Hermes plugin system."""
ctx.register_platform(
name="my_platform",
label="My Platform",
adapter_factory=lambda cfg: MyPlatformAdapter(cfg),
check_fn=check_requirements,
validate_config=validate_config,
required_env=["MY_PLATFORM_TOKEN"],
install_hint="pip install my-platform-sdk",
# Per-platform user authorization env vars
allowed_users_env="MY_PLATFORM_ALLOWED_USERS",
allow_all_env="MY_PLATFORM_ALLOW_ALL_USERS",
# Message length limit for smart chunking (0 = no limit)
max_message_length=4000,
# LLM guidance injected into system prompt
platform_hint=(
"You are chatting via My Platform. "
"It supports markdown formatting."
),
# Display
emoji="💬",
)
# Optional: register platform-specific tools
ctx.register_tool(
name="my_platform_search",
toolset="my_platform",
schema={...},
handler=my_search_handler,
)
```
### Configuration
Users configure the platform in `config.yaml`:
```yaml
gateway:
platforms:
my_platform:
enabled: true
extra:
token: "..."
channel: "#general"
```
Or via environment variables (which the adapter reads in `__init__`).
### What the Plugin System Handles Automatically
When you call `ctx.register_platform()`, the following integration points are handled for you — no core code changes needed:
| Integration point | How it works |
|---|---|
| Gateway adapter creation | Registry checked before built-in if/elif chain |
| Config parsing | `Platform._missing_()` accepts any platform name |
| Connected platform validation | Registry `validate_config()` called |
| User authorization | `allowed_users_env` / `allow_all_env` checked |
| Cron delivery | `Platform()` resolves any registered name |
| send_message tool | Routes through live gateway adapter |
| Webhook cross-platform delivery | Registry checked for known platforms |
| `/update` command access | `allow_update_command` flag |
| Channel directory | Plugin platforms included in enumeration |
| System prompt hints | `platform_hint` injected into LLM context |
| Message chunking | `max_message_length` for smart splitting |
| PII redaction | `pii_safe` flag |
| `hermes status` | Shows plugin platforms with `(plugin)` tag |
| `hermes gateway setup` | Plugin platforms appear in setup menu |
| `hermes tools` / `hermes skills` | Plugin platforms in per-platform config |
| Token lock (multi-profile) | Use `acquire_scoped_lock()` in your `connect()` |
| Orphaned config warning | Descriptive log when plugin is missing |
### Reference Implementation
See `plugins/platforms/irc/` in the repo for a complete working example — a full async IRC adapter with zero external dependencies.
---
## Step-by-Step Checklist (Built-in Path)
:::note
This checklist is for adding a platform directly to the Hermes core codebase — typically done by core contributors for officially supported platforms. Community/third-party platforms should use the [Plugin Path](#plugin-path-recommended) above.
:::
## Step-by-Step Checklist
### 1. Platform Enum
+2 -2
View File
@@ -23,7 +23,7 @@ hermes claw migrate --preset full --yes
The migration always shows a full preview of what will be imported before making any changes. Review the list, then confirm to proceed.
Reads from `~/.openclaw/` by default. Legacy `~/.clawdbot/` or `~/.moldbot/` directories are detected automatically. Same for legacy config filenames (`clawdbot.json`, `moldbot.json`).
Reads from `~/.openclaw/` by default. Legacy `~/.clawdbot/` or `~/.moltbot/` directories are detected automatically. Same for legacy config filenames (`clawdbot.json`, `moltbot.json`).
## Options
@@ -234,7 +234,7 @@ The migration resolves all three formats. For env templates and SecretRef object
### "OpenClaw directory not found"
The migration checks `~/.openclaw/`, then `~/.clawdbot/`, then `~/.moldbot/`. If your installation is elsewhere, use `--source /path/to/your/openclaw`.
The migration checks `~/.openclaw/`, then `~/.clawdbot/`, then `~/.moltbot/`. If your installation is elsewhere, use `--source /path/to/your/openclaw`.
### "No provider API keys found"
+1 -1
View File
@@ -660,7 +660,7 @@ hermes insights [--days N] [--source platform]
hermes claw migrate [options]
```
Migrate your OpenClaw setup to Hermes. Reads from `~/.openclaw` (or a custom path) and writes to `~/.hermes`. Automatically detects legacy directory names (`~/.clawdbot`, `~/.moldbot`) and config filenames (`clawdbot.json`, `moldbot.json`).
Migrate your OpenClaw setup to Hermes. Reads from `~/.openclaw` (or a custom path) and writes to `~/.hermes`. Automatically detects legacy directory names (`~/.clawdbot`, `~/.moltbot`) and config filenames (`clawdbot.json`, `moltbot.json`).
| Option | Description |
|--------|-------------|