Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d814cd115 | |||
| c52f6348b6 | |||
| 3162472674 | |||
| 8b9d22a74b | |||
| fee0e0d35e | |||
| 81ac62c0e9 | |||
| f53a5a7fe1 | |||
| fdf55e0fe9 | |||
| 36f57dbc51 | |||
| 1871227198 | |||
| eb2a49f95a | |||
| 73f970fa4d | |||
| 4cadfef8e3 | |||
| 8e00b3a69e | |||
| 1ca9b19750 | |||
| 1cec910b6a | |||
| 8a48c58bd3 | |||
| a0a02c1bc0 | |||
| cfbfc4c3f1 | |||
| fa7cd44b92 | |||
| 50d86b3c71 | |||
| 27eeea0555 | |||
| fd73937ec8 |
+23
-6
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -219,6 +219,21 @@ 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
|
||||
|
||||
@@ -626,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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -456,6 +456,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
# show the standard typing gateway event for bots)
|
||||
self._typing_tasks: Dict[str, asyncio.Task] = {}
|
||||
self._bot_task: Optional[asyncio.Task] = None
|
||||
self._post_connect_task: Optional[asyncio.Task] = None
|
||||
# Dedup cache: prevents duplicate bot responses when Discord
|
||||
# RESUME replays events after reconnects.
|
||||
self._dedup = MessageDeduplicator()
|
||||
@@ -545,15 +546,14 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
|
||||
# Resolve any usernames in the allowed list to numeric IDs
|
||||
await adapter_self._resolve_allowed_usernames()
|
||||
|
||||
# Sync slash commands with Discord
|
||||
try:
|
||||
synced = await adapter_self._client.tree.sync()
|
||||
logger.info("[%s] Synced %d slash command(s)", adapter_self.name, len(synced))
|
||||
except Exception as e: # pragma: no cover - defensive logging
|
||||
logger.warning("[%s] Slash command sync failed: %s", adapter_self.name, e, exc_info=True)
|
||||
adapter_self._ready_event.set()
|
||||
|
||||
if adapter_self._post_connect_task and not adapter_self._post_connect_task.done():
|
||||
adapter_self._post_connect_task.cancel()
|
||||
adapter_self._post_connect_task = asyncio.create_task(
|
||||
adapter_self._run_post_connect_initialization()
|
||||
)
|
||||
|
||||
@self._client.event
|
||||
async def on_message(message: DiscordMessage):
|
||||
# Dedup: Discord RESUME replays events after reconnects (#4777)
|
||||
@@ -686,14 +686,36 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
except Exception as e: # pragma: no cover - defensive logging
|
||||
logger.warning("[%s] Error during disconnect: %s", self.name, e, exc_info=True)
|
||||
|
||||
if self._post_connect_task and not self._post_connect_task.done():
|
||||
self._post_connect_task.cancel()
|
||||
try:
|
||||
await self._post_connect_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
self._running = False
|
||||
self._client = None
|
||||
self._ready_event.clear()
|
||||
self._post_connect_task = None
|
||||
|
||||
self._release_platform_lock()
|
||||
|
||||
logger.info("[%s] Disconnected", self.name)
|
||||
|
||||
async def _run_post_connect_initialization(self) -> None:
|
||||
"""Finish non-critical startup work after Discord is connected."""
|
||||
if not self._client:
|
||||
return
|
||||
try:
|
||||
synced = await asyncio.wait_for(self._client.tree.sync(), timeout=30)
|
||||
logger.info("[%s] Synced %d slash command(s)", self.name, len(synced))
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("[%s] Slash command sync timed out after 30s", self.name)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e: # pragma: no cover - defensive logging
|
||||
logger.warning("[%s] Slash command sync failed: %s", self.name, e, exc_info=True)
|
||||
|
||||
async def _add_reaction(self, message: Any, emoji: str) -> bool:
|
||||
"""Add an emoji reaction to a Discord message."""
|
||||
if not message or not hasattr(message, "add_reaction"):
|
||||
|
||||
+162
-55
@@ -104,7 +104,7 @@ MAX_MESSAGE_LENGTH = 4000
|
||||
# Uses get_hermes_home() so each profile gets its own Matrix store.
|
||||
from hermes_constants import get_hermes_dir as _get_hermes_dir
|
||||
_STORE_DIR = _get_hermes_dir("platforms/matrix/store", "matrix/store")
|
||||
_CRYPTO_PICKLE_PATH = _STORE_DIR / "crypto_store.pickle"
|
||||
_CRYPTO_DB_PATH = _STORE_DIR / "crypto.db"
|
||||
|
||||
# Grace period: ignore messages older than this many seconds before startup.
|
||||
_STARTUP_GRACE_SECONDS = 5
|
||||
@@ -165,6 +165,33 @@ def check_matrix_requirements() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class _CryptoStateStore:
|
||||
"""Adapter that satisfies the mautrix crypto StateStore interface.
|
||||
|
||||
OlmMachine requires a StateStore with ``is_encrypted``,
|
||||
``get_encryption_info``, and ``find_shared_rooms``. The basic
|
||||
``MemoryStateStore`` from ``mautrix.client`` doesn't implement these,
|
||||
so we provide simple implementations that consult the client's room
|
||||
state.
|
||||
"""
|
||||
|
||||
def __init__(self, client_state_store: Any, joined_rooms: set):
|
||||
self._ss = client_state_store
|
||||
self._joined_rooms = joined_rooms
|
||||
|
||||
async def is_encrypted(self, room_id: str) -> bool:
|
||||
return (await self.get_encryption_info(room_id)) is not None
|
||||
|
||||
async def get_encryption_info(self, room_id: str):
|
||||
if hasattr(self._ss, "get_encryption_info"):
|
||||
return await self._ss.get_encryption_info(room_id)
|
||||
return None
|
||||
|
||||
async def find_shared_rooms(self, user_id: str) -> list:
|
||||
# Return all joined rooms — simple but correct for a single-user bot.
|
||||
return list(self._joined_rooms)
|
||||
|
||||
|
||||
class MatrixAdapter(BasePlatformAdapter):
|
||||
"""Gateway adapter for Matrix (any homeserver)."""
|
||||
|
||||
@@ -199,6 +226,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
)
|
||||
|
||||
self._client: Any = None # mautrix.client.Client
|
||||
self._crypto_db: Any = None # mautrix.util.async_db.Database
|
||||
self._sync_task: Optional[asyncio.Task] = None
|
||||
self._closing = False
|
||||
self._startup_ts: float = 0.0
|
||||
@@ -252,6 +280,92 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
self._processed_events_set.add(event_id)
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# E2EE helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _verify_device_keys_on_server(self, client: Any, olm: Any) -> bool:
|
||||
"""Verify our device keys are on the homeserver after loading crypto state.
|
||||
|
||||
Returns True if keys are valid or were successfully re-uploaded.
|
||||
Returns False if verification fails (caller should refuse E2EE).
|
||||
"""
|
||||
try:
|
||||
resp = await client.query_keys({client.mxid: [client.device_id]})
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Matrix: cannot verify device keys on server: %s — refusing E2EE", exc,
|
||||
)
|
||||
return False
|
||||
|
||||
# query_keys returns typed objects (QueryKeysResponse, DeviceKeys
|
||||
# with KeyID keys). Normalise to plain strings for comparison.
|
||||
device_keys_map = getattr(resp, "device_keys", {}) or {}
|
||||
our_user_devices = device_keys_map.get(str(client.mxid)) or {}
|
||||
our_keys = our_user_devices.get(str(client.device_id))
|
||||
|
||||
if not our_keys:
|
||||
logger.warning("Matrix: device keys missing from server — re-uploading")
|
||||
olm.account.shared = False
|
||||
try:
|
||||
await olm.share_keys()
|
||||
except Exception as exc:
|
||||
logger.error("Matrix: failed to re-upload device keys: %s", exc)
|
||||
return False
|
||||
return True
|
||||
|
||||
# DeviceKeys.keys is a dict[KeyID, str]. Iterate to find the
|
||||
# ed25519 key rather than constructing a KeyID for lookup.
|
||||
server_ed25519 = None
|
||||
keys_dict = getattr(our_keys, "keys", {}) or {}
|
||||
for key_id, key_value in keys_dict.items():
|
||||
if str(key_id).startswith("ed25519:"):
|
||||
server_ed25519 = str(key_value)
|
||||
break
|
||||
local_ed25519 = olm.account.identity_keys.get("ed25519")
|
||||
|
||||
if server_ed25519 != local_ed25519:
|
||||
if olm.account.shared:
|
||||
# Restored account from DB but server has different keys — corrupted state.
|
||||
logger.error(
|
||||
"Matrix: server has different identity keys for device %s — "
|
||||
"local crypto state is stale. Delete %s and restart.",
|
||||
client.device_id,
|
||||
_CRYPTO_DB_PATH,
|
||||
)
|
||||
return False
|
||||
|
||||
# Fresh account (never uploaded). Server has stale keys from a
|
||||
# previous installation. Try to delete the old device and re-upload.
|
||||
logger.warning(
|
||||
"Matrix: server has stale keys for device %s — attempting re-upload",
|
||||
client.device_id,
|
||||
)
|
||||
try:
|
||||
await client.api.request(
|
||||
client.api.Method.DELETE
|
||||
if hasattr(client.api, "Method")
|
||||
else "DELETE",
|
||||
f"/_matrix/client/v3/devices/{client.device_id}",
|
||||
)
|
||||
logger.info("Matrix: deleted stale device %s from server", client.device_id)
|
||||
except Exception:
|
||||
# Device deletion often requires UIA or may simply not be
|
||||
# permitted — that's fine, share_keys will try to overwrite.
|
||||
pass
|
||||
try:
|
||||
await olm.share_keys()
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Matrix: cannot upload device keys for %s: %s. "
|
||||
"Try generating a new access token to get a fresh device.",
|
||||
client.device_id,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Required overrides
|
||||
# ------------------------------------------------------------------
|
||||
@@ -350,54 +464,54 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
return False
|
||||
try:
|
||||
from mautrix.crypto import OlmMachine
|
||||
from mautrix.crypto.store import MemoryCryptoStore
|
||||
from mautrix.crypto.store.asyncpg import PgCryptoStore
|
||||
from mautrix.util.async_db import Database
|
||||
|
||||
_STORE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Remove legacy pickle file from pre-SQLite era.
|
||||
legacy_pickle = _STORE_DIR / "crypto_store.pickle"
|
||||
if legacy_pickle.exists():
|
||||
logger.info("Matrix: removing legacy crypto_store.pickle (migrated to SQLite)")
|
||||
legacy_pickle.unlink()
|
||||
|
||||
# Open SQLite-backed crypto store.
|
||||
crypto_db = Database.create(
|
||||
f"sqlite:///{_CRYPTO_DB_PATH}",
|
||||
upgrade_table=PgCryptoStore.upgrade_table,
|
||||
)
|
||||
await crypto_db.start()
|
||||
self._crypto_db = crypto_db
|
||||
|
||||
# account_id and pickle_key are required by mautrix ≥0.21.
|
||||
# Use the Matrix user ID as account_id for stable identity.
|
||||
# pickle_key secures in-memory serialisation; derive from
|
||||
# the same user_id:device_id pair used for the on-disk HMAC.
|
||||
_acct_id = self._user_id or "hermes"
|
||||
_pickle_key = f"{_acct_id}:{self._device_id}"
|
||||
crypto_store = MemoryCryptoStore(
|
||||
_pickle_key = f"{_acct_id}:{self._device_id or 'default'}"
|
||||
crypto_store = PgCryptoStore(
|
||||
account_id=_acct_id,
|
||||
pickle_key=_pickle_key,
|
||||
db=crypto_db,
|
||||
)
|
||||
await crypto_store.open()
|
||||
|
||||
# Restore persisted crypto state from a previous run.
|
||||
# Uses HMAC to verify integrity before unpickling.
|
||||
pickle_path = _CRYPTO_PICKLE_PATH
|
||||
if pickle_path.exists():
|
||||
try:
|
||||
import hashlib, hmac, pickle
|
||||
raw = pickle_path.read_bytes()
|
||||
# Format: 32-byte HMAC-SHA256 signature + pickle data.
|
||||
if len(raw) > 32:
|
||||
sig, payload = raw[:32], raw[32:]
|
||||
# Key is derived from the device_id + user_id (stable per install).
|
||||
hmac_key = f"{self._user_id}:{self._device_id}".encode()
|
||||
expected = hmac.new(hmac_key, payload, hashlib.sha256).digest()
|
||||
if hmac.compare_digest(sig, expected):
|
||||
saved = pickle.loads(payload) # noqa: S301
|
||||
if isinstance(saved, MemoryCryptoStore):
|
||||
crypto_store = saved
|
||||
logger.info("Matrix: restored E2EE crypto store from %s", pickle_path)
|
||||
else:
|
||||
logger.warning("Matrix: crypto store HMAC mismatch — ignoring stale/tampered file")
|
||||
except Exception as exc:
|
||||
logger.warning("Matrix: could not restore crypto store: %s", exc)
|
||||
crypto_state = _CryptoStateStore(state_store, self._joined_rooms)
|
||||
olm = OlmMachine(client, crypto_store, crypto_state)
|
||||
|
||||
olm = OlmMachine(client, crypto_store, state_store)
|
||||
|
||||
# Set trust policy: accept unverified devices so senders
|
||||
# share Megolm session keys with us automatically.
|
||||
# Accept unverified devices so senders share Megolm
|
||||
# session keys with us automatically.
|
||||
olm.share_keys_min_trust = TrustState.UNVERIFIED
|
||||
olm.send_keys_min_trust = TrustState.UNVERIFIED
|
||||
|
||||
await olm.load()
|
||||
|
||||
# Verify our device keys are still on the homeserver.
|
||||
if not await self._verify_device_keys_on_server(client, olm):
|
||||
await crypto_db.stop()
|
||||
await api.session.close()
|
||||
return False
|
||||
|
||||
client.crypto = olm
|
||||
logger.info(
|
||||
"Matrix: E2EE enabled (store: %s%s)",
|
||||
str(_STORE_DIR),
|
||||
str(_CRYPTO_DB_PATH),
|
||||
f", device_id={client.device_id}" if client.device_id else "",
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -438,6 +552,15 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
)
|
||||
# Build DM room cache from m.direct account data.
|
||||
await self._refresh_dm_cache()
|
||||
|
||||
# Dispatch events from the initial sync so the OlmMachine
|
||||
# receives to-device key shares queued while we were offline.
|
||||
try:
|
||||
tasks = client.handle_sync(sync_data)
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks)
|
||||
except Exception as exc:
|
||||
logger.warning("Matrix: initial sync event dispatch error: %s", exc)
|
||||
else:
|
||||
logger.warning("Matrix: initial sync returned unexpected type %s", type(sync_data).__name__)
|
||||
except Exception as exc:
|
||||
@@ -466,21 +589,12 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
# Persist E2EE crypto store before closing so the next restart
|
||||
# can decrypt events using sessions from this run.
|
||||
if self._client and self._encryption and getattr(self._client, "crypto", None):
|
||||
# Close the SQLite crypto store database.
|
||||
if hasattr(self, "_crypto_db") and self._crypto_db:
|
||||
try:
|
||||
import hashlib, hmac, pickle
|
||||
crypto_store = self._client.crypto.crypto_store
|
||||
_STORE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
pickle_path = _CRYPTO_PICKLE_PATH
|
||||
payload = pickle.dumps(crypto_store)
|
||||
hmac_key = f"{self._user_id}:{self._device_id}".encode()
|
||||
sig = hmac.new(hmac_key, payload, hashlib.sha256).digest()
|
||||
pickle_path.write_bytes(sig + payload)
|
||||
logger.info("Matrix: persisted E2EE crypto store to %s", pickle_path)
|
||||
await self._crypto_db.stop()
|
||||
except Exception as exc:
|
||||
logger.debug("Matrix: could not persist crypto store on disconnect: %s", exc)
|
||||
logger.debug("Matrix: could not close crypto DB on disconnect: %s", exc)
|
||||
|
||||
if self._client:
|
||||
try:
|
||||
@@ -853,13 +967,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
except Exception as exc:
|
||||
logger.warning("Matrix: sync event dispatch error: %s", exc)
|
||||
|
||||
# Share keys periodically if E2EE is enabled.
|
||||
if self._encryption and getattr(client, "crypto", None):
|
||||
try:
|
||||
await client.crypto.share_keys()
|
||||
except Exception as exc:
|
||||
logger.warning("Matrix: E2EE key share failed: %s", exc)
|
||||
|
||||
# Retry any buffered undecrypted events.
|
||||
if self._pending_megolm:
|
||||
await self._retry_pending_decryptions()
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
+197
-39
@@ -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
|
||||
@@ -916,6 +925,12 @@ class GatewayRunner:
|
||||
adapter.fatal_error_code or "unknown",
|
||||
adapter.fatal_error_message or "unknown error",
|
||||
)
|
||||
self._update_platform_runtime_status(
|
||||
adapter.platform.value,
|
||||
platform_state="retrying" if adapter.fatal_error_retryable else "fatal",
|
||||
error_code=adapter.fatal_error_code,
|
||||
error_message=adapter.fatal_error_message,
|
||||
)
|
||||
|
||||
existing = self.adapters.get(adapter.platform)
|
||||
if existing is adapter:
|
||||
@@ -993,6 +1008,25 @@ class GatewayRunner:
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _update_platform_runtime_status(
|
||||
self,
|
||||
platform: str,
|
||||
*,
|
||||
platform_state: Optional[str] = None,
|
||||
error_code: Optional[str] = None,
|
||||
error_message: Optional[str] = None,
|
||||
) -> None:
|
||||
try:
|
||||
from gateway.status import write_runtime_status
|
||||
write_runtime_status(
|
||||
platform=platform,
|
||||
platform_state=platform_state,
|
||||
error_code=error_code,
|
||||
error_message=error_message,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _load_prefill_messages() -> List[Dict[str, Any]]:
|
||||
@@ -1498,16 +1532,34 @@ class GatewayRunner:
|
||||
|
||||
# Try to connect
|
||||
logger.info("Connecting to %s...", platform.value)
|
||||
self._update_platform_runtime_status(
|
||||
platform.value,
|
||||
platform_state="connecting",
|
||||
error_code=None,
|
||||
error_message=None,
|
||||
)
|
||||
try:
|
||||
success = await adapter.connect()
|
||||
if success:
|
||||
self.adapters[platform] = adapter
|
||||
self._sync_voice_mode_state_to_adapter(adapter)
|
||||
connected_count += 1
|
||||
self._update_platform_runtime_status(
|
||||
platform.value,
|
||||
platform_state="connected",
|
||||
error_code=None,
|
||||
error_message=None,
|
||||
)
|
||||
logger.info("✓ %s connected", platform.value)
|
||||
else:
|
||||
logger.warning("✗ %s failed to connect", platform.value)
|
||||
if adapter.has_fatal_error:
|
||||
self._update_platform_runtime_status(
|
||||
platform.value,
|
||||
platform_state="retrying" if adapter.fatal_error_retryable else "fatal",
|
||||
error_code=adapter.fatal_error_code,
|
||||
error_message=adapter.fatal_error_message,
|
||||
)
|
||||
target = (
|
||||
startup_retryable_errors
|
||||
if adapter.fatal_error_retryable
|
||||
@@ -1524,6 +1576,12 @@ class GatewayRunner:
|
||||
"next_retry": time.monotonic() + 30,
|
||||
}
|
||||
else:
|
||||
self._update_platform_runtime_status(
|
||||
platform.value,
|
||||
platform_state="retrying",
|
||||
error_code=None,
|
||||
error_message="failed to connect",
|
||||
)
|
||||
startup_retryable_errors.append(
|
||||
f"{platform.value}: failed to connect"
|
||||
)
|
||||
@@ -1535,6 +1593,12 @@ class GatewayRunner:
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("✗ %s error: %s", platform.value, e)
|
||||
self._update_platform_runtime_status(
|
||||
platform.value,
|
||||
platform_state="retrying",
|
||||
error_code=None,
|
||||
error_message=str(e),
|
||||
)
|
||||
startup_retryable_errors.append(f"{platform.value}: {e}")
|
||||
# Unexpected exceptions are typically transient — queue for retry
|
||||
self._failed_platforms[platform] = {
|
||||
@@ -1813,6 +1877,12 @@ class GatewayRunner:
|
||||
self._sync_voice_mode_state_to_adapter(adapter)
|
||||
self.delivery_router.adapters = self.adapters
|
||||
del self._failed_platforms[platform]
|
||||
self._update_platform_runtime_status(
|
||||
platform.value,
|
||||
platform_state="connected",
|
||||
error_code=None,
|
||||
error_message=None,
|
||||
)
|
||||
logger.info("✓ %s reconnected successfully", platform.value)
|
||||
|
||||
# Rebuild channel directory with the new adapter
|
||||
@@ -1824,12 +1894,24 @@ class GatewayRunner:
|
||||
else:
|
||||
# Check if the failure is non-retryable
|
||||
if adapter.has_fatal_error and not adapter.fatal_error_retryable:
|
||||
self._update_platform_runtime_status(
|
||||
platform.value,
|
||||
platform_state="fatal",
|
||||
error_code=adapter.fatal_error_code,
|
||||
error_message=adapter.fatal_error_message,
|
||||
)
|
||||
logger.warning(
|
||||
"Reconnect %s: non-retryable error (%s), removing from retry queue",
|
||||
platform.value, adapter.fatal_error_message,
|
||||
)
|
||||
del self._failed_platforms[platform]
|
||||
else:
|
||||
self._update_platform_runtime_status(
|
||||
platform.value,
|
||||
platform_state="retrying",
|
||||
error_code=adapter.fatal_error_code,
|
||||
error_message=adapter.fatal_error_message or "failed to reconnect",
|
||||
)
|
||||
backoff = min(30 * (2 ** (attempt - 1)), _BACKOFF_CAP)
|
||||
info["attempts"] = attempt
|
||||
info["next_retry"] = time.monotonic() + backoff
|
||||
@@ -1838,6 +1920,12 @@ class GatewayRunner:
|
||||
platform.value, backoff,
|
||||
)
|
||||
except Exception as e:
|
||||
self._update_platform_runtime_status(
|
||||
platform.value,
|
||||
platform_state="retrying",
|
||||
error_code=None,
|
||||
error_message=str(e),
|
||||
)
|
||||
backoff = min(30 * (2 ** (attempt - 1)), _BACKOFF_CAP)
|
||||
info["attempts"] = attempt
|
||||
info["next_retry"] = time.monotonic() + backoff
|
||||
@@ -3877,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."""
|
||||
@@ -5637,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)
|
||||
@@ -5645,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
|
||||
@@ -5686,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
|
||||
@@ -5710,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)
|
||||
@@ -6903,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 = (
|
||||
@@ -7911,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())
|
||||
|
||||
@@ -7995,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
|
||||
@@ -8040,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.
|
||||
@@ -8458,23 +8626,11 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Centralized logging — agent.log (INFO+) and errors.log (WARNING+).
|
||||
# Centralized logging — agent.log (INFO+), errors.log (WARNING+),
|
||||
# and gateway.log (INFO+, gateway-component records only).
|
||||
# Idempotent, so repeated calls from AIAgent.__init__ won't duplicate.
|
||||
from hermes_logging import setup_logging
|
||||
log_dir = setup_logging(hermes_home=_hermes_home, mode="gateway")
|
||||
|
||||
# Gateway-specific rotating log — captures all gateway-level messages
|
||||
# (session management, platform adapters, slash commands, etc.).
|
||||
from agent.redact import RedactingFormatter
|
||||
from hermes_logging import _add_rotating_handler
|
||||
_add_rotating_handler(
|
||||
logging.getLogger(),
|
||||
log_dir / 'gateway.log',
|
||||
level=logging.INFO,
|
||||
max_bytes=5 * 1024 * 1024,
|
||||
backup_count=3,
|
||||
formatter=RedactingFormatter('%(asctime)s %(levelname)s %(name)s: %(message)s'),
|
||||
)
|
||||
setup_logging(hermes_home=_hermes_home, mode="gateway")
|
||||
|
||||
# Optional stderr handler — level driven by -v/-q flags on the CLI.
|
||||
# verbosity=None (-q/--quiet): no stderr output
|
||||
@@ -8482,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
-2
@@ -807,9 +807,9 @@ class SessionStore:
|
||||
to avoid resetting long-idle sessions that are harmless to resume.
|
||||
Returns the number of sessions that were suspended.
|
||||
"""
|
||||
import time as _time
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff = _time.time() - max_age_seconds
|
||||
cutoff = _now() - timedelta(seconds=max_age_seconds)
|
||||
count = 0
|
||||
with self._lock:
|
||||
self._ensure_loaded_locked()
|
||||
|
||||
+17
-16
@@ -26,6 +26,7 @@ _GATEWAY_KIND = "hermes-gateway"
|
||||
_RUNTIME_STATUS_FILE = "gateway_state.json"
|
||||
_LOCKS_DIRNAME = "gateway-locks"
|
||||
_IS_WINDOWS = sys.platform == "win32"
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def _get_pid_path() -> Path:
|
||||
@@ -218,14 +219,14 @@ def write_pid_file() -> None:
|
||||
|
||||
def write_runtime_status(
|
||||
*,
|
||||
gateway_state: Optional[str] = None,
|
||||
exit_reason: Optional[str] = None,
|
||||
restart_requested: Optional[bool] = None,
|
||||
active_agents: Optional[int] = None,
|
||||
platform: Optional[str] = None,
|
||||
platform_state: Optional[str] = None,
|
||||
error_code: Optional[str] = None,
|
||||
error_message: Optional[str] = None,
|
||||
gateway_state: Any = _UNSET,
|
||||
exit_reason: Any = _UNSET,
|
||||
restart_requested: Any = _UNSET,
|
||||
active_agents: Any = _UNSET,
|
||||
platform: Any = _UNSET,
|
||||
platform_state: Any = _UNSET,
|
||||
error_code: Any = _UNSET,
|
||||
error_message: Any = _UNSET,
|
||||
) -> None:
|
||||
"""Persist gateway runtime health information for diagnostics/status."""
|
||||
path = _get_runtime_status_path()
|
||||
@@ -236,22 +237,22 @@ def write_runtime_status(
|
||||
payload["start_time"] = _get_process_start_time(os.getpid())
|
||||
payload["updated_at"] = _utc_now_iso()
|
||||
|
||||
if gateway_state is not None:
|
||||
if gateway_state is not _UNSET:
|
||||
payload["gateway_state"] = gateway_state
|
||||
if exit_reason is not None:
|
||||
if exit_reason is not _UNSET:
|
||||
payload["exit_reason"] = exit_reason
|
||||
if restart_requested is not None:
|
||||
if restart_requested is not _UNSET:
|
||||
payload["restart_requested"] = bool(restart_requested)
|
||||
if active_agents is not None:
|
||||
if active_agents is not _UNSET:
|
||||
payload["active_agents"] = max(0, int(active_agents))
|
||||
|
||||
if platform is not None:
|
||||
if platform is not _UNSET:
|
||||
platform_payload = payload["platforms"].get(platform, {})
|
||||
if platform_state is not None:
|
||||
if platform_state is not _UNSET:
|
||||
platform_payload["state"] = platform_state
|
||||
if error_code is not None:
|
||||
if error_code is not _UNSET:
|
||||
platform_payload["error_code"] = error_code
|
||||
if error_message is not None:
|
||||
if error_message is not _UNSET:
|
||||
platform_payload["error_message"] = error_message
|
||||
platform_payload["updated_at"] = _utc_now_iso()
|
||||
payload["platforms"][platform] = platform_payload
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
"""
|
||||
Backup and import commands for hermes CLI.
|
||||
|
||||
`hermes backup` creates a zip archive of the entire ~/.hermes/ directory
|
||||
(excluding the hermes-agent repo and transient files).
|
||||
|
||||
`hermes import` restores from a backup zip, overlaying onto the current
|
||||
HERMES_HOME root.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import get_default_hermes_root, display_hermes_home
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exclusion rules
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Directory names to skip entirely (matched against each path component)
|
||||
_EXCLUDED_DIRS = {
|
||||
"hermes-agent", # the codebase repo — re-clone instead
|
||||
"__pycache__", # bytecode caches — regenerated on import
|
||||
".git", # nested git dirs (profiles shouldn't have these, but safety)
|
||||
"node_modules", # js deps if website/ somehow leaks in
|
||||
}
|
||||
|
||||
# File-name suffixes to skip
|
||||
_EXCLUDED_SUFFIXES = (
|
||||
".pyc",
|
||||
".pyo",
|
||||
)
|
||||
|
||||
# File names to skip (runtime state that's meaningless on another machine)
|
||||
_EXCLUDED_NAMES = {
|
||||
"gateway.pid",
|
||||
"cron.pid",
|
||||
}
|
||||
|
||||
|
||||
def _should_exclude(rel_path: Path) -> bool:
|
||||
"""Return True if *rel_path* (relative to hermes root) should be skipped."""
|
||||
parts = rel_path.parts
|
||||
|
||||
# Any path component matches an excluded dir name
|
||||
for part in parts:
|
||||
if part in _EXCLUDED_DIRS:
|
||||
return True
|
||||
|
||||
name = rel_path.name
|
||||
|
||||
if name in _EXCLUDED_NAMES:
|
||||
return True
|
||||
|
||||
if name.endswith(_EXCLUDED_SUFFIXES):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _format_size(nbytes: int) -> str:
|
||||
"""Human-readable file size."""
|
||||
for unit in ("B", "KB", "MB", "GB"):
|
||||
if nbytes < 1024:
|
||||
return f"{nbytes:.1f} {unit}" if unit != "B" else f"{nbytes} {unit}"
|
||||
nbytes /= 1024
|
||||
return f"{nbytes:.1f} TB"
|
||||
|
||||
|
||||
def run_backup(args) -> None:
|
||||
"""Create a zip backup of the Hermes home directory."""
|
||||
hermes_root = get_default_hermes_root()
|
||||
|
||||
if not hermes_root.is_dir():
|
||||
print(f"Error: Hermes home directory not found at {hermes_root}")
|
||||
sys.exit(1)
|
||||
|
||||
# Determine output path
|
||||
if args.output:
|
||||
out_path = Path(args.output).expanduser().resolve()
|
||||
# If user gave a directory, put the zip inside it
|
||||
if out_path.is_dir():
|
||||
stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
|
||||
out_path = out_path / f"hermes-backup-{stamp}.zip"
|
||||
else:
|
||||
stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
|
||||
out_path = Path.home() / f"hermes-backup-{stamp}.zip"
|
||||
|
||||
# Ensure the suffix is .zip
|
||||
if out_path.suffix.lower() != ".zip":
|
||||
out_path = out_path.with_suffix(out_path.suffix + ".zip")
|
||||
|
||||
# Ensure parent directory exists
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Collect files
|
||||
print(f"Scanning {display_hermes_home()} ...")
|
||||
files_to_add: list[tuple[Path, Path]] = [] # (absolute, relative)
|
||||
skipped_dirs = set()
|
||||
|
||||
for dirpath, dirnames, filenames in os.walk(hermes_root, followlinks=False):
|
||||
dp = Path(dirpath)
|
||||
rel_dir = dp.relative_to(hermes_root)
|
||||
|
||||
# Prune excluded directories in-place so os.walk doesn't descend
|
||||
orig_dirnames = dirnames[:]
|
||||
dirnames[:] = [
|
||||
d for d in dirnames
|
||||
if d not in _EXCLUDED_DIRS
|
||||
]
|
||||
for removed in set(orig_dirnames) - set(dirnames):
|
||||
skipped_dirs.add(str(rel_dir / removed))
|
||||
|
||||
for fname in filenames:
|
||||
fpath = dp / fname
|
||||
rel = fpath.relative_to(hermes_root)
|
||||
|
||||
if _should_exclude(rel):
|
||||
continue
|
||||
|
||||
# Skip the output zip itself if it happens to be inside hermes root
|
||||
try:
|
||||
if fpath.resolve() == out_path.resolve():
|
||||
continue
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
files_to_add.append((fpath, rel))
|
||||
|
||||
if not files_to_add:
|
||||
print("No files to back up.")
|
||||
return
|
||||
|
||||
# Create the zip
|
||||
file_count = len(files_to_add)
|
||||
print(f"Backing up {file_count} files ...")
|
||||
|
||||
total_bytes = 0
|
||||
errors = []
|
||||
t0 = time.monotonic()
|
||||
|
||||
with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
|
||||
for i, (abs_path, rel_path) in enumerate(files_to_add, 1):
|
||||
try:
|
||||
zf.write(abs_path, arcname=str(rel_path))
|
||||
total_bytes += abs_path.stat().st_size
|
||||
except (PermissionError, OSError) as exc:
|
||||
errors.append(f" {rel_path}: {exc}")
|
||||
continue
|
||||
|
||||
# Progress every 500 files
|
||||
if i % 500 == 0:
|
||||
print(f" {i}/{file_count} files ...")
|
||||
|
||||
elapsed = time.monotonic() - t0
|
||||
zip_size = out_path.stat().st_size
|
||||
|
||||
# Summary
|
||||
print()
|
||||
print(f"Backup complete: {out_path}")
|
||||
print(f" Files: {file_count}")
|
||||
print(f" Original: {_format_size(total_bytes)}")
|
||||
print(f" Compressed: {_format_size(zip_size)}")
|
||||
print(f" Time: {elapsed:.1f}s")
|
||||
|
||||
if skipped_dirs:
|
||||
print(f"\n Excluded directories:")
|
||||
for d in sorted(skipped_dirs):
|
||||
print(f" {d}/")
|
||||
|
||||
if errors:
|
||||
print(f"\n Warnings ({len(errors)} files skipped):")
|
||||
for e in errors[:10]:
|
||||
print(e)
|
||||
if len(errors) > 10:
|
||||
print(f" ... and {len(errors) - 10} more")
|
||||
|
||||
print(f"\nRestore with: hermes import {out_path.name}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _validate_backup_zip(zf: zipfile.ZipFile) -> tuple[bool, str]:
|
||||
"""Check that a zip looks like a Hermes backup.
|
||||
|
||||
Returns (ok, reason).
|
||||
"""
|
||||
names = zf.namelist()
|
||||
if not names:
|
||||
return False, "zip archive is empty"
|
||||
|
||||
# Look for telltale files that a hermes home would have
|
||||
markers = {"config.yaml", ".env", "hermes_state.db", "memory_store.db"}
|
||||
found = set()
|
||||
for n in names:
|
||||
# Could be at the root or one level deep (if someone zipped the directory)
|
||||
basename = Path(n).name
|
||||
if basename in markers:
|
||||
found.add(basename)
|
||||
|
||||
if not found:
|
||||
return False, (
|
||||
"zip does not appear to be a Hermes backup "
|
||||
"(no config.yaml, .env, or state databases found)"
|
||||
)
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def _detect_prefix(zf: zipfile.ZipFile) -> str:
|
||||
"""Detect if the zip has a common directory prefix wrapping all entries.
|
||||
|
||||
Some tools zip as `.hermes/config.yaml` instead of `config.yaml`.
|
||||
Returns the prefix to strip (empty string if none).
|
||||
"""
|
||||
names = [n for n in zf.namelist() if not n.endswith("/")]
|
||||
if not names:
|
||||
return ""
|
||||
|
||||
# Find common prefix
|
||||
parts_list = [Path(n).parts for n in names]
|
||||
|
||||
# Check if all entries share a common first directory
|
||||
first_parts = {p[0] for p in parts_list if len(p) > 1}
|
||||
if len(first_parts) == 1:
|
||||
prefix = first_parts.pop()
|
||||
# Only strip if it looks like a hermes dir name
|
||||
if prefix in (".hermes", "hermes"):
|
||||
return prefix + "/"
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def run_import(args) -> None:
|
||||
"""Restore a Hermes backup from a zip file."""
|
||||
zip_path = Path(args.zipfile).expanduser().resolve()
|
||||
|
||||
if not zip_path.is_file():
|
||||
print(f"Error: File not found: {zip_path}")
|
||||
sys.exit(1)
|
||||
|
||||
if not zipfile.is_zipfile(zip_path):
|
||||
print(f"Error: Not a valid zip file: {zip_path}")
|
||||
sys.exit(1)
|
||||
|
||||
hermes_root = get_default_hermes_root()
|
||||
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
# Validate
|
||||
ok, reason = _validate_backup_zip(zf)
|
||||
if not ok:
|
||||
print(f"Error: {reason}")
|
||||
sys.exit(1)
|
||||
|
||||
prefix = _detect_prefix(zf)
|
||||
members = [n for n in zf.namelist() if not n.endswith("/")]
|
||||
file_count = len(members)
|
||||
|
||||
print(f"Backup contains {file_count} files")
|
||||
print(f"Target: {display_hermes_home()}")
|
||||
|
||||
if prefix:
|
||||
print(f"Detected archive prefix: {prefix!r} (will be stripped)")
|
||||
|
||||
# Check for existing installation
|
||||
has_config = (hermes_root / "config.yaml").exists()
|
||||
has_env = (hermes_root / ".env").exists()
|
||||
|
||||
if (has_config or has_env) and not args.force:
|
||||
print()
|
||||
print("Warning: Target directory already has Hermes configuration.")
|
||||
print("Importing will overwrite existing files with backup contents.")
|
||||
print()
|
||||
try:
|
||||
answer = input("Continue? [y/N] ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\nAborted.")
|
||||
sys.exit(1)
|
||||
if answer not in ("y", "yes"):
|
||||
print("Aborted.")
|
||||
return
|
||||
|
||||
# Extract
|
||||
print(f"\nImporting {file_count} files ...")
|
||||
hermes_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
errors = []
|
||||
restored = 0
|
||||
t0 = time.monotonic()
|
||||
|
||||
for member in members:
|
||||
# Strip prefix if detected
|
||||
if prefix and member.startswith(prefix):
|
||||
rel = member[len(prefix):]
|
||||
else:
|
||||
rel = member
|
||||
|
||||
if not rel:
|
||||
continue
|
||||
|
||||
target = hermes_root / rel
|
||||
|
||||
# Security: reject absolute paths and traversals
|
||||
try:
|
||||
target.resolve().relative_to(hermes_root.resolve())
|
||||
except ValueError:
|
||||
errors.append(f" {rel}: path traversal blocked")
|
||||
continue
|
||||
|
||||
try:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zf.open(member) as src, open(target, "wb") as dst:
|
||||
dst.write(src.read())
|
||||
restored += 1
|
||||
except (PermissionError, OSError) as exc:
|
||||
errors.append(f" {rel}: {exc}")
|
||||
|
||||
if restored % 500 == 0:
|
||||
print(f" {restored}/{file_count} files ...")
|
||||
|
||||
elapsed = time.monotonic() - t0
|
||||
|
||||
# Summary
|
||||
print()
|
||||
print(f"Import complete: {restored} files restored in {elapsed:.1f}s")
|
||||
print(f" Target: {display_hermes_home()}")
|
||||
|
||||
if errors:
|
||||
print(f"\n Warnings ({len(errors)} files skipped):")
|
||||
for e in errors[:10]:
|
||||
print(e)
|
||||
if len(errors) > 10:
|
||||
print(f" ... and {len(errors) - 10} more")
|
||||
|
||||
# Post-import: restore profile wrapper scripts
|
||||
profiles_dir = hermes_root / "profiles"
|
||||
restored_profiles = []
|
||||
if profiles_dir.is_dir():
|
||||
try:
|
||||
from hermes_cli.profiles import (
|
||||
create_wrapper_script, check_alias_collision,
|
||||
_is_wrapper_dir_in_path, _get_wrapper_dir,
|
||||
)
|
||||
for entry in sorted(profiles_dir.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
profile_name = entry.name
|
||||
# Only create wrappers for directories with config
|
||||
if not (entry / "config.yaml").exists() and not (entry / ".env").exists():
|
||||
continue
|
||||
collision = check_alias_collision(profile_name)
|
||||
if collision:
|
||||
print(f" Skipped alias '{profile_name}': {collision}")
|
||||
restored_profiles.append((profile_name, False))
|
||||
else:
|
||||
wrapper = create_wrapper_script(profile_name)
|
||||
restored_profiles.append((profile_name, wrapper is not None))
|
||||
|
||||
if restored_profiles:
|
||||
created = [n for n, ok in restored_profiles if ok]
|
||||
skipped = [n for n, ok in restored_profiles if not ok]
|
||||
if created:
|
||||
print(f"\n Profile aliases restored: {', '.join(created)}")
|
||||
if skipped:
|
||||
print(f" Profile aliases skipped: {', '.join(skipped)}")
|
||||
if not _is_wrapper_dir_in_path():
|
||||
print(f"\n Note: {_get_wrapper_dir()} is not in your PATH.")
|
||||
print(' Add to your shell config (~/.bashrc or ~/.zshrc):')
|
||||
print(' export PATH="$HOME/.local/bin:$PATH"')
|
||||
except ImportError:
|
||||
# hermes_cli.profiles might not be available (fresh install)
|
||||
if any(profiles_dir.iterdir()):
|
||||
print(f"\n Profiles detected but aliases could not be created.")
|
||||
print(f" Run: hermes profile list (after installing hermes)")
|
||||
|
||||
# Guidance
|
||||
print()
|
||||
if not (hermes_root / "hermes-agent").is_dir():
|
||||
print("Note: The hermes-agent codebase was not included in the backup.")
|
||||
print(" If this is a fresh install, run: hermes update")
|
||||
|
||||
if restored_profiles:
|
||||
gw_profiles = [n for n, _ in restored_profiles]
|
||||
print("\nTo re-enable gateway services for profiles:")
|
||||
for pname in gw_profiles:
|
||||
print(f" hermes -p {pname} gateway install")
|
||||
|
||||
print("Done. Your Hermes configuration has been restored.")
|
||||
+10
-59
@@ -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:
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -706,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,
|
||||
}
|
||||
|
||||
+64
-9
@@ -1,16 +1,18 @@
|
||||
"""``hermes logs`` — view and filter Hermes log files.
|
||||
|
||||
Supports tailing, following, session filtering, level filtering, and
|
||||
relative time ranges. All log files live under ``~/.hermes/logs/``.
|
||||
Supports tailing, following, session filtering, level filtering,
|
||||
component filtering, and relative time ranges. All log files live
|
||||
under ``~/.hermes/logs/``.
|
||||
|
||||
Usage examples::
|
||||
|
||||
hermes logs # last 50 lines of agent.log
|
||||
hermes logs -f # follow agent.log in real time
|
||||
hermes logs errors # last 50 lines of errors.log
|
||||
hermes logs gateway -n 100 # last 100 lines of gateway.log
|
||||
hermes logs gateway -n 100 # last 100 lines of gateway.log
|
||||
hermes logs --level WARNING # only WARNING+ lines
|
||||
hermes logs --session abc123 # filter by session ID substring
|
||||
hermes logs --component tools # only tool-related lines
|
||||
hermes logs --since 1h # lines from the last hour
|
||||
hermes logs --since 30m -f # follow, starting 30 min ago
|
||||
"""
|
||||
@@ -20,7 +22,7 @@ import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from hermes_constants import get_hermes_home, display_hermes_home
|
||||
|
||||
@@ -38,6 +40,15 @@ _TS_RE = re.compile(r"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})")
|
||||
# Level extraction — matches " INFO ", " WARNING ", " ERROR ", " DEBUG ", " CRITICAL "
|
||||
_LEVEL_RE = re.compile(r"\s(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s")
|
||||
|
||||
# Logger name extraction — after level and optional session tag, the next
|
||||
# non-space token before ":" is the logger name.
|
||||
# Matches: "INFO gateway.run:" or "INFO [sess_abc] tools.terminal_tool:"
|
||||
_LOGGER_NAME_RE = re.compile(
|
||||
r"\s(?:DEBUG|INFO|WARNING|ERROR|CRITICAL)" # level
|
||||
r"(?:\s+\[.*?\])?" # optional session tag
|
||||
r"\s+(\S+):" # logger name
|
||||
)
|
||||
|
||||
# Level ordering for >= filtering
|
||||
_LEVEL_ORDER = {"DEBUG": 0, "INFO": 1, "WARNING": 2, "ERROR": 3, "CRITICAL": 4}
|
||||
|
||||
@@ -79,12 +90,27 @@ def _extract_level(line: str) -> Optional[str]:
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _extract_logger_name(line: str) -> Optional[str]:
|
||||
"""Extract the logger name from a log line."""
|
||||
m = _LOGGER_NAME_RE.search(line)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _line_matches_component(line: str, prefixes: Sequence[str]) -> bool:
|
||||
"""Check if a log line's logger name starts with any of *prefixes*."""
|
||||
name = _extract_logger_name(line)
|
||||
if name is None:
|
||||
return False
|
||||
return name.startswith(tuple(prefixes))
|
||||
|
||||
|
||||
def _matches_filters(
|
||||
line: str,
|
||||
*,
|
||||
min_level: Optional[str] = None,
|
||||
session_filter: Optional[str] = None,
|
||||
since: Optional[datetime] = None,
|
||||
component_prefixes: Optional[Sequence[str]] = None,
|
||||
) -> bool:
|
||||
"""Check if a log line passes all active filters."""
|
||||
if since is not None:
|
||||
@@ -102,6 +128,10 @@ def _matches_filters(
|
||||
if session_filter not in line:
|
||||
return False
|
||||
|
||||
if component_prefixes is not None:
|
||||
if not _line_matches_component(line, component_prefixes):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -113,6 +143,7 @@ def tail_log(
|
||||
level: Optional[str] = None,
|
||||
session: Optional[str] = None,
|
||||
since: Optional[str] = None,
|
||||
component: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Read and display log lines, optionally following in real time.
|
||||
|
||||
@@ -130,6 +161,8 @@ def tail_log(
|
||||
Session ID substring to filter on.
|
||||
since
|
||||
Relative time string (e.g. ``"1h"``, ``"30m"``).
|
||||
component
|
||||
Component name to filter by (e.g. ``"gateway"``, ``"tools"``).
|
||||
"""
|
||||
filename = LOG_FILES.get(log_name)
|
||||
if filename is None:
|
||||
@@ -155,13 +188,29 @@ def tail_log(
|
||||
print(f"Invalid --level: {level!r}. Use DEBUG, INFO, WARNING, ERROR, or CRITICAL.")
|
||||
sys.exit(1)
|
||||
|
||||
has_filters = min_level is not None or session is not None or since_dt is not None
|
||||
# Resolve component to logger name prefixes
|
||||
component_prefixes = None
|
||||
if component:
|
||||
from hermes_logging import COMPONENT_PREFIXES
|
||||
component_lower = component.lower()
|
||||
if component_lower not in COMPONENT_PREFIXES:
|
||||
available = ", ".join(sorted(COMPONENT_PREFIXES))
|
||||
print(f"Unknown component: {component!r}. Available: {available}")
|
||||
sys.exit(1)
|
||||
component_prefixes = COMPONENT_PREFIXES[component_lower]
|
||||
|
||||
has_filters = (
|
||||
min_level is not None
|
||||
or session is not None
|
||||
or since_dt is not None
|
||||
or component_prefixes is not None
|
||||
)
|
||||
|
||||
# Read and display the tail
|
||||
try:
|
||||
lines = _read_tail(log_path, num_lines, has_filters=has_filters,
|
||||
min_level=min_level, session_filter=session,
|
||||
since=since_dt)
|
||||
since=since_dt, component_prefixes=component_prefixes)
|
||||
except PermissionError:
|
||||
print(f"Permission denied: {log_path}")
|
||||
sys.exit(1)
|
||||
@@ -172,6 +221,8 @@ def tail_log(
|
||||
filter_parts.append(f"level>={min_level}")
|
||||
if session:
|
||||
filter_parts.append(f"session={session}")
|
||||
if component:
|
||||
filter_parts.append(f"component={component}")
|
||||
if since:
|
||||
filter_parts.append(f"since={since}")
|
||||
filter_desc = f" [{', '.join(filter_parts)}]" if filter_parts else ""
|
||||
@@ -190,7 +241,7 @@ def tail_log(
|
||||
# Follow mode — poll for new content
|
||||
try:
|
||||
_follow_log(log_path, min_level=min_level, session_filter=session,
|
||||
since=since_dt)
|
||||
since=since_dt, component_prefixes=component_prefixes)
|
||||
except KeyboardInterrupt:
|
||||
print("\n--- stopped ---")
|
||||
|
||||
@@ -203,6 +254,7 @@ def _read_tail(
|
||||
min_level: Optional[str] = None,
|
||||
session_filter: Optional[str] = None,
|
||||
since: Optional[datetime] = None,
|
||||
component_prefixes: Optional[Sequence[str]] = None,
|
||||
) -> list:
|
||||
"""Read the last *num_lines* matching lines from a log file.
|
||||
|
||||
@@ -215,7 +267,8 @@ def _read_tail(
|
||||
filtered = [
|
||||
l for l in raw_lines
|
||||
if _matches_filters(l, min_level=min_level,
|
||||
session_filter=session_filter, since=since)
|
||||
session_filter=session_filter, since=since,
|
||||
component_prefixes=component_prefixes)
|
||||
]
|
||||
return filtered[-num_lines:]
|
||||
else:
|
||||
@@ -284,6 +337,7 @@ def _follow_log(
|
||||
min_level: Optional[str] = None,
|
||||
session_filter: Optional[str] = None,
|
||||
since: Optional[datetime] = None,
|
||||
component_prefixes: Optional[Sequence[str]] = None,
|
||||
) -> None:
|
||||
"""Poll a log file for new content and print matching lines."""
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
@@ -293,7 +347,8 @@ def _follow_log(
|
||||
line = f.readline()
|
||||
if line:
|
||||
if _matches_filters(line, min_level=min_level,
|
||||
session_filter=session_filter, since=since):
|
||||
session_filter=session_filter, since=since,
|
||||
component_prefixes=component_prefixes):
|
||||
print(line, end="")
|
||||
sys.stdout.flush()
|
||||
else:
|
||||
|
||||
+67
-1
@@ -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
|
||||
@@ -2818,6 +2830,18 @@ def cmd_config(args):
|
||||
config_command(args)
|
||||
|
||||
|
||||
def cmd_backup(args):
|
||||
"""Back up Hermes home directory to a zip file."""
|
||||
from hermes_cli.backup import run_backup
|
||||
run_backup(args)
|
||||
|
||||
|
||||
def cmd_import(args):
|
||||
"""Restore a Hermes backup from a zip file."""
|
||||
from hermes_cli.backup import run_import
|
||||
run_import(args)
|
||||
|
||||
|
||||
def cmd_version(args):
|
||||
"""Show version."""
|
||||
print(f"Hermes Agent v{__version__} ({__release_date__})")
|
||||
@@ -4338,6 +4362,7 @@ def cmd_logs(args):
|
||||
level=getattr(args, "level", None),
|
||||
session=getattr(args, "session", None),
|
||||
since=getattr(args, "since", None),
|
||||
component=getattr(args, "component", None),
|
||||
)
|
||||
|
||||
|
||||
@@ -4903,7 +4928,43 @@ For more help on a command:
|
||||
help="Show redacted API key prefixes (first/last 4 chars) instead of just set/not set"
|
||||
)
|
||||
dump_parser.set_defaults(func=cmd_dump)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# backup command
|
||||
# =========================================================================
|
||||
backup_parser = subparsers.add_parser(
|
||||
"backup",
|
||||
help="Back up Hermes home directory to a zip file",
|
||||
description="Create a zip archive of your entire Hermes configuration, "
|
||||
"skills, sessions, and data (excludes the hermes-agent codebase)"
|
||||
)
|
||||
backup_parser.add_argument(
|
||||
"-o", "--output",
|
||||
help="Output path for the zip file (default: ~/hermes-backup-<timestamp>.zip)"
|
||||
)
|
||||
backup_parser.set_defaults(func=cmd_backup)
|
||||
|
||||
# =========================================================================
|
||||
# import command
|
||||
# =========================================================================
|
||||
import_parser = subparsers.add_parser(
|
||||
"import",
|
||||
help="Restore a Hermes backup from a zip file",
|
||||
description="Extract a previously created Hermes backup into your "
|
||||
"Hermes home directory, restoring configuration, skills, "
|
||||
"sessions, and data"
|
||||
)
|
||||
import_parser.add_argument(
|
||||
"zipfile",
|
||||
help="Path to the backup zip file"
|
||||
)
|
||||
import_parser.add_argument(
|
||||
"--force", "-f",
|
||||
action="store_true",
|
||||
help="Overwrite existing files without confirmation"
|
||||
)
|
||||
import_parser.set_defaults(func=cmd_import)
|
||||
|
||||
# =========================================================================
|
||||
# config command
|
||||
# =========================================================================
|
||||
@@ -5737,6 +5798,7 @@ Examples:
|
||||
hermes logs gateway -n 100 Show last 100 lines of gateway.log
|
||||
hermes logs --level WARNING Only show WARNING and above
|
||||
hermes logs --session abc123 Filter by session ID
|
||||
hermes logs --component tools Only show tool-related lines
|
||||
hermes logs --since 1h Lines from the last hour
|
||||
hermes logs --since 30m -f Follow, starting from 30 min ago
|
||||
hermes logs list List available log files with sizes
|
||||
@@ -5766,6 +5828,10 @@ Examples:
|
||||
"--since", metavar="TIME",
|
||||
help="Show lines since TIME ago (e.g. 1h, 30m, 2d)",
|
||||
)
|
||||
logs_parser.add_argument(
|
||||
"--component", metavar="NAME",
|
||||
help="Filter by component: gateway, agent, tools, cli, cron",
|
||||
)
|
||||
logs_parser.set_defaults(func=cmd_logs)
|
||||
|
||||
# =========================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
|
||||
+139
-7
@@ -7,16 +7,28 @@ gateway call early in their startup path. All log files live under
|
||||
Log files produced:
|
||||
agent.log — INFO+, all agent/tool/session activity (the main log)
|
||||
errors.log — WARNING+, errors and warnings only (quick triage)
|
||||
gateway.log — INFO+, gateway-only events (created when mode="gateway")
|
||||
|
||||
Both files use ``RotatingFileHandler`` with ``RedactingFormatter`` so
|
||||
All files use ``RotatingFileHandler`` with ``RedactingFormatter`` so
|
||||
secrets are never written to disk.
|
||||
|
||||
Component separation:
|
||||
gateway.log only receives records from ``gateway.*`` loggers —
|
||||
platform adapters, session management, slash commands, delivery.
|
||||
agent.log remains the catch-all (everything goes there).
|
||||
|
||||
Session context:
|
||||
Call ``set_session_context(session_id)`` at the start of a conversation
|
||||
and ``clear_session_context()`` when done. All log lines emitted on
|
||||
that thread will include ``[session_id]`` for filtering/correlation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from hermes_constants import get_config_path, get_hermes_home
|
||||
|
||||
@@ -25,9 +37,14 @@ from hermes_constants import get_config_path, get_hermes_home
|
||||
# unless ``force=True``.
|
||||
_logging_initialized = False
|
||||
|
||||
# Default log format — includes timestamp, level, logger name, and message.
|
||||
_LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s: %(message)s"
|
||||
_LOG_FORMAT_VERBOSE = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
# Thread-local storage for per-conversation session context.
|
||||
_session_context = threading.local()
|
||||
|
||||
# Default log format — includes timestamp, level, optional session tag,
|
||||
# logger name, and message. The ``%(session_tag)s`` field is guaranteed to
|
||||
# exist on every LogRecord via _install_session_record_factory() below.
|
||||
_LOG_FORMAT = "%(asctime)s %(levelname)s%(session_tag)s %(name)s: %(message)s"
|
||||
_LOG_FORMAT_VERBOSE = "%(asctime)s - %(name)s - %(levelname)s%(session_tag)s - %(message)s"
|
||||
|
||||
# Third-party loggers that are noisy at DEBUG/INFO level.
|
||||
_NOISY_LOGGERS = (
|
||||
@@ -48,6 +65,99 @@ _NOISY_LOGGERS = (
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public session context API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def set_session_context(session_id: str) -> None:
|
||||
"""Set the session ID for the current thread.
|
||||
|
||||
All subsequent log records on this thread will include ``[session_id]``
|
||||
in the formatted output. Call at the start of ``run_conversation()``.
|
||||
"""
|
||||
_session_context.session_id = session_id
|
||||
|
||||
|
||||
def clear_session_context() -> None:
|
||||
"""Clear the session ID for the current thread.
|
||||
|
||||
Optional — ``set_session_context()`` overwrites the previous value,
|
||||
so explicit clearing is only needed if the thread is reused for
|
||||
non-conversation work after ``run_conversation()`` returns.
|
||||
"""
|
||||
_session_context.session_id = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Record factory — injects session_tag into every LogRecord at creation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _install_session_record_factory() -> None:
|
||||
"""Replace the global LogRecord factory with one that adds ``session_tag``.
|
||||
|
||||
Unlike a ``logging.Filter`` on a handler or logger, the record factory
|
||||
runs for EVERY record in the process — including records that propagate
|
||||
from child loggers and records handled by third-party handlers. This
|
||||
guarantees ``%(session_tag)s`` is always available in format strings,
|
||||
eliminating the KeyError that would occur if a handler used our format
|
||||
without having a ``_SessionFilter`` attached.
|
||||
|
||||
Idempotent — checks for a marker attribute to avoid double-wrapping if
|
||||
the module is reloaded.
|
||||
"""
|
||||
current_factory = logging.getLogRecordFactory()
|
||||
if getattr(current_factory, "_hermes_session_injector", False):
|
||||
return # already installed
|
||||
|
||||
def _session_record_factory(*args, **kwargs):
|
||||
record = current_factory(*args, **kwargs)
|
||||
sid = getattr(_session_context, "session_id", None)
|
||||
record.session_tag = f" [{sid}]" if sid else "" # type: ignore[attr-defined]
|
||||
return record
|
||||
|
||||
_session_record_factory._hermes_session_injector = True # type: ignore[attr-defined]
|
||||
logging.setLogRecordFactory(_session_record_factory)
|
||||
|
||||
|
||||
# Install immediately on import — session_tag is available on all records
|
||||
# from this point forward, even before setup_logging() is called.
|
||||
_install_session_record_factory()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _ComponentFilter(logging.Filter):
|
||||
"""Only pass records whose logger name starts with one of *prefixes*.
|
||||
|
||||
Used to route gateway-specific records to ``gateway.log`` while
|
||||
keeping ``agent.log`` as the catch-all.
|
||||
"""
|
||||
|
||||
def __init__(self, prefixes: Sequence[str]) -> None:
|
||||
super().__init__()
|
||||
self._prefixes = tuple(prefixes)
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
return record.name.startswith(self._prefixes)
|
||||
|
||||
|
||||
# Logger name prefixes that belong to each component.
|
||||
# Used by _ComponentFilter and exposed for ``hermes logs --component``.
|
||||
COMPONENT_PREFIXES = {
|
||||
"gateway": ("gateway",),
|
||||
"agent": ("agent", "run_agent", "model_tools", "batch_runner"),
|
||||
"tools": ("tools",),
|
||||
"cli": ("hermes_cli", "cli"),
|
||||
"cron": ("cron",),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def setup_logging(
|
||||
*,
|
||||
hermes_home: Optional[Path] = None,
|
||||
@@ -78,8 +188,9 @@ def setup_logging(
|
||||
Number of rotated backup files to keep.
|
||||
Defaults to 3 or the value from config.yaml ``logging.backup_count``.
|
||||
mode
|
||||
Hint for the caller context: ``"cli"``, ``"gateway"``, ``"cron"``.
|
||||
Currently used only for log format tuning (gateway includes PID).
|
||||
Caller context: ``"cli"``, ``"gateway"``, ``"cron"``.
|
||||
When ``"gateway"``, an additional ``gateway.log`` file is created
|
||||
that receives only gateway-component records.
|
||||
force
|
||||
Re-run setup even if it has already been called.
|
||||
|
||||
@@ -130,6 +241,18 @@ def setup_logging(
|
||||
formatter=RedactingFormatter(_LOG_FORMAT),
|
||||
)
|
||||
|
||||
# --- gateway.log (INFO+, gateway component only) ------------------------
|
||||
if mode == "gateway":
|
||||
_add_rotating_handler(
|
||||
root,
|
||||
log_dir / "gateway.log",
|
||||
level=logging.INFO,
|
||||
max_bytes=5 * 1024 * 1024,
|
||||
backup_count=3,
|
||||
formatter=RedactingFormatter(_LOG_FORMAT),
|
||||
log_filter=_ComponentFilter(COMPONENT_PREFIXES["gateway"]),
|
||||
)
|
||||
|
||||
# Ensure root logger level is low enough for the handlers to fire.
|
||||
if root.level == logging.NOTSET or root.level > level:
|
||||
root.setLevel(level)
|
||||
@@ -218,9 +341,16 @@ def _add_rotating_handler(
|
||||
max_bytes: int,
|
||||
backup_count: int,
|
||||
formatter: logging.Formatter,
|
||||
log_filter: Optional[logging.Filter] = None,
|
||||
) -> None:
|
||||
"""Add a ``RotatingFileHandler`` to *logger*, skipping if one already
|
||||
exists for the same resolved file path (idempotent).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
log_filter
|
||||
Optional filter to attach to the handler (e.g. ``_ComponentFilter``
|
||||
for gateway.log).
|
||||
"""
|
||||
resolved = path.resolve()
|
||||
for existing in logger.handlers:
|
||||
@@ -236,6 +366,8 @@ def _add_rotating_handler(
|
||||
)
|
||||
handler.setLevel(level)
|
||||
handler.setFormatter(formatter)
|
||||
if log_filter is not None:
|
||||
handler.addFilter(log_filter)
|
||||
logger.addHandler(handler)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ dev = ["debugpy>=1.8.0,<2", "pytest>=9.0.2,<10", "pytest-asyncio>=1.3.0,<2", "py
|
||||
messaging = ["python-telegram-bot[webhooks]>=22.6,<23", "discord.py[voice]>=2.7.1,<3", "aiohttp>=3.13.3,<4", "slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"]
|
||||
cron = ["croniter>=6.0.0,<7"]
|
||||
slack = ["slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"]
|
||||
matrix = ["mautrix[encryption]>=0.20,<1", "Markdown>=3.6,<4"]
|
||||
matrix = ["mautrix[encryption]>=0.20,<1", "Markdown>=3.6,<4", "aiosqlite>=0.20", "asyncpg>=0.29"]
|
||||
cli = ["simple-term-menu>=1.0,<2"]
|
||||
tts-premium = ["elevenlabs>=1.0,<2"]
|
||||
voice = [
|
||||
|
||||
+14
-3
@@ -6548,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)
|
||||
@@ -6570,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:
|
||||
@@ -7529,6 +7535,11 @@ class AIAgent:
|
||||
# Installed once, transparent when streams are healthy, prevents crash on write.
|
||||
_install_safe_stdio()
|
||||
|
||||
# Tag all log records on this thread with the session ID so
|
||||
# ``hermes logs --session <id>`` can filter a single conversation.
|
||||
from hermes_logging import set_session_context
|
||||
set_session_context(self.session_id)
|
||||
|
||||
# If the previous turn activated fallback, restore the primary
|
||||
# runtime so this turn gets a fresh attempt with the preferred model.
|
||||
# No-op when _fallback_activated is False (gateway, first turn, etc.).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -74,6 +74,26 @@ class FakeBot:
|
||||
return None
|
||||
|
||||
|
||||
class SlowSyncTree(FakeTree):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.started = asyncio.Event()
|
||||
self.allow_finish = asyncio.Event()
|
||||
|
||||
async def _slow_sync():
|
||||
self.started.set()
|
||||
await self.allow_finish.wait()
|
||||
return []
|
||||
|
||||
self.sync = AsyncMock(side_effect=_slow_sync)
|
||||
|
||||
|
||||
class SlowSyncBot(FakeBot):
|
||||
def __init__(self, *, intents, proxy=None):
|
||||
super().__init__(intents=intents, proxy=proxy)
|
||||
self.tree = SlowSyncTree()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("allowed_users", "expected_members_intent"),
|
||||
@@ -138,3 +158,36 @@ async def test_connect_releases_token_lock_on_timeout(monkeypatch):
|
||||
assert ok is False
|
||||
assert released == [("discord-bot-token", "test-token")]
|
||||
assert adapter._platform_lock_identity is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_does_not_wait_for_slash_sync(monkeypatch):
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
|
||||
|
||||
monkeypatch.setattr("gateway.status.acquire_scoped_lock", lambda scope, identity, metadata=None: (True, None))
|
||||
monkeypatch.setattr("gateway.status.release_scoped_lock", lambda scope, identity: None)
|
||||
|
||||
intents = SimpleNamespace(message_content=False, dm_messages=False, guild_messages=False, members=False, voice_states=False)
|
||||
monkeypatch.setattr(discord_platform.Intents, "default", lambda: intents)
|
||||
|
||||
created = {}
|
||||
|
||||
def fake_bot_factory(*, command_prefix, intents, proxy=None):
|
||||
bot = SlowSyncBot(intents=intents, proxy=proxy)
|
||||
created["bot"] = bot
|
||||
return bot
|
||||
|
||||
monkeypatch.setattr(discord_platform.commands, "Bot", fake_bot_factory)
|
||||
monkeypatch.setattr(adapter, "_resolve_allowed_usernames", AsyncMock())
|
||||
|
||||
ok = await asyncio.wait_for(adapter.connect(), timeout=1.0)
|
||||
|
||||
assert ok is True
|
||||
assert adapter._ready_event.is_set()
|
||||
|
||||
await asyncio.wait_for(created["bot"].tree.started.wait(), timeout=1.0)
|
||||
assert created["bot"].tree.sync.await_count == 1
|
||||
|
||||
created["bot"].tree.allow_finish.set()
|
||||
await asyncio.sleep(0)
|
||||
await adapter.disconnect()
|
||||
|
||||
@@ -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'."""
|
||||
|
||||
@@ -157,12 +157,44 @@ def _make_fake_mautrix():
|
||||
mautrix_crypto_store = types.ModuleType("mautrix.crypto.store")
|
||||
|
||||
class MemoryCryptoStore:
|
||||
def __init__(self, account_id="", pickle_key=""):
|
||||
def __init__(self, account_id="", pickle_key=""): # noqa: S301
|
||||
self.account_id = account_id
|
||||
self.pickle_key = pickle_key
|
||||
|
||||
mautrix_crypto_store.MemoryCryptoStore = MemoryCryptoStore
|
||||
|
||||
# --- mautrix.crypto.store.asyncpg ---
|
||||
mautrix_crypto_store_asyncpg = types.ModuleType("mautrix.crypto.store.asyncpg")
|
||||
|
||||
class PgCryptoStore:
|
||||
upgrade_table = MagicMock()
|
||||
|
||||
def __init__(self, account_id="", pickle_key="", db=None): # noqa: S301
|
||||
self.account_id = account_id
|
||||
self.pickle_key = pickle_key
|
||||
self.db = db
|
||||
|
||||
async def open(self):
|
||||
pass
|
||||
|
||||
mautrix_crypto_store_asyncpg.PgCryptoStore = PgCryptoStore
|
||||
|
||||
# --- mautrix.util ---
|
||||
mautrix_util = types.ModuleType("mautrix.util")
|
||||
|
||||
# --- mautrix.util.async_db ---
|
||||
mautrix_util_async_db = types.ModuleType("mautrix.util.async_db")
|
||||
|
||||
class Database:
|
||||
@classmethod
|
||||
def create(cls, url, upgrade_table=None):
|
||||
db = MagicMock()
|
||||
db.start = AsyncMock()
|
||||
db.stop = AsyncMock()
|
||||
return db
|
||||
|
||||
mautrix_util_async_db.Database = Database
|
||||
|
||||
return {
|
||||
"mautrix": mautrix,
|
||||
"mautrix.api": mautrix_api,
|
||||
@@ -171,6 +203,9 @@ def _make_fake_mautrix():
|
||||
"mautrix.client.state_store": mautrix_client_state_store,
|
||||
"mautrix.crypto": mautrix_crypto,
|
||||
"mautrix.crypto.store": mautrix_crypto_store,
|
||||
"mautrix.crypto.store.asyncpg": mautrix_crypto_store_asyncpg,
|
||||
"mautrix.util": mautrix_util,
|
||||
"mautrix.util.async_db": mautrix_util_async_db,
|
||||
}
|
||||
|
||||
|
||||
@@ -740,6 +775,12 @@ class TestMatrixAccessTokenAuth:
|
||||
mock_client.whoami = AsyncMock(return_value=FakeWhoamiResponse("@bot:example.org", "DEV123"))
|
||||
mock_client.sync = AsyncMock(return_value={"rooms": {"join": {"!room:server": {}}}})
|
||||
mock_client.add_event_handler = MagicMock()
|
||||
mock_client.handle_sync = MagicMock(return_value=[])
|
||||
mock_client.query_keys = AsyncMock(return_value={
|
||||
"device_keys": {"@bot:example.org": {"DEV123": {
|
||||
"keys": {"ed25519:DEV123": "fake_ed25519_key"},
|
||||
}}},
|
||||
})
|
||||
mock_client.api = MagicMock()
|
||||
mock_client.api.token = "syt_test_access_token"
|
||||
mock_client.api.session = MagicMock()
|
||||
@@ -751,6 +792,8 @@ class TestMatrixAccessTokenAuth:
|
||||
mock_olm.share_keys = AsyncMock()
|
||||
mock_olm.share_keys_min_trust = None
|
||||
mock_olm.send_keys_min_trust = None
|
||||
mock_olm.account = MagicMock()
|
||||
mock_olm.account.identity_keys = {"ed25519": "fake_ed25519_key"}
|
||||
|
||||
# Patch Client constructor to return our mock
|
||||
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
|
||||
@@ -924,6 +967,12 @@ class TestMatrixDeviceId:
|
||||
mock_client.whoami = AsyncMock(return_value=MagicMock(user_id="@bot:example.org", device_id="WHOAMI_DEV"))
|
||||
mock_client.sync = AsyncMock(return_value={"rooms": {"join": {"!room:server": {}}}})
|
||||
mock_client.add_event_handler = MagicMock()
|
||||
mock_client.handle_sync = MagicMock(return_value=[])
|
||||
mock_client.query_keys = AsyncMock(return_value={
|
||||
"device_keys": {"@bot:example.org": {"MY_STABLE_DEVICE": {
|
||||
"keys": {"ed25519:MY_STABLE_DEVICE": "fake_ed25519_key"},
|
||||
}}},
|
||||
})
|
||||
mock_client.api = MagicMock()
|
||||
mock_client.api.token = "syt_test_access_token"
|
||||
mock_client.api.session = MagicMock()
|
||||
@@ -934,6 +983,8 @@ class TestMatrixDeviceId:
|
||||
mock_olm.share_keys = AsyncMock()
|
||||
mock_olm.share_keys_min_trust = None
|
||||
mock_olm.send_keys_min_trust = None
|
||||
mock_olm.account = MagicMock()
|
||||
mock_olm.account.identity_keys = {"ed25519": "fake_ed25519_key"}
|
||||
|
||||
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
|
||||
fake_mautrix_mods["mautrix.crypto"].OlmMachine = MagicMock(return_value=mock_olm)
|
||||
@@ -1030,8 +1081,8 @@ class TestMatrixDeviceIdConfig:
|
||||
|
||||
class TestMatrixSyncLoop:
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_loop_shares_keys_when_encryption_enabled(self):
|
||||
"""_sync_loop should call crypto.share_keys() after each sync."""
|
||||
async def test_sync_loop_dispatches_events_and_stores_token(self):
|
||||
"""_sync_loop should call handle_sync() and persist next_batch."""
|
||||
adapter = _make_adapter()
|
||||
adapter._encryption = True
|
||||
adapter._closing = False
|
||||
@@ -1046,7 +1097,6 @@ class TestMatrixSyncLoop:
|
||||
return {"rooms": {"join": {"!room:example.org": {}}}, "next_batch": "s1234"}
|
||||
|
||||
mock_crypto = MagicMock()
|
||||
mock_crypto.share_keys = AsyncMock()
|
||||
|
||||
mock_sync_store = MagicMock()
|
||||
mock_sync_store.get_next_batch = AsyncMock(return_value=None)
|
||||
@@ -1062,7 +1112,6 @@ class TestMatrixSyncLoop:
|
||||
await adapter._sync_loop()
|
||||
|
||||
fake_client.sync.assert_awaited_once()
|
||||
mock_crypto.share_keys.assert_awaited_once()
|
||||
fake_client.handle_sync.assert_called_once()
|
||||
mock_sync_store.put_next_batch.assert_awaited_once_with("s1234")
|
||||
|
||||
@@ -1248,6 +1297,12 @@ class TestMatrixEncryptedEventHandler:
|
||||
mock_client.whoami = AsyncMock(return_value=MagicMock(user_id="@bot:example.org", device_id="DEV123"))
|
||||
mock_client.sync = AsyncMock(return_value={"rooms": {"join": {"!room:server": {}}}})
|
||||
mock_client.add_event_handler = MagicMock()
|
||||
mock_client.handle_sync = MagicMock(return_value=[])
|
||||
mock_client.query_keys = AsyncMock(return_value={
|
||||
"device_keys": {"@bot:example.org": {"DEV123": {
|
||||
"keys": {"ed25519:DEV123": "fake_ed25519_key"},
|
||||
}}},
|
||||
})
|
||||
mock_client.api = MagicMock()
|
||||
mock_client.api.token = "syt_test_token"
|
||||
mock_client.api.session = MagicMock()
|
||||
@@ -1258,6 +1313,8 @@ class TestMatrixEncryptedEventHandler:
|
||||
mock_olm.share_keys = AsyncMock()
|
||||
mock_olm.share_keys_min_trust = None
|
||||
mock_olm.send_keys_min_trust = None
|
||||
mock_olm.account = MagicMock()
|
||||
mock_olm.account.identity_keys = {"ed25519": "fake_ed25519_key"}
|
||||
|
||||
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
|
||||
fake_mautrix_mods["mautrix.crypto"].OlmMachine = MagicMock(return_value=mock_olm)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
from gateway.platforms.base import BasePlatformAdapter
|
||||
@@ -45,6 +46,23 @@ class _DisabledAdapter(BasePlatformAdapter):
|
||||
return {"id": chat_id}
|
||||
|
||||
|
||||
class _SuccessfulAdapter(BasePlatformAdapter):
|
||||
def __init__(self):
|
||||
super().__init__(PlatformConfig(enabled=True, token="***"), Platform.DISCORD)
|
||||
|
||||
async def connect(self) -> bool:
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
self._mark_disconnected()
|
||||
|
||||
async def send(self, chat_id, content, reply_to=None, metadata=None):
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_chat_info(self, chat_id):
|
||||
return {"id": chat_id}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_returns_failure_for_retryable_startup_errors(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
@@ -65,7 +83,7 @@ async def test_runner_returns_failure_for_retryable_startup_errors(monkeypatch,
|
||||
state = read_runtime_status()
|
||||
assert state["gateway_state"] == "startup_failed"
|
||||
assert "temporary DNS resolution failure" in state["exit_reason"]
|
||||
assert state["platforms"]["telegram"]["state"] == "fatal"
|
||||
assert state["platforms"]["telegram"]["state"] == "retrying"
|
||||
assert state["platforms"]["telegram"]["error_code"] == "telegram_connect_error"
|
||||
|
||||
|
||||
@@ -89,6 +107,64 @@ async def test_runner_allows_cron_only_mode_when_no_platforms_are_enabled(monkey
|
||||
assert state["gateway_state"] == "running"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_records_connected_platform_state_on_success(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
config = GatewayConfig(
|
||||
platforms={
|
||||
Platform.DISCORD: PlatformConfig(enabled=True, token="***")
|
||||
},
|
||||
sessions_dir=tmp_path / "sessions",
|
||||
)
|
||||
runner = GatewayRunner(config)
|
||||
|
||||
monkeypatch.setattr(runner, "_create_adapter", lambda platform, platform_config: _SuccessfulAdapter())
|
||||
monkeypatch.setattr(runner.hooks, "discover_and_load", lambda: None)
|
||||
monkeypatch.setattr(runner.hooks, "emit", AsyncMock())
|
||||
|
||||
ok = await runner.start()
|
||||
|
||||
assert ok is True
|
||||
state = read_runtime_status()
|
||||
assert state["gateway_state"] == "running"
|
||||
assert state["platforms"]["discord"]["state"] == "connected"
|
||||
assert state["platforms"]["discord"]["error_code"] is None
|
||||
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))
|
||||
|
||||
@@ -104,6 +104,34 @@ class TestGatewayRuntimeStatus:
|
||||
assert payload["platforms"]["telegram"]["error_code"] == "telegram_polling_conflict"
|
||||
assert payload["platforms"]["telegram"]["error_message"] == "another poller is active"
|
||||
|
||||
def test_write_runtime_status_explicit_none_clears_stale_fields(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
status.write_runtime_status(
|
||||
gateway_state="startup_failed",
|
||||
exit_reason="stale error",
|
||||
platform="discord",
|
||||
platform_state="fatal",
|
||||
error_code="discord_timeout",
|
||||
error_message="stale platform error",
|
||||
)
|
||||
|
||||
status.write_runtime_status(
|
||||
gateway_state="running",
|
||||
exit_reason=None,
|
||||
platform="discord",
|
||||
platform_state="connected",
|
||||
error_code=None,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
payload = status.read_runtime_status()
|
||||
assert payload["gateway_state"] == "running"
|
||||
assert payload["exit_reason"] is None
|
||||
assert payload["platforms"]["discord"]["state"] == "connected"
|
||||
assert payload["platforms"]["discord"]["error_code"] is None
|
||||
assert payload["platforms"]["discord"]["error_message"] is None
|
||||
|
||||
|
||||
class TestTerminatePid:
|
||||
def test_force_uses_taskkill_on_windows(self, monkeypatch):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,897 @@
|
||||
"""Tests for hermes backup and import commands."""
|
||||
|
||||
import os
|
||||
import zipfile
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_hermes_tree(root: Path) -> None:
|
||||
"""Create a realistic ~/.hermes directory structure for testing."""
|
||||
(root / "config.yaml").write_text("model:\n provider: openrouter\n")
|
||||
(root / ".env").write_text("OPENROUTER_API_KEY=sk-test-123\n")
|
||||
(root / "memory_store.db").write_bytes(b"fake-sqlite")
|
||||
(root / "hermes_state.db").write_bytes(b"fake-state")
|
||||
|
||||
# Sessions
|
||||
(root / "sessions").mkdir(exist_ok=True)
|
||||
(root / "sessions" / "abc123.json").write_text("{}")
|
||||
|
||||
# Skills
|
||||
(root / "skills").mkdir(exist_ok=True)
|
||||
(root / "skills" / "my-skill").mkdir()
|
||||
(root / "skills" / "my-skill" / "SKILL.md").write_text("# My Skill\n")
|
||||
|
||||
# Skins
|
||||
(root / "skins").mkdir(exist_ok=True)
|
||||
(root / "skins" / "cyber.yaml").write_text("name: cyber\n")
|
||||
|
||||
# Cron
|
||||
(root / "cron").mkdir(exist_ok=True)
|
||||
(root / "cron" / "jobs.json").write_text("[]")
|
||||
|
||||
# Memories
|
||||
(root / "memories").mkdir(exist_ok=True)
|
||||
(root / "memories" / "notes.json").write_text("{}")
|
||||
|
||||
# Profiles
|
||||
(root / "profiles").mkdir(exist_ok=True)
|
||||
(root / "profiles" / "coder").mkdir()
|
||||
(root / "profiles" / "coder" / "config.yaml").write_text("model:\n provider: anthropic\n")
|
||||
(root / "profiles" / "coder" / ".env").write_text("ANTHROPIC_API_KEY=sk-ant-123\n")
|
||||
|
||||
# hermes-agent repo (should be EXCLUDED)
|
||||
(root / "hermes-agent").mkdir(exist_ok=True)
|
||||
(root / "hermes-agent" / "run_agent.py").write_text("# big file\n")
|
||||
(root / "hermes-agent" / ".git").mkdir()
|
||||
(root / "hermes-agent" / ".git" / "HEAD").write_text("ref: refs/heads/main\n")
|
||||
|
||||
# __pycache__ (should be EXCLUDED)
|
||||
(root / "plugins").mkdir(exist_ok=True)
|
||||
(root / "plugins" / "__pycache__").mkdir()
|
||||
(root / "plugins" / "__pycache__" / "mod.cpython-312.pyc").write_bytes(b"\x00")
|
||||
|
||||
# PID files (should be EXCLUDED)
|
||||
(root / "gateway.pid").write_text("12345")
|
||||
|
||||
# Logs (should be included)
|
||||
(root / "logs").mkdir(exist_ok=True)
|
||||
(root / "logs" / "agent.log").write_text("log line\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _should_exclude tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestShouldExclude:
|
||||
def test_excludes_hermes_agent(self):
|
||||
from hermes_cli.backup import _should_exclude
|
||||
assert _should_exclude(Path("hermes-agent/run_agent.py"))
|
||||
assert _should_exclude(Path("hermes-agent/.git/HEAD"))
|
||||
|
||||
def test_excludes_pycache(self):
|
||||
from hermes_cli.backup import _should_exclude
|
||||
assert _should_exclude(Path("plugins/__pycache__/mod.cpython-312.pyc"))
|
||||
|
||||
def test_excludes_pyc_files(self):
|
||||
from hermes_cli.backup import _should_exclude
|
||||
assert _should_exclude(Path("some/module.pyc"))
|
||||
|
||||
def test_excludes_pid_files(self):
|
||||
from hermes_cli.backup import _should_exclude
|
||||
assert _should_exclude(Path("gateway.pid"))
|
||||
assert _should_exclude(Path("cron.pid"))
|
||||
|
||||
def test_includes_config(self):
|
||||
from hermes_cli.backup import _should_exclude
|
||||
assert not _should_exclude(Path("config.yaml"))
|
||||
|
||||
def test_includes_env(self):
|
||||
from hermes_cli.backup import _should_exclude
|
||||
assert not _should_exclude(Path(".env"))
|
||||
|
||||
def test_includes_skills(self):
|
||||
from hermes_cli.backup import _should_exclude
|
||||
assert not _should_exclude(Path("skills/my-skill/SKILL.md"))
|
||||
|
||||
def test_includes_profiles(self):
|
||||
from hermes_cli.backup import _should_exclude
|
||||
assert not _should_exclude(Path("profiles/coder/config.yaml"))
|
||||
|
||||
def test_includes_sessions(self):
|
||||
from hermes_cli.backup import _should_exclude
|
||||
assert not _should_exclude(Path("sessions/abc.json"))
|
||||
|
||||
def test_includes_logs(self):
|
||||
from hermes_cli.backup import _should_exclude
|
||||
assert not _should_exclude(Path("logs/agent.log"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backup tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBackup:
|
||||
def test_creates_zip(self, tmp_path, monkeypatch):
|
||||
"""Backup creates a valid zip containing expected files."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
_make_hermes_tree(hermes_home)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
# get_default_hermes_root needs this
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
out_zip = tmp_path / "backup.zip"
|
||||
args = Namespace(output=str(out_zip))
|
||||
|
||||
from hermes_cli.backup import run_backup
|
||||
run_backup(args)
|
||||
|
||||
assert out_zip.exists()
|
||||
with zipfile.ZipFile(out_zip, "r") as zf:
|
||||
names = zf.namelist()
|
||||
# Config should be present
|
||||
assert "config.yaml" in names
|
||||
assert ".env" in names
|
||||
# Skills
|
||||
assert "skills/my-skill/SKILL.md" in names
|
||||
# Profiles
|
||||
assert "profiles/coder/config.yaml" in names
|
||||
assert "profiles/coder/.env" in names
|
||||
# Sessions
|
||||
assert "sessions/abc123.json" in names
|
||||
# Logs
|
||||
assert "logs/agent.log" in names
|
||||
# Skins
|
||||
assert "skins/cyber.yaml" in names
|
||||
|
||||
def test_excludes_hermes_agent(self, tmp_path, monkeypatch):
|
||||
"""Backup does NOT include hermes-agent/ directory."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
_make_hermes_tree(hermes_home)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
out_zip = tmp_path / "backup.zip"
|
||||
args = Namespace(output=str(out_zip))
|
||||
|
||||
from hermes_cli.backup import run_backup
|
||||
run_backup(args)
|
||||
|
||||
with zipfile.ZipFile(out_zip, "r") as zf:
|
||||
names = zf.namelist()
|
||||
agent_files = [n for n in names if "hermes-agent" in n]
|
||||
assert agent_files == [], f"hermes-agent files leaked into backup: {agent_files}"
|
||||
|
||||
def test_excludes_pycache(self, tmp_path, monkeypatch):
|
||||
"""Backup does NOT include __pycache__ dirs."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
_make_hermes_tree(hermes_home)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
out_zip = tmp_path / "backup.zip"
|
||||
args = Namespace(output=str(out_zip))
|
||||
|
||||
from hermes_cli.backup import run_backup
|
||||
run_backup(args)
|
||||
|
||||
with zipfile.ZipFile(out_zip, "r") as zf:
|
||||
names = zf.namelist()
|
||||
pycache_files = [n for n in names if "__pycache__" in n]
|
||||
assert pycache_files == []
|
||||
|
||||
def test_excludes_pid_files(self, tmp_path, monkeypatch):
|
||||
"""Backup does NOT include PID files."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
_make_hermes_tree(hermes_home)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
out_zip = tmp_path / "backup.zip"
|
||||
args = Namespace(output=str(out_zip))
|
||||
|
||||
from hermes_cli.backup import run_backup
|
||||
run_backup(args)
|
||||
|
||||
with zipfile.ZipFile(out_zip, "r") as zf:
|
||||
names = zf.namelist()
|
||||
pid_files = [n for n in names if n.endswith(".pid")]
|
||||
assert pid_files == []
|
||||
|
||||
def test_default_output_path(self, tmp_path, monkeypatch):
|
||||
"""When no output path given, zip goes to ~/hermes-backup-*.zip."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text("model: test\n")
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
args = Namespace(output=None)
|
||||
|
||||
from hermes_cli.backup import run_backup
|
||||
run_backup(args)
|
||||
|
||||
# Should exist in home dir
|
||||
zips = list(tmp_path.glob("hermes-backup-*.zip"))
|
||||
assert len(zips) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestImport:
|
||||
def _make_backup_zip(self, zip_path: Path, files: dict[str, str | bytes]) -> None:
|
||||
"""Create a test zip with given files."""
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
for name, content in files.items():
|
||||
if isinstance(content, bytes):
|
||||
zf.writestr(name, content)
|
||||
else:
|
||||
zf.writestr(name, content)
|
||||
|
||||
def test_restores_files(self, tmp_path, monkeypatch):
|
||||
"""Import extracts files into hermes home."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
zip_path = tmp_path / "backup.zip"
|
||||
self._make_backup_zip(zip_path, {
|
||||
"config.yaml": "model:\n provider: openrouter\n",
|
||||
".env": "OPENROUTER_API_KEY=sk-test\n",
|
||||
"skills/my-skill/SKILL.md": "# My Skill\n",
|
||||
"profiles/coder/config.yaml": "model:\n provider: anthropic\n",
|
||||
})
|
||||
|
||||
args = Namespace(zipfile=str(zip_path), force=True)
|
||||
|
||||
from hermes_cli.backup import run_import
|
||||
run_import(args)
|
||||
|
||||
assert (hermes_home / "config.yaml").read_text() == "model:\n provider: openrouter\n"
|
||||
assert (hermes_home / ".env").read_text() == "OPENROUTER_API_KEY=sk-test\n"
|
||||
assert (hermes_home / "skills" / "my-skill" / "SKILL.md").read_text() == "# My Skill\n"
|
||||
assert (hermes_home / "profiles" / "coder" / "config.yaml").exists()
|
||||
|
||||
def test_strips_hermes_prefix(self, tmp_path, monkeypatch):
|
||||
"""Import strips .hermes/ prefix if all entries share it."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
zip_path = tmp_path / "backup.zip"
|
||||
self._make_backup_zip(zip_path, {
|
||||
".hermes/config.yaml": "model: test\n",
|
||||
".hermes/skills/a/SKILL.md": "# A\n",
|
||||
})
|
||||
|
||||
args = Namespace(zipfile=str(zip_path), force=True)
|
||||
|
||||
from hermes_cli.backup import run_import
|
||||
run_import(args)
|
||||
|
||||
assert (hermes_home / "config.yaml").read_text() == "model: test\n"
|
||||
assert (hermes_home / "skills" / "a" / "SKILL.md").read_text() == "# A\n"
|
||||
|
||||
def test_rejects_empty_zip(self, tmp_path, monkeypatch):
|
||||
"""Import rejects an empty zip."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
zip_path = tmp_path / "empty.zip"
|
||||
with zipfile.ZipFile(zip_path, "w"):
|
||||
pass # empty
|
||||
|
||||
args = Namespace(zipfile=str(zip_path), force=True)
|
||||
|
||||
from hermes_cli.backup import run_import
|
||||
with pytest.raises(SystemExit):
|
||||
run_import(args)
|
||||
|
||||
def test_rejects_non_hermes_zip(self, tmp_path, monkeypatch):
|
||||
"""Import rejects a zip that doesn't look like a hermes backup."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
zip_path = tmp_path / "random.zip"
|
||||
self._make_backup_zip(zip_path, {
|
||||
"some/random/file.txt": "hello",
|
||||
"another/thing.json": "{}",
|
||||
})
|
||||
|
||||
args = Namespace(zipfile=str(zip_path), force=True)
|
||||
|
||||
from hermes_cli.backup import run_import
|
||||
with pytest.raises(SystemExit):
|
||||
run_import(args)
|
||||
|
||||
def test_blocks_path_traversal(self, tmp_path, monkeypatch):
|
||||
"""Import blocks zip entries with path traversal."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
zip_path = tmp_path / "evil.zip"
|
||||
# Include a marker file so validation passes
|
||||
self._make_backup_zip(zip_path, {
|
||||
"config.yaml": "model: test\n",
|
||||
"../../etc/passwd": "root:x:0:0\n",
|
||||
})
|
||||
|
||||
args = Namespace(zipfile=str(zip_path), force=True)
|
||||
|
||||
from hermes_cli.backup import run_import
|
||||
run_import(args)
|
||||
|
||||
# config.yaml should be restored
|
||||
assert (hermes_home / "config.yaml").exists()
|
||||
# traversal file should NOT exist outside hermes home
|
||||
assert not (tmp_path / "etc" / "passwd").exists()
|
||||
|
||||
def test_confirmation_prompt_abort(self, tmp_path, monkeypatch):
|
||||
"""Import aborts when user says no to confirmation."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
# Pre-existing config triggers the confirmation
|
||||
(hermes_home / "config.yaml").write_text("existing: true\n")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
zip_path = tmp_path / "backup.zip"
|
||||
self._make_backup_zip(zip_path, {
|
||||
"config.yaml": "model: restored\n",
|
||||
})
|
||||
|
||||
args = Namespace(zipfile=str(zip_path), force=False)
|
||||
|
||||
from hermes_cli.backup import run_import
|
||||
with patch("builtins.input", return_value="n"):
|
||||
run_import(args)
|
||||
|
||||
# Original config should be unchanged
|
||||
assert (hermes_home / "config.yaml").read_text() == "existing: true\n"
|
||||
|
||||
def test_force_skips_confirmation(self, tmp_path, monkeypatch):
|
||||
"""Import with --force skips confirmation and overwrites."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text("existing: true\n")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
zip_path = tmp_path / "backup.zip"
|
||||
self._make_backup_zip(zip_path, {
|
||||
"config.yaml": "model: restored\n",
|
||||
})
|
||||
|
||||
args = Namespace(zipfile=str(zip_path), force=True)
|
||||
|
||||
from hermes_cli.backup import run_import
|
||||
run_import(args)
|
||||
|
||||
assert (hermes_home / "config.yaml").read_text() == "model: restored\n"
|
||||
|
||||
def test_missing_file_exits(self, tmp_path, monkeypatch):
|
||||
"""Import exits with error for nonexistent file."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
args = Namespace(zipfile=str(tmp_path / "nonexistent.zip"), force=True)
|
||||
|
||||
from hermes_cli.backup import run_import
|
||||
with pytest.raises(SystemExit):
|
||||
run_import(args)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Round-trip test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRoundTrip:
|
||||
def test_backup_then_import(self, tmp_path, monkeypatch):
|
||||
"""Full round-trip: backup -> import to a new location -> verify."""
|
||||
# Source
|
||||
src_home = tmp_path / "source" / ".hermes"
|
||||
src_home.mkdir(parents=True)
|
||||
_make_hermes_tree(src_home)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(src_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path / "source")
|
||||
|
||||
# Backup
|
||||
out_zip = tmp_path / "roundtrip.zip"
|
||||
from hermes_cli.backup import run_backup, run_import
|
||||
|
||||
run_backup(Namespace(output=str(out_zip)))
|
||||
assert out_zip.exists()
|
||||
|
||||
# Import into a different location
|
||||
dst_home = tmp_path / "dest" / ".hermes"
|
||||
dst_home.mkdir(parents=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(dst_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path / "dest")
|
||||
|
||||
run_import(Namespace(zipfile=str(out_zip), force=True))
|
||||
|
||||
# Verify key files
|
||||
assert (dst_home / "config.yaml").read_text() == "model:\n provider: openrouter\n"
|
||||
assert (dst_home / ".env").read_text() == "OPENROUTER_API_KEY=sk-test-123\n"
|
||||
assert (dst_home / "skills" / "my-skill" / "SKILL.md").exists()
|
||||
assert (dst_home / "profiles" / "coder" / "config.yaml").exists()
|
||||
assert (dst_home / "sessions" / "abc123.json").exists()
|
||||
assert (dst_home / "logs" / "agent.log").exists()
|
||||
|
||||
# hermes-agent should NOT be present
|
||||
assert not (dst_home / "hermes-agent").exists()
|
||||
# __pycache__ should NOT be present
|
||||
assert not (dst_home / "plugins" / "__pycache__").exists()
|
||||
# PID files should NOT be present
|
||||
assert not (dst_home / "gateway.pid").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validate / detect-prefix unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFormatSize:
|
||||
def test_bytes(self):
|
||||
from hermes_cli.backup import _format_size
|
||||
assert _format_size(512) == "512 B"
|
||||
|
||||
def test_kilobytes(self):
|
||||
from hermes_cli.backup import _format_size
|
||||
assert "KB" in _format_size(2048)
|
||||
|
||||
def test_megabytes(self):
|
||||
from hermes_cli.backup import _format_size
|
||||
assert "MB" in _format_size(5 * 1024 * 1024)
|
||||
|
||||
def test_gigabytes(self):
|
||||
from hermes_cli.backup import _format_size
|
||||
assert "GB" in _format_size(3 * 1024 ** 3)
|
||||
|
||||
def test_terabytes(self):
|
||||
from hermes_cli.backup import _format_size
|
||||
assert "TB" in _format_size(2 * 1024 ** 4)
|
||||
|
||||
|
||||
class TestValidation:
|
||||
def test_validate_with_config(self):
|
||||
"""Zip with config.yaml passes validation."""
|
||||
import io
|
||||
from hermes_cli.backup import _validate_backup_zip
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("config.yaml", "test")
|
||||
buf.seek(0)
|
||||
with zipfile.ZipFile(buf, "r") as zf:
|
||||
ok, reason = _validate_backup_zip(zf)
|
||||
assert ok
|
||||
|
||||
def test_validate_with_env(self):
|
||||
"""Zip with .env passes validation."""
|
||||
import io
|
||||
from hermes_cli.backup import _validate_backup_zip
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr(".env", "KEY=val")
|
||||
buf.seek(0)
|
||||
with zipfile.ZipFile(buf, "r") as zf:
|
||||
ok, reason = _validate_backup_zip(zf)
|
||||
assert ok
|
||||
|
||||
def test_validate_rejects_random(self):
|
||||
"""Zip without hermes markers fails validation."""
|
||||
import io
|
||||
from hermes_cli.backup import _validate_backup_zip
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("random/file.txt", "hello")
|
||||
buf.seek(0)
|
||||
with zipfile.ZipFile(buf, "r") as zf:
|
||||
ok, reason = _validate_backup_zip(zf)
|
||||
assert not ok
|
||||
|
||||
def test_detect_prefix_hermes(self):
|
||||
"""Detects .hermes/ prefix wrapping all entries."""
|
||||
import io
|
||||
from hermes_cli.backup import _detect_prefix
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr(".hermes/config.yaml", "test")
|
||||
zf.writestr(".hermes/skills/a/SKILL.md", "skill")
|
||||
buf.seek(0)
|
||||
with zipfile.ZipFile(buf, "r") as zf:
|
||||
assert _detect_prefix(zf) == ".hermes/"
|
||||
|
||||
def test_detect_prefix_none(self):
|
||||
"""No prefix when entries are at root."""
|
||||
import io
|
||||
from hermes_cli.backup import _detect_prefix
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("config.yaml", "test")
|
||||
zf.writestr("skills/a/SKILL.md", "skill")
|
||||
buf.seek(0)
|
||||
with zipfile.ZipFile(buf, "r") as zf:
|
||||
assert _detect_prefix(zf) == ""
|
||||
|
||||
def test_detect_prefix_only_dirs(self):
|
||||
"""Prefix detection returns empty for zip with only directory entries."""
|
||||
import io
|
||||
from hermes_cli.backup import _detect_prefix
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
# Only directory entries (trailing slash)
|
||||
zf.writestr(".hermes/", "")
|
||||
zf.writestr(".hermes/skills/", "")
|
||||
buf.seek(0)
|
||||
with zipfile.ZipFile(buf, "r") as zf:
|
||||
assert _detect_prefix(zf) == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge case tests for uncovered paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBackupEdgeCases:
|
||||
def test_nonexistent_hermes_home(self, tmp_path, monkeypatch):
|
||||
"""Backup exits when hermes home doesn't exist."""
|
||||
fake_home = tmp_path / "nonexistent" / ".hermes"
|
||||
monkeypatch.setenv("HERMES_HOME", str(fake_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path / "nonexistent")
|
||||
|
||||
args = Namespace(output=str(tmp_path / "out.zip"))
|
||||
|
||||
from hermes_cli.backup import run_backup
|
||||
with pytest.raises(SystemExit):
|
||||
run_backup(args)
|
||||
|
||||
def test_output_is_directory(self, tmp_path, monkeypatch):
|
||||
"""When output path is a directory, zip is created inside it."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text("model: test\n")
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
out_dir = tmp_path / "backups"
|
||||
out_dir.mkdir()
|
||||
|
||||
args = Namespace(output=str(out_dir))
|
||||
|
||||
from hermes_cli.backup import run_backup
|
||||
run_backup(args)
|
||||
|
||||
zips = list(out_dir.glob("hermes-backup-*.zip"))
|
||||
assert len(zips) == 1
|
||||
|
||||
def test_output_without_zip_suffix(self, tmp_path, monkeypatch):
|
||||
"""Output path without .zip gets suffix appended."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text("model: test\n")
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
out_path = tmp_path / "mybackup.tar"
|
||||
args = Namespace(output=str(out_path))
|
||||
|
||||
from hermes_cli.backup import run_backup
|
||||
run_backup(args)
|
||||
|
||||
# Should have .tar.zip suffix
|
||||
assert (tmp_path / "mybackup.tar.zip").exists()
|
||||
|
||||
def test_empty_hermes_home(self, tmp_path, monkeypatch):
|
||||
"""Backup handles empty hermes home (no files to back up)."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
# Only excluded dirs, no actual files
|
||||
(hermes_home / "__pycache__").mkdir()
|
||||
(hermes_home / "__pycache__" / "foo.pyc").write_bytes(b"\x00")
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
args = Namespace(output=str(tmp_path / "out.zip"))
|
||||
|
||||
from hermes_cli.backup import run_backup
|
||||
run_backup(args)
|
||||
|
||||
# No zip should be created
|
||||
assert not (tmp_path / "out.zip").exists()
|
||||
|
||||
def test_permission_error_during_backup(self, tmp_path, monkeypatch):
|
||||
"""Backup handles permission errors gracefully."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text("model: test\n")
|
||||
|
||||
# Create an unreadable file
|
||||
bad_file = hermes_home / "secret.db"
|
||||
bad_file.write_text("data")
|
||||
bad_file.chmod(0o000)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
out_zip = tmp_path / "out.zip"
|
||||
args = Namespace(output=str(out_zip))
|
||||
|
||||
from hermes_cli.backup import run_backup
|
||||
try:
|
||||
run_backup(args)
|
||||
finally:
|
||||
# Restore permissions for cleanup
|
||||
bad_file.chmod(0o644)
|
||||
|
||||
# Zip should still be created with the readable files
|
||||
assert out_zip.exists()
|
||||
|
||||
def test_skips_output_zip_inside_hermes(self, tmp_path, monkeypatch):
|
||||
"""Backup skips its own output zip if it's inside hermes root."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text("model: test\n")
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
# Output inside hermes home
|
||||
out_zip = hermes_home / "backup.zip"
|
||||
args = Namespace(output=str(out_zip))
|
||||
|
||||
from hermes_cli.backup import run_backup
|
||||
run_backup(args)
|
||||
|
||||
# The zip should exist but not contain itself
|
||||
assert out_zip.exists()
|
||||
with zipfile.ZipFile(out_zip, "r") as zf:
|
||||
assert "backup.zip" not in zf.namelist()
|
||||
|
||||
|
||||
class TestImportEdgeCases:
|
||||
def _make_backup_zip(self, zip_path: Path, files: dict[str, str | bytes]) -> None:
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
for name, content in files.items():
|
||||
zf.writestr(name, content)
|
||||
|
||||
def test_not_a_zip(self, tmp_path, monkeypatch):
|
||||
"""Import rejects a non-zip file."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
not_zip = tmp_path / "fake.zip"
|
||||
not_zip.write_text("this is not a zip")
|
||||
|
||||
args = Namespace(zipfile=str(not_zip), force=True)
|
||||
|
||||
from hermes_cli.backup import run_import
|
||||
with pytest.raises(SystemExit):
|
||||
run_import(args)
|
||||
|
||||
def test_eof_during_confirmation(self, tmp_path, monkeypatch):
|
||||
"""Import handles EOFError during confirmation prompt."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text("existing\n")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
zip_path = tmp_path / "backup.zip"
|
||||
self._make_backup_zip(zip_path, {"config.yaml": "new\n"})
|
||||
|
||||
args = Namespace(zipfile=str(zip_path), force=False)
|
||||
|
||||
from hermes_cli.backup import run_import
|
||||
with patch("builtins.input", side_effect=EOFError):
|
||||
with pytest.raises(SystemExit):
|
||||
run_import(args)
|
||||
|
||||
def test_keyboard_interrupt_during_confirmation(self, tmp_path, monkeypatch):
|
||||
"""Import handles KeyboardInterrupt during confirmation prompt."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / ".env").write_text("KEY=val\n")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
zip_path = tmp_path / "backup.zip"
|
||||
self._make_backup_zip(zip_path, {"config.yaml": "new\n"})
|
||||
|
||||
args = Namespace(zipfile=str(zip_path), force=False)
|
||||
|
||||
from hermes_cli.backup import run_import
|
||||
with patch("builtins.input", side_effect=KeyboardInterrupt):
|
||||
with pytest.raises(SystemExit):
|
||||
run_import(args)
|
||||
|
||||
def test_permission_error_during_import(self, tmp_path, monkeypatch):
|
||||
"""Import handles permission errors during extraction."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
# Create a read-only directory so extraction fails
|
||||
locked_dir = hermes_home / "locked"
|
||||
locked_dir.mkdir()
|
||||
locked_dir.chmod(0o555)
|
||||
|
||||
zip_path = tmp_path / "backup.zip"
|
||||
self._make_backup_zip(zip_path, {
|
||||
"config.yaml": "model: test\n",
|
||||
"locked/secret.txt": "data",
|
||||
})
|
||||
|
||||
args = Namespace(zipfile=str(zip_path), force=True)
|
||||
|
||||
from hermes_cli.backup import run_import
|
||||
try:
|
||||
run_import(args)
|
||||
finally:
|
||||
locked_dir.chmod(0o755)
|
||||
|
||||
# config.yaml should still be restored despite the error
|
||||
assert (hermes_home / "config.yaml").exists()
|
||||
|
||||
def test_progress_with_many_files(self, tmp_path, monkeypatch):
|
||||
"""Import shows progress with 500+ files."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
zip_path = tmp_path / "big.zip"
|
||||
files = {"config.yaml": "model: test\n"}
|
||||
for i in range(600):
|
||||
files[f"sessions/s{i:04d}.json"] = "{}"
|
||||
|
||||
self._make_backup_zip(zip_path, files)
|
||||
|
||||
args = Namespace(zipfile=str(zip_path), force=True)
|
||||
|
||||
from hermes_cli.backup import run_import
|
||||
run_import(args)
|
||||
|
||||
assert (hermes_home / "config.yaml").exists()
|
||||
assert (hermes_home / "sessions" / "s0599.json").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Profile restoration tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestProfileRestoration:
|
||||
def _make_backup_zip(self, zip_path: Path, files: dict[str, str | bytes]) -> None:
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
for name, content in files.items():
|
||||
zf.writestr(name, content)
|
||||
|
||||
def test_import_creates_profile_wrappers(self, tmp_path, monkeypatch):
|
||||
"""Import auto-creates wrapper scripts for restored profiles."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
# Mock the wrapper dir to be inside tmp_path
|
||||
wrapper_dir = tmp_path / ".local" / "bin"
|
||||
wrapper_dir.mkdir(parents=True)
|
||||
|
||||
zip_path = tmp_path / "backup.zip"
|
||||
self._make_backup_zip(zip_path, {
|
||||
"config.yaml": "model:\n provider: openrouter\n",
|
||||
"profiles/coder/config.yaml": "model:\n provider: anthropic\n",
|
||||
"profiles/coder/.env": "ANTHROPIC_API_KEY=sk-test\n",
|
||||
"profiles/researcher/config.yaml": "model:\n provider: deepseek\n",
|
||||
})
|
||||
|
||||
args = Namespace(zipfile=str(zip_path), force=True)
|
||||
|
||||
from hermes_cli.backup import run_import
|
||||
run_import(args)
|
||||
|
||||
# Profile directories should exist
|
||||
assert (hermes_home / "profiles" / "coder" / "config.yaml").exists()
|
||||
assert (hermes_home / "profiles" / "researcher" / "config.yaml").exists()
|
||||
|
||||
# Wrapper scripts should be created
|
||||
assert (wrapper_dir / "coder").exists()
|
||||
assert (wrapper_dir / "researcher").exists()
|
||||
|
||||
# Wrappers should contain the right content
|
||||
coder_wrapper = (wrapper_dir / "coder").read_text()
|
||||
assert "hermes -p coder" in coder_wrapper
|
||||
|
||||
def test_import_skips_profile_dirs_without_config(self, tmp_path, monkeypatch):
|
||||
"""Import doesn't create wrappers for profile dirs without config."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
wrapper_dir = tmp_path / ".local" / "bin"
|
||||
wrapper_dir.mkdir(parents=True)
|
||||
|
||||
zip_path = tmp_path / "backup.zip"
|
||||
self._make_backup_zip(zip_path, {
|
||||
"config.yaml": "model: test\n",
|
||||
"profiles/valid/config.yaml": "model: test\n",
|
||||
"profiles/empty/readme.txt": "nothing here\n",
|
||||
})
|
||||
|
||||
args = Namespace(zipfile=str(zip_path), force=True)
|
||||
|
||||
from hermes_cli.backup import run_import
|
||||
run_import(args)
|
||||
|
||||
# Only valid profile should get a wrapper
|
||||
assert (wrapper_dir / "valid").exists()
|
||||
assert not (wrapper_dir / "empty").exists()
|
||||
|
||||
def test_import_without_profiles_module(self, tmp_path, monkeypatch):
|
||||
"""Import gracefully handles missing profiles module (fresh install)."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
zip_path = tmp_path / "backup.zip"
|
||||
self._make_backup_zip(zip_path, {
|
||||
"config.yaml": "model: test\n",
|
||||
"profiles/coder/config.yaml": "model: test\n",
|
||||
})
|
||||
|
||||
args = Namespace(zipfile=str(zip_path), force=True)
|
||||
|
||||
# Simulate profiles module not being available
|
||||
import hermes_cli.backup as backup_mod
|
||||
original_import = __builtins__.__import__ if hasattr(__builtins__, '__import__') else __import__
|
||||
|
||||
def fake_import(name, *a, **kw):
|
||||
if name == "hermes_cli.profiles":
|
||||
raise ImportError("no profiles module")
|
||||
return original_import(name, *a, **kw)
|
||||
|
||||
from hermes_cli.backup import run_import
|
||||
with patch("builtins.__import__", side_effect=fake_import):
|
||||
run_import(args)
|
||||
|
||||
# Files should still be restored even if wrappers can't be created
|
||||
assert (hermes_home / "profiles" / "coder" / "config.yaml").exists()
|
||||
@@ -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"
|
||||
)
|
||||
+166
-199
@@ -1,288 +1,255 @@
|
||||
"""Tests for hermes_cli/logs.py — log viewing and filtering."""
|
||||
"""Tests for hermes_cli.logs — log viewing and filtering."""
|
||||
|
||||
import os
|
||||
import textwrap
|
||||
from datetime import datetime, timedelta
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.logs import (
|
||||
LOG_FILES,
|
||||
_extract_level,
|
||||
_extract_logger_name,
|
||||
_line_matches_component,
|
||||
_matches_filters,
|
||||
_parse_line_timestamp,
|
||||
_parse_since,
|
||||
_read_last_n_lines,
|
||||
list_logs,
|
||||
tail_log,
|
||||
_read_tail,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def log_dir(tmp_path, monkeypatch):
|
||||
"""Create a fake HERMES_HOME with a logs/ directory."""
|
||||
home = Path(os.environ["HERMES_HOME"])
|
||||
logs = home / "logs"
|
||||
logs.mkdir(parents=True, exist_ok=True)
|
||||
return logs
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_agent_log(log_dir):
|
||||
"""Write a realistic agent.log with mixed levels and sessions."""
|
||||
lines = textwrap.dedent("""\
|
||||
2026-04-05 10:00:00,000 INFO run_agent: conversation turn: session=sess_aaa model=claude provider=openrouter platform=cli history=0 msg='hello'
|
||||
2026-04-05 10:00:01,000 INFO run_agent: tool terminal completed (0.50s, 200 chars)
|
||||
2026-04-05 10:00:02,000 INFO run_agent: API call #1: model=claude provider=openrouter in=1000 out=200 total=1200 latency=1.5s
|
||||
2026-04-05 10:00:03,000 WARNING run_agent: Tool web_search returned error (2.00s): timeout
|
||||
2026-04-05 10:00:04,000 INFO run_agent: conversation turn: session=sess_bbb model=gpt-5 provider=openai platform=telegram history=5 msg='fix bug'
|
||||
2026-04-05 10:00:05,000 ERROR run_agent: API call failed after 3 retries. rate limited
|
||||
2026-04-05 10:00:06,000 INFO run_agent: tool read_file completed (0.01s, 500 chars)
|
||||
2026-04-05 10:00:07,000 DEBUG run_agent: verbose internal detail
|
||||
2026-04-05 10:00:08,000 INFO credential_pool: credential pool: marking key-1 exhausted (status=429), rotating
|
||||
2026-04-05 10:00:09,000 INFO credential_pool: credential pool: rotated to key-2
|
||||
""")
|
||||
path = log_dir / "agent.log"
|
||||
path.write_text(lines)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_errors_log(log_dir):
|
||||
"""Write a small errors.log."""
|
||||
lines = textwrap.dedent("""\
|
||||
2026-04-05 10:00:03,000 WARNING run_agent: Tool web_search returned error (2.00s): timeout
|
||||
2026-04-05 10:00:05,000 ERROR run_agent: API call failed after 3 retries. rate limited
|
||||
""")
|
||||
path = log_dir / "errors.log"
|
||||
path.write_text(lines)
|
||||
return path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_since
|
||||
# Timestamp parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseSince:
|
||||
def test_hours(self):
|
||||
cutoff = _parse_since("2h")
|
||||
assert cutoff is not None
|
||||
assert (datetime.now() - cutoff).total_seconds() == pytest.approx(7200, abs=5)
|
||||
assert abs((datetime.now() - cutoff).total_seconds() - 7200) < 2
|
||||
|
||||
def test_minutes(self):
|
||||
cutoff = _parse_since("30m")
|
||||
assert cutoff is not None
|
||||
assert (datetime.now() - cutoff).total_seconds() == pytest.approx(1800, abs=5)
|
||||
assert abs((datetime.now() - cutoff).total_seconds() - 1800) < 2
|
||||
|
||||
def test_days(self):
|
||||
cutoff = _parse_since("1d")
|
||||
assert cutoff is not None
|
||||
assert (datetime.now() - cutoff).total_seconds() == pytest.approx(86400, abs=5)
|
||||
assert abs((datetime.now() - cutoff).total_seconds() - 86400) < 2
|
||||
|
||||
def test_seconds(self):
|
||||
cutoff = _parse_since("60s")
|
||||
cutoff = _parse_since("120s")
|
||||
assert cutoff is not None
|
||||
assert (datetime.now() - cutoff).total_seconds() == pytest.approx(60, abs=5)
|
||||
assert abs((datetime.now() - cutoff).total_seconds() - 120) < 2
|
||||
|
||||
def test_invalid_returns_none(self):
|
||||
assert _parse_since("abc") is None
|
||||
assert _parse_since("") is None
|
||||
assert _parse_since("10x") is None
|
||||
|
||||
def test_whitespace_handling(self):
|
||||
cutoff = _parse_since(" 1h ")
|
||||
def test_whitespace_tolerance(self):
|
||||
cutoff = _parse_since(" 5m ")
|
||||
assert cutoff is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_line_timestamp
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseLineTimestamp:
|
||||
def test_standard_format(self):
|
||||
ts = _parse_line_timestamp("2026-04-05 10:00:00,123 INFO something")
|
||||
assert ts is not None
|
||||
assert ts.year == 2026
|
||||
assert ts.hour == 10
|
||||
ts = _parse_line_timestamp("2026-04-11 10:23:45 INFO gateway.run: msg")
|
||||
assert ts == datetime(2026, 4, 11, 10, 23, 45)
|
||||
|
||||
def test_no_timestamp(self):
|
||||
assert _parse_line_timestamp("just some text") is None
|
||||
assert _parse_line_timestamp("no timestamp here") is None
|
||||
|
||||
def test_continuation_line(self):
|
||||
assert _parse_line_timestamp(" at module.function (line 42)") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _extract_level
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtractLevel:
|
||||
def test_info(self):
|
||||
assert _extract_level("2026-04-05 10:00:00 INFO run_agent: something") == "INFO"
|
||||
assert _extract_level("2026-01-01 00:00:00 INFO gateway.run: msg") == "INFO"
|
||||
|
||||
def test_warning(self):
|
||||
assert _extract_level("2026-04-05 10:00:00 WARNING run_agent: bad") == "WARNING"
|
||||
assert _extract_level("2026-01-01 00:00:00 WARNING tools.file: msg") == "WARNING"
|
||||
|
||||
def test_error(self):
|
||||
assert _extract_level("2026-04-05 10:00:00 ERROR run_agent: crash") == "ERROR"
|
||||
assert _extract_level("2026-01-01 00:00:00 ERROR run_agent: msg") == "ERROR"
|
||||
|
||||
def test_debug(self):
|
||||
assert _extract_level("2026-04-05 10:00:00 DEBUG run_agent: detail") == "DEBUG"
|
||||
assert _extract_level("2026-01-01 00:00:00 DEBUG agent.aux: msg") == "DEBUG"
|
||||
|
||||
def test_no_level(self):
|
||||
assert _extract_level("just a plain line") is None
|
||||
assert _extract_level("random text") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _matches_filters
|
||||
# Logger name extraction (new for component filtering)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtractLoggerName:
|
||||
def test_standard_line(self):
|
||||
line = "2026-04-11 10:23:45 INFO gateway.run: Starting gateway"
|
||||
assert _extract_logger_name(line) == "gateway.run"
|
||||
|
||||
def test_nested_logger(self):
|
||||
line = "2026-04-11 10:23:45 INFO gateway.platforms.telegram: connected"
|
||||
assert _extract_logger_name(line) == "gateway.platforms.telegram"
|
||||
|
||||
def test_warning_level(self):
|
||||
line = "2026-04-11 10:23:45 WARNING tools.terminal_tool: timeout"
|
||||
assert _extract_logger_name(line) == "tools.terminal_tool"
|
||||
|
||||
def test_with_session_tag(self):
|
||||
line = "2026-04-11 10:23:45 INFO [abc123] tools.file_tools: reading file"
|
||||
assert _extract_logger_name(line) == "tools.file_tools"
|
||||
|
||||
def test_with_session_tag_and_error(self):
|
||||
line = "2026-04-11 10:23:45 ERROR [sess_xyz] agent.context_compressor: failed"
|
||||
assert _extract_logger_name(line) == "agent.context_compressor"
|
||||
|
||||
def test_top_level_module(self):
|
||||
line = "2026-04-11 10:23:45 INFO run_agent: starting conversation"
|
||||
assert _extract_logger_name(line) == "run_agent"
|
||||
|
||||
def test_no_match(self):
|
||||
assert _extract_logger_name("random text") is None
|
||||
|
||||
|
||||
class TestLineMatchesComponent:
|
||||
def test_gateway_component(self):
|
||||
line = "2026-04-11 10:23:45 INFO gateway.run: msg"
|
||||
assert _line_matches_component(line, ("gateway",))
|
||||
|
||||
def test_gateway_nested(self):
|
||||
line = "2026-04-11 10:23:45 INFO gateway.platforms.telegram: msg"
|
||||
assert _line_matches_component(line, ("gateway",))
|
||||
|
||||
def test_tools_component(self):
|
||||
line = "2026-04-11 10:23:45 INFO tools.terminal_tool: msg"
|
||||
assert _line_matches_component(line, ("tools",))
|
||||
|
||||
def test_agent_with_multiple_prefixes(self):
|
||||
prefixes = ("agent", "run_agent", "model_tools")
|
||||
assert _line_matches_component(
|
||||
"2026-04-11 10:23:45 INFO agent.context_compressor: msg", prefixes)
|
||||
assert _line_matches_component(
|
||||
"2026-04-11 10:23:45 INFO run_agent: msg", prefixes)
|
||||
assert _line_matches_component(
|
||||
"2026-04-11 10:23:45 INFO model_tools: msg", prefixes)
|
||||
|
||||
def test_no_match(self):
|
||||
line = "2026-04-11 10:23:45 INFO tools.browser: msg"
|
||||
assert not _line_matches_component(line, ("gateway",))
|
||||
|
||||
def test_with_session_tag(self):
|
||||
line = "2026-04-11 10:23:45 INFO [abc] gateway.run: msg"
|
||||
assert _line_matches_component(line, ("gateway",))
|
||||
|
||||
def test_unparseable_line(self):
|
||||
assert not _line_matches_component("random text", ("gateway",))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Combined filter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMatchesFilters:
|
||||
def test_no_filters_always_matches(self):
|
||||
assert _matches_filters("any line") is True
|
||||
def test_no_filters_passes_everything(self):
|
||||
assert _matches_filters("any line")
|
||||
|
||||
def test_level_filter_passes(self):
|
||||
def test_level_filter(self):
|
||||
assert _matches_filters(
|
||||
"2026-04-05 10:00:00 WARNING something",
|
||||
min_level="WARNING",
|
||||
) is True
|
||||
"2026-01-01 00:00:00 WARNING x: msg", min_level="WARNING")
|
||||
assert not _matches_filters(
|
||||
"2026-01-01 00:00:00 INFO x: msg", min_level="WARNING")
|
||||
|
||||
def test_level_filter_rejects(self):
|
||||
def test_session_filter(self):
|
||||
assert _matches_filters(
|
||||
"2026-04-05 10:00:00 INFO something",
|
||||
min_level="WARNING",
|
||||
) is False
|
||||
"2026-01-01 00:00:00 INFO [abc123] x: msg", session_filter="abc123")
|
||||
assert not _matches_filters(
|
||||
"2026-01-01 00:00:00 INFO [xyz789] x: msg", session_filter="abc123")
|
||||
|
||||
def test_session_filter_passes(self):
|
||||
def test_component_filter(self):
|
||||
assert _matches_filters(
|
||||
"session=sess_aaa model=claude",
|
||||
session_filter="sess_aaa",
|
||||
) is True
|
||||
|
||||
def test_session_filter_rejects(self):
|
||||
assert _matches_filters(
|
||||
"session=sess_aaa model=claude",
|
||||
session_filter="sess_bbb",
|
||||
) is False
|
||||
|
||||
def test_since_filter_passes(self):
|
||||
# Line from the future should always pass
|
||||
assert _matches_filters(
|
||||
"2099-01-01 00:00:00 INFO future",
|
||||
since=datetime.now(),
|
||||
) is True
|
||||
|
||||
def test_since_filter_rejects(self):
|
||||
assert _matches_filters(
|
||||
"2020-01-01 00:00:00 INFO past",
|
||||
since=datetime.now(),
|
||||
) is False
|
||||
"2026-01-01 00:00:00 INFO gateway.run: msg",
|
||||
component_prefixes=("gateway",))
|
||||
assert not _matches_filters(
|
||||
"2026-01-01 00:00:00 INFO tools.file: msg",
|
||||
component_prefixes=("gateway",))
|
||||
|
||||
def test_combined_filters(self):
|
||||
line = "2099-01-01 00:00:00 WARNING run_agent: session=abc error"
|
||||
"""All filters must pass for a line to match."""
|
||||
line = "2026-04-11 10:00:00 WARNING [sess_1] gateway.run: connection lost"
|
||||
assert _matches_filters(
|
||||
line, min_level="WARNING", session_filter="abc",
|
||||
since=datetime.now(),
|
||||
) is True
|
||||
# Fails session filter
|
||||
line,
|
||||
min_level="WARNING",
|
||||
session_filter="sess_1",
|
||||
component_prefixes=("gateway",),
|
||||
)
|
||||
# Fails component filter
|
||||
assert not _matches_filters(
|
||||
line,
|
||||
min_level="WARNING",
|
||||
session_filter="sess_1",
|
||||
component_prefixes=("tools",),
|
||||
)
|
||||
|
||||
def test_since_filter(self):
|
||||
# Line with a very old timestamp should be filtered out
|
||||
assert not _matches_filters(
|
||||
"2020-01-01 00:00:00 INFO x: old msg",
|
||||
since=datetime.now() - timedelta(hours=1))
|
||||
# Line with a recent timestamp should pass
|
||||
recent = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
assert _matches_filters(
|
||||
line, min_level="WARNING", session_filter="xyz",
|
||||
) is False
|
||||
f"{recent} INFO x: recent msg",
|
||||
since=datetime.now() - timedelta(hours=1))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _read_last_n_lines
|
||||
# File reading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReadLastNLines:
|
||||
def test_reads_correct_count(self, sample_agent_log):
|
||||
lines = _read_last_n_lines(sample_agent_log, 3)
|
||||
assert len(lines) == 3
|
||||
class TestReadTail:
|
||||
def test_read_small_file(self, tmp_path):
|
||||
log_file = tmp_path / "test.log"
|
||||
lines = [f"2026-01-01 00:00:0{i} INFO x: line {i}\n" for i in range(10)]
|
||||
log_file.write_text("".join(lines))
|
||||
|
||||
def test_reads_all_when_fewer(self, sample_agent_log):
|
||||
lines = _read_last_n_lines(sample_agent_log, 100)
|
||||
assert len(lines) == 10 # sample has 10 lines
|
||||
result = _read_last_n_lines(log_file, 5)
|
||||
assert len(result) == 5
|
||||
assert "line 9" in result[-1]
|
||||
|
||||
def test_empty_file(self, log_dir):
|
||||
empty = log_dir / "empty.log"
|
||||
empty.write_text("")
|
||||
lines = _read_last_n_lines(empty, 10)
|
||||
assert lines == []
|
||||
def test_read_with_component_filter(self, tmp_path):
|
||||
log_file = tmp_path / "test.log"
|
||||
lines = [
|
||||
"2026-01-01 00:00:00 INFO gateway.run: gw msg\n",
|
||||
"2026-01-01 00:00:01 INFO tools.file: tool msg\n",
|
||||
"2026-01-01 00:00:02 INFO gateway.session: session msg\n",
|
||||
"2026-01-01 00:00:03 INFO agent.compressor: agent msg\n",
|
||||
]
|
||||
log_file.write_text("".join(lines))
|
||||
|
||||
def test_last_line_content(self, sample_agent_log):
|
||||
lines = _read_last_n_lines(sample_agent_log, 1)
|
||||
assert "rotated to key-2" in lines[0]
|
||||
result = _read_tail(
|
||||
log_file, 50,
|
||||
has_filters=True,
|
||||
component_prefixes=("gateway",),
|
||||
)
|
||||
assert len(result) == 2
|
||||
assert "gw msg" in result[0]
|
||||
assert "session msg" in result[1]
|
||||
|
||||
def test_empty_file(self, tmp_path):
|
||||
log_file = tmp_path / "empty.log"
|
||||
log_file.write_text("")
|
||||
result = _read_last_n_lines(log_file, 10)
|
||||
assert result == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tail_log
|
||||
# LOG_FILES registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTailLog:
|
||||
def test_basic_tail(self, sample_agent_log, capsys):
|
||||
tail_log("agent", num_lines=3)
|
||||
captured = capsys.readouterr()
|
||||
assert "agent.log" in captured.out
|
||||
# Should have the header + 3 lines
|
||||
lines = captured.out.strip().split("\n")
|
||||
assert len(lines) == 4 # 1 header + 3 content
|
||||
|
||||
def test_level_filter(self, sample_agent_log, capsys):
|
||||
tail_log("agent", num_lines=50, level="ERROR")
|
||||
captured = capsys.readouterr()
|
||||
assert "level>=ERROR" in captured.out
|
||||
# Only the ERROR line should appear
|
||||
content_lines = [l for l in captured.out.strip().split("\n") if not l.startswith("---")]
|
||||
assert len(content_lines) == 1
|
||||
assert "API call failed" in content_lines[0]
|
||||
|
||||
def test_session_filter(self, sample_agent_log, capsys):
|
||||
tail_log("agent", num_lines=50, session="sess_bbb")
|
||||
captured = capsys.readouterr()
|
||||
content_lines = [l for l in captured.out.strip().split("\n") if not l.startswith("---")]
|
||||
assert len(content_lines) == 1
|
||||
assert "sess_bbb" in content_lines[0]
|
||||
|
||||
def test_errors_log(self, sample_errors_log, capsys):
|
||||
tail_log("errors", num_lines=10)
|
||||
captured = capsys.readouterr()
|
||||
assert "errors.log" in captured.out
|
||||
assert "WARNING" in captured.out or "ERROR" in captured.out
|
||||
|
||||
def test_unknown_log_exits(self):
|
||||
with pytest.raises(SystemExit):
|
||||
tail_log("nonexistent")
|
||||
|
||||
def test_missing_file_exits(self, log_dir):
|
||||
with pytest.raises(SystemExit):
|
||||
tail_log("agent") # agent.log doesn't exist in clean log_dir
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_logs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestListLogs:
|
||||
def test_lists_files(self, sample_agent_log, sample_errors_log, capsys):
|
||||
list_logs()
|
||||
captured = capsys.readouterr()
|
||||
assert "agent.log" in captured.out
|
||||
assert "errors.log" in captured.out
|
||||
|
||||
def test_empty_dir(self, log_dir, capsys):
|
||||
list_logs()
|
||||
captured = capsys.readouterr()
|
||||
assert "no log files yet" in captured.out
|
||||
|
||||
def test_shows_sizes(self, sample_agent_log, capsys):
|
||||
list_logs()
|
||||
captured = capsys.readouterr()
|
||||
# File is small, should show as bytes or KB
|
||||
assert "B" in captured.out or "KB" in captured.out
|
||||
class TestLogFiles:
|
||||
def test_known_log_files(self):
|
||||
assert "agent" in LOG_FILES
|
||||
assert "errors" in LOG_FILES
|
||||
assert "gateway" in LOG_FILES
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
import threading
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
@@ -34,6 +35,8 @@ def _reset_logging_state():
|
||||
h.close()
|
||||
else:
|
||||
pre_existing.append(h)
|
||||
# Ensure the record factory is installed (it's idempotent).
|
||||
hermes_logging._install_session_record_factory()
|
||||
yield
|
||||
# Restore — remove any handlers added during the test.
|
||||
for h in list(root.handlers):
|
||||
@@ -41,6 +44,7 @@ def _reset_logging_state():
|
||||
root.removeHandler(h)
|
||||
h.close()
|
||||
hermes_logging._logging_initialized = False
|
||||
hermes_logging.clear_session_context()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -220,6 +224,294 @@ class TestSetupLogging:
|
||||
]
|
||||
assert agent_handlers[0].level == logging.WARNING
|
||||
|
||||
def test_record_factory_installed(self, hermes_home):
|
||||
"""The custom record factory injects session_tag on all records."""
|
||||
hermes_logging.setup_logging(hermes_home=hermes_home)
|
||||
factory = logging.getLogRecordFactory()
|
||||
assert getattr(factory, "_hermes_session_injector", False), (
|
||||
"Record factory should have _hermes_session_injector marker"
|
||||
)
|
||||
# Verify session_tag exists on a fresh record
|
||||
record = factory("test", logging.INFO, "", 0, "msg", (), None)
|
||||
assert hasattr(record, "session_tag")
|
||||
|
||||
|
||||
class TestGatewayMode:
|
||||
"""setup_logging(mode='gateway') creates a filtered gateway.log."""
|
||||
|
||||
def test_gateway_log_created(self, hermes_home):
|
||||
hermes_logging.setup_logging(hermes_home=hermes_home, mode="gateway")
|
||||
root = logging.getLogger()
|
||||
|
||||
gw_handlers = [
|
||||
h for h in root.handlers
|
||||
if isinstance(h, RotatingFileHandler)
|
||||
and "gateway.log" in getattr(h, "baseFilename", "")
|
||||
]
|
||||
assert len(gw_handlers) == 1
|
||||
|
||||
def test_gateway_log_not_created_in_cli_mode(self, hermes_home):
|
||||
hermes_logging.setup_logging(hermes_home=hermes_home, mode="cli")
|
||||
root = logging.getLogger()
|
||||
|
||||
gw_handlers = [
|
||||
h for h in root.handlers
|
||||
if isinstance(h, RotatingFileHandler)
|
||||
and "gateway.log" in getattr(h, "baseFilename", "")
|
||||
]
|
||||
assert len(gw_handlers) == 0
|
||||
|
||||
def test_gateway_log_receives_gateway_records(self, hermes_home):
|
||||
"""gateway.log captures records from gateway.* loggers."""
|
||||
hermes_logging.setup_logging(hermes_home=hermes_home, mode="gateway")
|
||||
|
||||
gw_logger = logging.getLogger("gateway.platforms.telegram")
|
||||
gw_logger.info("telegram connected")
|
||||
|
||||
for h in logging.getLogger().handlers:
|
||||
h.flush()
|
||||
|
||||
gw_log = hermes_home / "logs" / "gateway.log"
|
||||
assert gw_log.exists()
|
||||
assert "telegram connected" in gw_log.read_text()
|
||||
|
||||
def test_gateway_log_rejects_non_gateway_records(self, hermes_home):
|
||||
"""gateway.log does NOT capture records from tools.*, agent.*, etc."""
|
||||
hermes_logging.setup_logging(hermes_home=hermes_home, mode="gateway")
|
||||
|
||||
tool_logger = logging.getLogger("tools.terminal_tool")
|
||||
tool_logger.info("running command")
|
||||
|
||||
agent_logger = logging.getLogger("agent.context_compressor")
|
||||
agent_logger.info("compressing context")
|
||||
|
||||
for h in logging.getLogger().handlers:
|
||||
h.flush()
|
||||
|
||||
gw_log = hermes_home / "logs" / "gateway.log"
|
||||
if gw_log.exists():
|
||||
content = gw_log.read_text()
|
||||
assert "running command" not in content
|
||||
assert "compressing context" not in content
|
||||
|
||||
def test_agent_log_still_receives_all(self, hermes_home):
|
||||
"""agent.log (catch-all) still receives gateway AND tool records."""
|
||||
hermes_logging.setup_logging(hermes_home=hermes_home, mode="gateway")
|
||||
|
||||
logging.getLogger("gateway.run").info("gateway msg")
|
||||
logging.getLogger("tools.file_tools").info("file msg")
|
||||
|
||||
for h in logging.getLogger().handlers:
|
||||
h.flush()
|
||||
|
||||
agent_log = hermes_home / "logs" / "agent.log"
|
||||
content = agent_log.read_text()
|
||||
assert "gateway msg" in content
|
||||
assert "file msg" in content
|
||||
|
||||
|
||||
class TestSessionContext:
|
||||
"""set_session_context / clear_session_context + _SessionFilter."""
|
||||
|
||||
def test_session_tag_in_log_output(self, hermes_home):
|
||||
"""When session context is set, log lines include [session_id]."""
|
||||
hermes_logging.setup_logging(hermes_home=hermes_home)
|
||||
hermes_logging.set_session_context("abc123")
|
||||
|
||||
test_logger = logging.getLogger("test.session_tag")
|
||||
test_logger.info("tagged message")
|
||||
|
||||
for h in logging.getLogger().handlers:
|
||||
h.flush()
|
||||
|
||||
agent_log = hermes_home / "logs" / "agent.log"
|
||||
content = agent_log.read_text()
|
||||
assert "[abc123]" in content
|
||||
assert "tagged message" in content
|
||||
|
||||
def test_no_session_tag_without_context(self, hermes_home):
|
||||
"""Without session context, log lines have no session tag."""
|
||||
hermes_logging.setup_logging(hermes_home=hermes_home)
|
||||
hermes_logging.clear_session_context()
|
||||
|
||||
test_logger = logging.getLogger("test.no_session")
|
||||
test_logger.info("untagged message")
|
||||
|
||||
for h in logging.getLogger().handlers:
|
||||
h.flush()
|
||||
|
||||
agent_log = hermes_home / "logs" / "agent.log"
|
||||
content = agent_log.read_text()
|
||||
assert "untagged message" in content
|
||||
# Should not have any [xxx] session tag
|
||||
import re
|
||||
for line in content.splitlines():
|
||||
if "untagged message" in line:
|
||||
assert not re.search(r"\[.+?\]", line.split("INFO")[1].split("test.no_session")[0])
|
||||
|
||||
def test_clear_session_context(self, hermes_home):
|
||||
"""After clearing, session tag disappears."""
|
||||
hermes_logging.setup_logging(hermes_home=hermes_home)
|
||||
hermes_logging.set_session_context("xyz789")
|
||||
hermes_logging.clear_session_context()
|
||||
|
||||
test_logger = logging.getLogger("test.cleared")
|
||||
test_logger.info("after clear")
|
||||
|
||||
for h in logging.getLogger().handlers:
|
||||
h.flush()
|
||||
|
||||
agent_log = hermes_home / "logs" / "agent.log"
|
||||
content = agent_log.read_text()
|
||||
assert "[xyz789]" not in content
|
||||
|
||||
def test_session_context_thread_isolated(self, hermes_home):
|
||||
"""Session context is per-thread — one thread's context doesn't leak."""
|
||||
hermes_logging.setup_logging(hermes_home=hermes_home)
|
||||
|
||||
results = {}
|
||||
|
||||
def thread_a():
|
||||
hermes_logging.set_session_context("thread_a_session")
|
||||
logging.getLogger("test.thread_a").info("from thread A")
|
||||
for h in logging.getLogger().handlers:
|
||||
h.flush()
|
||||
|
||||
def thread_b():
|
||||
hermes_logging.set_session_context("thread_b_session")
|
||||
logging.getLogger("test.thread_b").info("from thread B")
|
||||
for h in logging.getLogger().handlers:
|
||||
h.flush()
|
||||
|
||||
ta = threading.Thread(target=thread_a)
|
||||
tb = threading.Thread(target=thread_b)
|
||||
ta.start()
|
||||
ta.join()
|
||||
tb.start()
|
||||
tb.join()
|
||||
|
||||
agent_log = hermes_home / "logs" / "agent.log"
|
||||
content = agent_log.read_text()
|
||||
|
||||
# Each thread's message should have its own session tag
|
||||
for line in content.splitlines():
|
||||
if "from thread A" in line:
|
||||
assert "[thread_a_session]" in line
|
||||
assert "[thread_b_session]" not in line
|
||||
if "from thread B" in line:
|
||||
assert "[thread_b_session]" in line
|
||||
assert "[thread_a_session]" not in line
|
||||
|
||||
|
||||
class TestRecordFactory:
|
||||
"""Unit tests for the custom LogRecord factory."""
|
||||
|
||||
def test_record_has_session_tag(self):
|
||||
"""Every record gets a session_tag attribute."""
|
||||
factory = logging.getLogRecordFactory()
|
||||
record = factory("test", logging.INFO, "", 0, "msg", (), None)
|
||||
assert hasattr(record, "session_tag")
|
||||
|
||||
def test_empty_tag_without_context(self):
|
||||
hermes_logging.clear_session_context()
|
||||
factory = logging.getLogRecordFactory()
|
||||
record = factory("test", logging.INFO, "", 0, "msg", (), None)
|
||||
assert record.session_tag == ""
|
||||
|
||||
def test_tag_with_context(self):
|
||||
hermes_logging.set_session_context("sess_42")
|
||||
factory = logging.getLogRecordFactory()
|
||||
record = factory("test", logging.INFO, "", 0, "msg", (), None)
|
||||
assert record.session_tag == " [sess_42]"
|
||||
|
||||
def test_idempotent_install(self):
|
||||
"""Calling _install_session_record_factory() twice doesn't double-wrap."""
|
||||
hermes_logging._install_session_record_factory()
|
||||
factory_a = logging.getLogRecordFactory()
|
||||
hermes_logging._install_session_record_factory()
|
||||
factory_b = logging.getLogRecordFactory()
|
||||
assert factory_a is factory_b
|
||||
|
||||
def test_works_with_any_handler(self):
|
||||
"""A handler using %(session_tag)s works even without _SessionFilter."""
|
||||
hermes_logging.set_session_context("any_handler_test")
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(logging.Formatter("%(session_tag)s %(message)s"))
|
||||
|
||||
logger = logging.getLogger("_test_any_handler")
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
try:
|
||||
# Should not raise KeyError
|
||||
logger.info("hello")
|
||||
finally:
|
||||
logger.removeHandler(handler)
|
||||
|
||||
|
||||
class TestComponentFilter:
|
||||
"""Unit tests for _ComponentFilter."""
|
||||
|
||||
def test_passes_matching_prefix(self):
|
||||
f = hermes_logging._ComponentFilter(("gateway",))
|
||||
record = logging.LogRecord(
|
||||
"gateway.run", logging.INFO, "", 0, "msg", (), None
|
||||
)
|
||||
assert f.filter(record) is True
|
||||
|
||||
def test_passes_nested_matching_prefix(self):
|
||||
f = hermes_logging._ComponentFilter(("gateway",))
|
||||
record = logging.LogRecord(
|
||||
"gateway.platforms.telegram", logging.INFO, "", 0, "msg", (), None
|
||||
)
|
||||
assert f.filter(record) is True
|
||||
|
||||
def test_blocks_non_matching(self):
|
||||
f = hermes_logging._ComponentFilter(("gateway",))
|
||||
record = logging.LogRecord(
|
||||
"tools.terminal_tool", logging.INFO, "", 0, "msg", (), None
|
||||
)
|
||||
assert f.filter(record) is False
|
||||
|
||||
def test_multiple_prefixes(self):
|
||||
f = hermes_logging._ComponentFilter(("agent", "run_agent", "model_tools"))
|
||||
assert f.filter(logging.LogRecord(
|
||||
"agent.compressor", logging.INFO, "", 0, "", (), None
|
||||
))
|
||||
assert f.filter(logging.LogRecord(
|
||||
"run_agent", logging.INFO, "", 0, "", (), None
|
||||
))
|
||||
assert f.filter(logging.LogRecord(
|
||||
"model_tools", logging.INFO, "", 0, "", (), None
|
||||
))
|
||||
assert not f.filter(logging.LogRecord(
|
||||
"tools.browser", logging.INFO, "", 0, "", (), None
|
||||
))
|
||||
|
||||
|
||||
class TestComponentPrefixes:
|
||||
"""COMPONENT_PREFIXES covers the expected components."""
|
||||
|
||||
def test_gateway_prefix(self):
|
||||
assert "gateway" in hermes_logging.COMPONENT_PREFIXES
|
||||
assert ("gateway",) == hermes_logging.COMPONENT_PREFIXES["gateway"]
|
||||
|
||||
def test_agent_prefix(self):
|
||||
prefixes = hermes_logging.COMPONENT_PREFIXES["agent"]
|
||||
assert "agent" in prefixes
|
||||
assert "run_agent" in prefixes
|
||||
assert "model_tools" in prefixes
|
||||
|
||||
def test_tools_prefix(self):
|
||||
assert ("tools",) == hermes_logging.COMPONENT_PREFIXES["tools"]
|
||||
|
||||
def test_cli_prefix(self):
|
||||
prefixes = hermes_logging.COMPONENT_PREFIXES["cli"]
|
||||
assert "hermes_cli" in prefixes
|
||||
assert "cli" in prefixes
|
||||
|
||||
def test_cron_prefix(self):
|
||||
assert ("cron",) == hermes_logging.COMPONENT_PREFIXES["cron"]
|
||||
|
||||
|
||||
class TestSetupVerboseLogging:
|
||||
"""setup_verbose_logging() adds a DEBUG-level console handler."""
|
||||
@@ -301,6 +593,59 @@ class TestAddRotatingHandler:
|
||||
logger.removeHandler(h)
|
||||
h.close()
|
||||
|
||||
def test_log_filter_attached(self, tmp_path):
|
||||
"""Optional log_filter is attached to the handler."""
|
||||
log_path = tmp_path / "filtered.log"
|
||||
logger = logging.getLogger("_test_rotating_filter")
|
||||
formatter = logging.Formatter("%(message)s")
|
||||
component_filter = hermes_logging._ComponentFilter(("test",))
|
||||
|
||||
hermes_logging._add_rotating_handler(
|
||||
logger, log_path,
|
||||
level=logging.INFO, max_bytes=1024, backup_count=1,
|
||||
formatter=formatter,
|
||||
log_filter=component_filter,
|
||||
)
|
||||
|
||||
handlers = [h for h in logger.handlers if isinstance(h, RotatingFileHandler)]
|
||||
assert len(handlers) == 1
|
||||
assert component_filter in handlers[0].filters
|
||||
# Clean up
|
||||
for h in list(logger.handlers):
|
||||
if isinstance(h, RotatingFileHandler):
|
||||
logger.removeHandler(h)
|
||||
h.close()
|
||||
|
||||
def test_no_session_filter_on_handler(self, tmp_path):
|
||||
"""Handlers rely on record factory, not per-handler _SessionFilter."""
|
||||
log_path = tmp_path / "no_session_filter.log"
|
||||
logger = logging.getLogger("_test_no_session_filter")
|
||||
formatter = logging.Formatter("%(session_tag)s%(message)s")
|
||||
|
||||
hermes_logging._add_rotating_handler(
|
||||
logger, log_path,
|
||||
level=logging.INFO, max_bytes=1024, backup_count=1,
|
||||
formatter=formatter,
|
||||
)
|
||||
|
||||
handlers = [h for h in logger.handlers if isinstance(h, RotatingFileHandler)]
|
||||
assert len(handlers) == 1
|
||||
# No _SessionFilter on the handler — record factory handles it
|
||||
assert len(handlers[0].filters) == 0
|
||||
|
||||
# But session_tag still works (via record factory)
|
||||
hermes_logging.set_session_context("factory_test")
|
||||
logger.info("test msg")
|
||||
handlers[0].flush()
|
||||
content = log_path.read_text()
|
||||
assert "[factory_test]" in content
|
||||
|
||||
# Clean up
|
||||
for h in list(logger.handlers):
|
||||
if isinstance(h, RotatingFileHandler):
|
||||
logger.removeHandler(h)
|
||||
h.close()
|
||||
|
||||
def test_managed_mode_initial_open_sets_group_writable(self, tmp_path):
|
||||
log_path = tmp_path / "managed-open.log"
|
||||
logger = logging.getLogger("_test_rotating_managed_open")
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -293,9 +293,7 @@ class ModalEnvironment(BaseEnvironment):
|
||||
offset += chunk_size
|
||||
proc.stdin.write_eof()
|
||||
await proc.stdin.drain.aio()
|
||||
exit_code = await proc.wait.aio()
|
||||
if exit_code != 0:
|
||||
raise RuntimeError(f"Modal upload failed (exit {exit_code})")
|
||||
await proc.wait.aio()
|
||||
|
||||
self._worker.run_coroutine(_write(), timeout=30)
|
||||
|
||||
|
||||
@@ -191,13 +191,11 @@ class SSHEnvironment(BaseEnvironment):
|
||||
_, ssh_stderr = ssh_proc.communicate(timeout=120)
|
||||
# Use communicate() instead of wait() to drain stderr and
|
||||
# avoid deadlock if tar produces more than PIPE_BUF of errors.
|
||||
# stdout is already closed (for SIGPIPE); only drain stderr.
|
||||
# Cannot use communicate() here — it would call fileno()
|
||||
# on the closed stdout fd and raise ValueError.
|
||||
tar_stderr_raw = b""
|
||||
if tar_proc.poll() is None:
|
||||
tar_proc.wait(timeout=10)
|
||||
tar_stderr_raw = tar_proc.stderr.read() if tar_proc.stderr else b""
|
||||
_, tar_stderr_raw = tar_proc.communicate(timeout=10)
|
||||
else:
|
||||
tar_stderr_raw = tar_proc.stderr.read() if tar_proc.stderr else b""
|
||||
except subprocess.TimeoutExpired:
|
||||
tar_proc.kill()
|
||||
ssh_proc.kill()
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|--------|-------------|
|
||||
|
||||
@@ -344,9 +344,79 @@ pip install 'hermes-agent[matrix]'
|
||||
**Fix**:
|
||||
1. Verify `libolm` is installed on your system (see the E2EE section above).
|
||||
2. Make sure `MATRIX_ENCRYPTION=true` is set in your `.env`.
|
||||
3. In your Matrix client (Element), go to the bot's profile → **Sessions** → verify/trust the bot's device.
|
||||
3. In your Matrix client (Element), go to the bot's profile -> Sessions -> verify/trust the bot's device.
|
||||
4. If the bot just joined an encrypted room, it can only decrypt messages sent *after* it joined. Older messages are inaccessible.
|
||||
|
||||
### Upgrading from a previous version with E2EE
|
||||
|
||||
If you previously used Hermes with `MATRIX_ENCRYPTION=true` and are upgrading to
|
||||
a version that uses the new SQLite-based crypto store, the bot's encryption
|
||||
identity has changed. Your Matrix client (Element) may cache the old device keys
|
||||
and refuse to share encryption sessions with the bot.
|
||||
|
||||
**Symptoms**: The bot connects and shows "E2EE enabled" in the logs, but all
|
||||
messages show "could not decrypt event" and the bot never responds.
|
||||
|
||||
**What's happening**: The old encryption state (from the previous `matrix-nio` or
|
||||
serialization-based `mautrix` backend) is incompatible with the new SQLite crypto
|
||||
store. The bot creates a fresh encryption identity, but your Matrix client still
|
||||
has the old keys cached and won't share the room's encryption session with a
|
||||
device whose keys changed. This is a Matrix security feature -- clients treat
|
||||
changed identity keys for the same device as suspicious.
|
||||
|
||||
**Fix** (one-time migration):
|
||||
|
||||
1. **Generate a new access token** to get a fresh device ID. The simplest way:
|
||||
|
||||
```bash
|
||||
curl -X POST https://your-server/_matrix/client/v3/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "m.login.password",
|
||||
"identifier": {"type": "m.id.user", "user": "@hermes:your-server.org"},
|
||||
"password": "your-password",
|
||||
"initial_device_display_name": "Hermes Agent"
|
||||
}'
|
||||
```
|
||||
|
||||
Copy the new `access_token` and update `MATRIX_ACCESS_TOKEN` in `~/.hermes/.env`.
|
||||
|
||||
2. **Delete old encryption state**:
|
||||
|
||||
```bash
|
||||
rm -f ~/.hermes/platforms/matrix/store/crypto.db
|
||||
rm -f ~/.hermes/platforms/matrix/store/crypto_store.*
|
||||
```
|
||||
|
||||
3. **Force your Matrix client to rotate the encryption session**. In Element,
|
||||
open the DM room with the bot and type `/discardsession`. This forces Element
|
||||
to create a new encryption session and share it with the bot's new device.
|
||||
|
||||
4. **Restart the gateway**:
|
||||
|
||||
```bash
|
||||
hermes gateway run
|
||||
```
|
||||
|
||||
5. **Send a new message**. The bot should decrypt and respond normally.
|
||||
|
||||
:::note
|
||||
After migration, messages sent *before* the upgrade cannot be decrypted -- the old
|
||||
encryption keys are gone. This only affects the transition; new messages work
|
||||
normally.
|
||||
:::
|
||||
|
||||
:::tip
|
||||
**New installations are not affected.** This migration is only needed if you had
|
||||
a working E2EE setup with a previous version of Hermes and are upgrading.
|
||||
|
||||
**Why a new access token?** Each Matrix access token is bound to a specific device
|
||||
ID. Reusing the same device ID with new encryption keys causes other Matrix
|
||||
clients to distrust the device (they see changed identity keys as a potential
|
||||
security breach). A new access token gets a new device ID with no stale key
|
||||
history, so other clients trust it immediately.
|
||||
:::
|
||||
|
||||
### Sync issues / bot falls behind
|
||||
|
||||
**Cause**: Long-running tool executions can delay the sync loop, or the homeserver is slow.
|
||||
|
||||
Reference in New Issue
Block a user