From 3db96bbb1d074b9c6fb9d65d7327380b4ad6bf7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabr=C3=ADcio=20Scaglioni?= Date: Thu, 9 Jul 2026 10:39:29 -0300 Subject: [PATCH 1/3] feat: add Hermes Agent as a first-class backend and transcript source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds full support for Nous Research's Hermes Agent across all three SkillOpt layers — core model backend, sleep-cycle backend, and transcript harvesting. Core model layer (skillopt/model/): - Register the Hermes CLI as a chat backend (hermes_chat) alongside the existing Claude, Codex, Qwen, and MiniMax backends. - All chat entry points (optimizer, target, messages, deployment) route through the Hermes adapter. Sleep-cycle backend (skillopt_sleep/backend.py): - Add HermesBackend extending CliBackend, driving hermes --profile chat -Q -q for attempt / judge / reflect calls. - CLI output is filtered to strip notices, warnings, and traceback debris so the pipeline sees only the model response. - Registered under get_backend("hermes") with aliases hermes_chat and hermes_cli. Transcript harvesting (skillopt_sleep/harvest_hermes.py): - New harvest source reads Hermes session data from ~/.hermes/state.db (SQLite), building SessionDigest objects from sessions + messages. - Skips engine-internal sessions (temp dirs matching skillopt_sleep_*) so the mine step only sees real user sessions. - Supports the standard harvest contract: scope, since, limit, project. Config additions (skillopt_sleep/config.py): - memory_filename — controls the project memory file name. Defaults to "CLAUDE.md" for backward compatibility. Set to "AGENTS.md" for Codex and Hermes. - hermes_home — path to Hermes state directory, defaults to ~/.hermes, overridable via $HERMES_HOME. Tests: - 7 new tests in TestHermesBackendCli covering backend registration, command construction, error capture, output filtering, and env vars. Backward compatible — defaults unchanged, all existing backends intact. --- skillopt/model/__init__.py | 81 ++++++++++ skillopt/model/backend_config.py | 8 +- skillopt/model/hermes_backend.py | 184 ++++++++++++++++++++++ skillopt_sleep/backend.py | 73 +++++++++ skillopt_sleep/config.py | 5 +- skillopt_sleep/cycle.py | 2 +- skillopt_sleep/harvest_hermes.py | 247 ++++++++++++++++++++++++++++++ skillopt_sleep/harvest_sources.py | 11 ++ tests/test_sleep_engine.py | 147 ++++++++++++++++++ 9 files changed, 752 insertions(+), 6 deletions(-) create mode 100644 skillopt/model/hermes_backend.py create mode 100644 skillopt_sleep/harvest_hermes.py diff --git a/skillopt/model/__init__.py b/skillopt/model/__init__.py index a09e6e0c..bda59b63 100644 --- a/skillopt/model/__init__.py +++ b/skillopt/model/__init__.py @@ -6,6 +6,7 @@ from skillopt.model import azure_openai as _openai from skillopt.model import claude_backend as _claude +from skillopt.model import hermes_backend as _hermes from skillopt.model import minimax_backend as _minimax from skillopt.model import qwen_backend as _qwen from skillopt.model.backend_config import ( # noqa: F401 @@ -55,6 +56,10 @@ def set_backend(name: str | None) -> str: set_optimizer_backend("openai_chat") set_target_backend("minimax_chat") return "minimax_chat" + if normalized in {"hermes", "hermes_chat"}: + set_optimizer_backend("hermes_chat") + set_target_backend("hermes_chat") + return "hermes_chat" raise ValueError(f"Unsupported legacy backend: {name!r}") @@ -74,6 +79,8 @@ def get_backend_name() -> str: return "qwen_chat" if optimizer == "openai_chat" and target == "minimax_chat": return "minimax_chat" + if optimizer == "hermes_chat" and target == "hermes_chat": + return "hermes_chat" return f"{optimizer}+{target}" @@ -105,6 +112,15 @@ def chat_optimizer( reasoning_effort=reasoning_effort, timeout=timeout, ) + if get_optimizer_backend() == "hermes_chat": + return _hermes.chat_optimizer( + system=system, + user=user, + max_completion_tokens=max_completion_tokens, + retries=retries, + stage=stage, + timeout=timeout, + ) return _openai.chat_optimizer( system=system, user=user, @@ -153,6 +169,15 @@ def chat_target( stage=stage, reasoning_effort=reasoning_effort, ) + if get_target_backend() == "hermes_chat": + return _hermes.chat_target( + system=system, + user=user, + max_completion_tokens=max_completion_tokens, + retries=retries, + stage=stage, + timeout=timeout, + ) if not is_target_chat_backend(): raise NotImplementedError( "chat_target is only supported with target_backend=openai_chat, claude_chat, qwen_chat, or minimax_chat. " @@ -204,6 +229,17 @@ def chat_optimizer_messages( return_message=return_message, timeout=timeout, ) + if get_optimizer_backend() == "hermes_chat": + return _hermes.chat_optimizer_messages( + messages=messages, + max_completion_tokens=max_completion_tokens, + retries=retries, + stage=stage, + tools=tools, + tool_choice=tool_choice, + return_message=return_message, + timeout=timeout, + ) return _openai.chat_optimizer_messages( messages=messages, max_completion_tokens=max_completion_tokens, @@ -263,6 +299,17 @@ def chat_target_messages( tool_choice=tool_choice, return_message=return_message, ) + if get_target_backend() == "hermes_chat": + return _hermes.chat_target_messages( + messages=messages, + max_completion_tokens=max_completion_tokens, + retries=retries, + stage=stage, + tools=tools, + tool_choice=tool_choice, + return_message=return_message, + timeout=timeout, + ) if not is_target_chat_backend(): raise NotImplementedError( "chat_target_messages is only supported with target_backend=openai_chat, claude_chat, qwen_chat, or minimax_chat. " @@ -294,6 +341,18 @@ def chat_messages_with_deployment( return_message: bool = False, timeout: int | None = None, ) -> tuple[Any, dict]: + if get_optimizer_backend() == "hermes_chat" or get_target_backend() == "hermes_chat": + return _hermes.chat_messages_with_deployment( + deployment=deployment, + messages=messages, + max_completion_tokens=max_completion_tokens, + retries=retries, + stage=stage, + tools=tools, + tool_choice=tool_choice, + return_message=return_message, + timeout=timeout, + ) return _openai.chat_messages_with_deployment( deployment=deployment, messages=messages, @@ -318,6 +377,16 @@ def chat_with_deployment( reasoning_effort: str | None = None, timeout: int | None = None, ) -> tuple[str, dict]: + if get_optimizer_backend() == "hermes_chat" or get_target_backend() == "hermes_chat": + return _hermes.chat_with_deployment( + deployment=deployment, + system=system, + user=user, + max_completion_tokens=max_completion_tokens, + retries=retries, + stage=stage, + timeout=timeout, + ) return _openai.chat_with_deployment( deployment=deployment, system=system, @@ -365,6 +434,17 @@ def get_token_summary() -> dict: summary[stage]["prompt_tokens"] += values["prompt_tokens"] summary[stage]["completion_tokens"] += values["completion_tokens"] summary[stage]["total_tokens"] += values["total_tokens"] + hermes_summary = _hermes.get_token_summary() + for stage, values in hermes_summary.items(): + if stage == "_total": + continue + if stage not in summary: + summary[stage] = values + continue + summary[stage]["calls"] += values["calls"] + summary[stage]["prompt_tokens"] += values["prompt_tokens"] + summary[stage]["completion_tokens"] += values["completion_tokens"] + summary[stage]["total_tokens"] += values["total_tokens"] total = { "calls": 0, "prompt_tokens": 0, @@ -387,6 +467,7 @@ def reset_token_tracker() -> None: _claude.reset_token_tracker() _qwen.reset_token_tracker() _minimax.reset_token_tracker() + _hermes.reset_token_tracker() def configure_azure_openai( diff --git a/skillopt/model/backend_config.py b/skillopt/model/backend_config.py index f23725c5..e73bafc9 100644 --- a/skillopt/model/backend_config.py +++ b/skillopt/model/backend_config.py @@ -49,10 +49,10 @@ def _parse_int(value: str | None, default: int) -> int: def set_optimizer_backend(backend: str) -> None: global OPTIMIZER_BACKEND OPTIMIZER_BACKEND = normalize_backend_name(backend or "openai_chat") - if OPTIMIZER_BACKEND not in {"openai_chat", "claude_chat", "qwen_chat", "minimax_chat"}: + if OPTIMIZER_BACKEND not in {"openai_chat", "claude_chat", "qwen_chat", "minimax_chat", "hermes_chat"}: raise ValueError( f"Unsupported optimizer backend: {OPTIMIZER_BACKEND!r}. " - "Supported values are 'openai_chat', 'claude_chat', 'qwen_chat', and 'minimax_chat'." + "Supported values are 'openai_chat', 'claude_chat', 'qwen_chat', 'minimax_chat', and 'hermes_chat'." ) os.environ["OPTIMIZER_BACKEND"] = OPTIMIZER_BACKEND @@ -64,10 +64,10 @@ def get_optimizer_backend() -> str: def set_target_backend(backend: str) -> None: global TARGET_BACKEND TARGET_BACKEND = normalize_backend_name(backend or "openai_chat") - if TARGET_BACKEND not in {"openai_chat", "claude_chat", "qwen_chat", "minimax_chat", "codex_exec", "claude_code_exec"}: + if TARGET_BACKEND not in {"openai_chat", "claude_chat", "qwen_chat", "minimax_chat", "codex_exec", "claude_code_exec", "hermes_chat"}: raise ValueError( f"Unsupported target backend: {TARGET_BACKEND!r}. " - "Supported values are 'openai_chat', 'claude_chat', 'qwen_chat', 'minimax_chat', 'codex_exec', and 'claude_code_exec'." + "Supported values are 'openai_chat', 'claude_chat', 'qwen_chat', 'minimax_chat', 'codex_exec', 'claude_code_exec', and 'hermes_chat'." ) os.environ["TARGET_BACKEND"] = TARGET_BACKEND diff --git a/skillopt/model/hermes_backend.py b/skillopt/model/hermes_backend.py new file mode 100644 index 00000000..f291e858 --- /dev/null +++ b/skillopt/model/hermes_backend.py @@ -0,0 +1,184 @@ +"""Hermes CLI chat backend for SkillOpt. + +Chama `hermes --profile chat -q ""` como target/optimizer. +Mais simples que claude_backend: sem tools, imagens, ou attachments. +""" +from __future__ import annotations + +import json +import os +import subprocess +import time +from typing import Any + +from skillopt.model.common import CompatAssistantMessage, CompatToolCall, CompatToolFunction, default_model_for_backend, tracker + +HERMES_BIN = os.environ.get("HERMES_BIN", "hermes") +HERMES_TARGET_PROFILE = os.environ.get("HERMES_TARGET_PROFILE", "default") +HERMES_OPTIMIZER_PROFILE = os.environ.get("HERMES_OPTIMIZER_PROFILE", "default") + +OPTIMIZER_DEPLOYMENT = os.environ.get("OPTIMIZER_DEPLOYMENT", "default") +TARGET_DEPLOYMENT = os.environ.get("TARGET_DEPLOYMENT", "default") + + +def _call_hermes(prompt: str, profile: str, timeout: int | None = None) -> tuple[str, dict[str, int]]: + """Call hermes CLI and return (response_text, token_info).""" + cmd = [HERMES_BIN, "--profile", profile, "chat", "-q", prompt] + t0 = time.time() + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout or 180, + env={**os.environ, "HERMES_NO_COLOR": "1"}, + ) + elapsed = time.time() - t0 + if proc.returncode != 0: + stderr = (proc.stderr or "").strip() + raise RuntimeError(stderr or f"Hermes CLI exited with code {proc.returncode}") + + text = (proc.stdout or "").strip() + tokens_in = len(prompt) // 4 + tokens_out = len(text) // 4 + return text, { + "prompt_tokens": tokens_in, + "completion_tokens": tokens_out, + "total_tokens": tokens_in + tokens_out, + } + + +def _build_prompt(system: str, user: str) -> str: + """Build a prompt string from system + user messages.""" + parts = [] + if system: + parts.append(system) + if user: + parts.append(user) + return "\n\n".join(parts) + + +def chat_optimizer(system: str, user: str, max_completion_tokens: int = 16384, retries: int = 3, stage: str = "optimizer", timeout: int | None = None) -> tuple[str, dict[str, int]]: + """Call Hermes as optimizer with profile=target.""" + del max_completion_tokens + prompt = _build_prompt(system, user) + last_err = None + for attempt in range(retries): + try: + text, usage = _call_hermes(prompt, HERMES_OPTIMIZER_PROFILE, timeout=timeout) + tracker.record(stage, usage["prompt_tokens"], usage["completion_tokens"]) + return text, usage + except Exception as e: + last_err = e + time.sleep(min(2 ** attempt, 10)) + raise RuntimeError(f"Hermes optimizer backend failed after {retries} retries: {last_err}") + + +def chat_target(system: str, user: str, max_completion_tokens: int = 16384, retries: int = 3, stage: str = "target", timeout: int | None = None) -> tuple[str, dict[str, int]]: + """Call Hermes as target with profile=target.""" + del max_completion_tokens + prompt = _build_prompt(system, user) + last_err = None + for attempt in range(retries): + try: + text, usage = _call_hermes(prompt, HERMES_TARGET_PROFILE, timeout=timeout) + tracker.record(stage, usage["prompt_tokens"], usage["completion_tokens"]) + return text, usage + except Exception as e: + last_err = e + time.sleep(min(2 ** attempt, 10)) + raise RuntimeError(f"Hermes target backend failed after {retries} retries: {last_err}") + + +def chat_with_deployment(deployment: str, system: str, user: str, max_completion_tokens: int = 16384, retries: int = 3, stage: str = "custom", timeout: int | None = None) -> tuple[str, dict[str, int]]: + """Call Hermes with a custom profile name as deployment.""" + del max_completion_tokens + profile = deployment or HERMES_TARGET_PROFILE + prompt = _build_prompt(system, user) + last_err = None + for attempt in range(retries): + try: + text, usage = _call_hermes(prompt, profile, timeout=timeout) + tracker.record(stage, usage["prompt_tokens"], usage["completion_tokens"]) + return text, usage + except Exception as e: + last_err = e + time.sleep(min(2 ** attempt, 10)) + raise RuntimeError(f"Hermes backend (deployment={deployment}) failed after {retries} retries: {last_err}") + + +# ── Message-based variants (needed for tool-using benchmarks like spreadsheetbench) ── + +def chat_optimizer_messages(messages: list[dict[str, Any]], max_completion_tokens: int = 16384, retries: int = 3, stage: str = "optimizer", *, tools: list[dict[str, Any]] | None = None, tool_choice: str | dict[str, Any] | None = None, return_message: bool = False, timeout: int | None = None) -> tuple[Any, dict[str, int]]: + """Simplified: flatten messages to prompt text.""" + del max_completion_tokens, tools, tool_choice, return_message + parts = [] + for msg in messages: + role = msg.get("role", "user") + content = msg.get("content", "") + if isinstance(content, list): + texts = [c.get("text", "") for c in content if isinstance(c, dict) and c.get("type") == "text"] + content = "\n".join(texts) + parts.append(f"<{role}>\n{content}") + prompt = "\n".join(parts) + text, usage = _call_hermes(prompt, HERMES_OPTIMIZER_PROFILE, timeout=timeout) + tracker.record(stage, usage["prompt_tokens"], usage["completion_tokens"]) + return text, usage + + +def chat_target_messages(messages: list[dict[str, Any]], max_completion_tokens: int = 16384, retries: int = 3, stage: str = "target", *, tools: list[dict[str, Any]] | None = None, tool_choice: str | dict[str, Any] | None = None, return_message: bool = False, timeout: int | None = None) -> tuple[Any, dict[str, int]]: + """Simplified: flatten messages to prompt text.""" + del max_completion_tokens, tools, tool_choice, return_message + parts = [] + for msg in messages: + role = msg.get("role", "user") + content = msg.get("content", "") + if isinstance(content, list): + texts = [c.get("text", "") for c in content if isinstance(c, dict) and c.get("type") == "text"] + content = "\n".join(texts) + parts.append(f"<{role}>\n{content}") + prompt = "\n".join(parts) + text, usage = _call_hermes(prompt, HERMES_TARGET_PROFILE, timeout=timeout) + tracker.record(stage, usage["prompt_tokens"], usage["completion_tokens"]) + return text, usage + + +def chat_messages_with_deployment(deployment: str, messages: list[dict[str, Any]], max_completion_tokens: int = 16384, retries: int = 3, stage: str = "custom", *, tools: list[dict[str, Any]] | None = None, tool_choice: str | dict[str, Any] | None = None, return_message: bool = False, timeout: int | None = None) -> tuple[Any, dict[str, int]]: + """Simplified: flatten messages to prompt text.""" + del max_completion_tokens, tools, tool_choice, return_message + profile = deployment or HERMES_TARGET_PROFILE + parts = [] + for msg in messages: + role = msg.get("role", "user") + content = msg.get("content", "") + if isinstance(content, list): + texts = [c.get("text", "") for c in content if isinstance(c, dict) and c.get("type") == "text"] + content = "\n".join(texts) + parts.append(f"<{role}>\n{content}") + prompt = "\n".join(parts) + text, usage = _call_hermes(prompt, profile, timeout=timeout) + tracker.record(stage, usage["prompt_tokens"], usage["completion_tokens"]) + return text, usage + + +def get_token_summary() -> dict[str, dict[str, int]]: + return tracker.summary() + + +def reset_token_tracker() -> None: + tracker.reset() + + +def set_reasoning_effort(effort: str | None) -> None: + pass # Not applicable for Hermes + + +def set_target_deployment(deployment: str) -> None: + global TARGET_DEPLOYMENT + TARGET_DEPLOYMENT = deployment or default_model_for_backend("hermes") + os.environ["TARGET_DEPLOYMENT"] = TARGET_DEPLOYMENT + + +def set_optimizer_deployment(deployment: str) -> None: + global OPTIMIZER_DEPLOYMENT + OPTIMIZER_DEPLOYMENT = deployment or default_model_for_backend("hermes") + os.environ["OPTIMIZER_DEPLOYMENT"] = OPTIMIZER_DEPLOYMENT diff --git a/skillopt_sleep/backend.py b/skillopt_sleep/backend.py index cf01b0af..54eb0142 100644 --- a/skillopt_sleep/backend.py +++ b/skillopt_sleep/backend.py @@ -1369,6 +1369,77 @@ def _call(self, prompt: str, *, max_tokens: int = 1024, retries: int = 5) -> str return "" +# ── Hermes CLI backend ───────────────────────────────────────────────────────── + +class HermesBackend(CliBackend): + """Drives Hermes Agent CLI: `hermes --profile chat -q ""`.""" + + name = "hermes" + + def __init__(self, model: str = "", timeout: int = 180) -> None: + super().__init__(model=model or os.environ.get("SKILLOPT_SLEEP_HERMES_MODEL", ""), + timeout=timeout) + self.hermes_bin = os.environ.get("HERMES_BIN", "hermes") + self.hermes_profile = os.environ.get("SKILLOPT_SLEEP_HERMES_PROFILE", + os.environ.get("HERMES_TARGET_PROFILE", "default")) + + def _call(self, prompt: str, *, max_tokens: int = 1024) -> str: + import re + import tempfile + cmd = [ + self.hermes_bin, + "--profile", self.hermes_profile, + "chat", "-Q", "-q", prompt, + ] + clean_cwd = tempfile.mkdtemp(prefix="skillopt_sleep_hermes_") + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=self.timeout, + cwd=clean_cwd, + env={**os.environ, "HERMES_NO_COLOR": "1"}, + ) + except Exception: + return "" + finally: + try: + import shutil + shutil.rmtree(clean_cwd, ignore_errors=True) + except Exception: + pass + if proc.returncode != 0: + stderr = (proc.stderr or "").strip() + self.last_call_error = stderr[:500] if stderr else f"Hermes CLI exited with code {proc.returncode}" + return "" + raw = (proc.stdout or "").strip() + # Strip known CLI boilerplate (notices, warnings, session IDs, tracebacks) + skip_prefixes = ( + "Bitwarden Secrets Manager:", + "Warning: Unknown", + "session_id:", + ) + lines = raw.split("\n") + body: list[str] = [] + in_traceback = False + for line in lines: + stripped = line.strip() + if not stripped: + continue + if any(stripped.startswith(p) for p in skip_prefixes): + continue + if stripped.startswith("Exception") or stripped.startswith("Traceback"): + in_traceback = True + continue + if in_traceback: + continue + body.append(line) + result = "\n".join(body).strip() + self._tokens += len(prompt) // 4 + len(result) // 4 + return result + + def get_backend( name: str, *, @@ -1383,6 +1454,8 @@ def get_backend( return ClaudeCliBackend(model=model, claude_path=claude_path) if n in {"codex", "codex_cli", "openai_codex"}: return CodexCliBackend(model=model, codex_path=codex_path, project_dir=project_dir) + if n in {"hermes", "hermes_chat", "hermes_cli"}: + return HermesBackend(model=model) if n in {"azure", "azure_openai", "aoai"}: return AzureOpenAIBackend(deployment=model, endpoint=azure_endpoint) if n in {"azure-responses", "azure_responses", "aoai-responses", "responses"}: diff --git a/skillopt_sleep/config.py b/skillopt_sleep/config.py index 06303e09..58030cde 100644 --- a/skillopt_sleep/config.py +++ b/skillopt_sleep/config.py @@ -19,12 +19,14 @@ HOME_STATE_DIR = os.path.expanduser("~/.skillopt-sleep") CLAUDE_HOME = os.path.expanduser("~/.claude") CODEX_HOME = os.path.expanduser("~/.codex") +HERMES_HOME = os.environ.get("HERMES_HOME") or os.path.expanduser("~/.hermes") DEFAULTS: Dict[str, Any] = { # ── scope ────────────────────────────────────────────────────────────── "claude_home": CLAUDE_HOME, "codex_home": CODEX_HOME, + "hermes_home": HERMES_HOME, "transcript_source": "claude", # "claude" | "codex" | "auto" "projects": "invoked", # "invoked" | "all" | [list of abs paths] "invoked_project": "", # filled at runtime (cwd) when projects == "invoked" @@ -48,7 +50,8 @@ "dream_rollouts": 1, # >1 => multi-rollout contrastive reflection per task "dream_factor": 0, # >0 => add N synthetic variants of each task to the dream "recall_k": 0, # >0 => recall the K most-similar past tasks into the dream - "evolve_memory": True, # consolidate CLAUDE.md + "memory_filename": "CLAUDE.md", # project memory file ("AGENTS.md" for Codex/Hermes) + "evolve_memory": True, # consolidate memory file "evolve_skill": True, # consolidate the managed SKILL.md "llm_mine": True, # use the backend to mine checkable tasks (real backends) "target_skill_path": "", # explicit SKILL.md target for repo-scoped agents diff --git a/skillopt_sleep/cycle.py b/skillopt_sleep/cycle.py index 6ad0d4fb..52d7fff3 100644 --- a/skillopt_sleep/cycle.py +++ b/skillopt_sleep/cycle.py @@ -120,7 +120,7 @@ def run_sleep_cycle( _progress(cfg, f"night {night}: project={project} backend={backend.name}") # ── live skill/memory docs ─────────────────────────────────────────── - live_memory_path = os.path.join(project, "CLAUDE.md") + live_memory_path = os.path.join(project, cfg.get("memory_filename", "CLAUDE.md")) live_skill_path = cfg.managed_skill_path() _progress(cfg, f"live skill: {live_skill_path}") raw_skill = _read(live_skill_path) diff --git a/skillopt_sleep/harvest_hermes.py b/skillopt_sleep/harvest_hermes.py new file mode 100644 index 00000000..cc02b2fc --- /dev/null +++ b/skillopt_sleep/harvest_hermes.py @@ -0,0 +1,247 @@ +"""Hermes Agent session harvesting for SkillOpt-Sleep. + +Reads session transcripts from the Hermes Agent state database +(``~/.hermes/state.db``) and returns ``SessionDigest`` objects. +""" + +from __future__ import annotations + +import os +import sqlite3 +from typing import Any, Dict, List, Optional + +from skillopt_sleep.types import SessionDigest + +HERMES_HOME = os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")) +STATE_DB = os.path.join(HERMES_HOME, "state.db") + + +def _filter_engine_sessions(sessions: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Skip sessions created by the engine's own backend calls. + + These sessions run in temp dirs (prefix ``skillopt_sleep_hermes_``) and + represent optimizer/target/grader calls, not real user sessions. We filter + by ``cwd`` matching the tempdir pattern used in ``HermesBackend._call()``. + """ + out: List[Dict[str, Any]] = [] + for s in sessions: + cwd = (s.get("cwd") or "").strip() + if not cwd: + # No cwd → probably a gateway session; keep it + out.append(s) + elif "skillopt_sleep_hermes_" in cwd: + # Engine's own tempdir → skip + continue + elif cwd.startswith("/tmp/") and len(cwd.split("/", 3)) <= 4: + # Very short-lived temp sessions; likely programmatic + continue + else: + out.append(s) + return out + + +def _fetch_messages(db_path: str, session_id: str) -> List[Dict[str, Any]]: + """Return all messages for a session, ordered by id.""" + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute( + """SELECT role, content, tool_name, timestamp + FROM messages + WHERE session_id = ? AND role IN ('user', 'assistant') + ORDER BY id""", + (session_id,), + ) + rows = [dict(r) for r in cursor.fetchall()] + conn.close() + return rows + + +def _build_digest( + session: Dict[str, Any], + messages: List[Dict[str, Any]], + scope: str = "invoked", + invoked_project: str = "", +) -> Optional[SessionDigest]: + """Build a ``SessionDigest`` from one session + its messages. + + Returns ``None`` if the session has no user or assistant turns, or if it + doesn't match the project scope. + """ + session_id = session.get("id") or "" + project = (session.get("cwd") or "").strip() + title = (session.get("title") or "").strip() + + user_prompts: List[str] = [] + assistant_finals: List[str] = [] + tools: List[str] = [] + n_user = 0 + n_asst = 0 + + # Collect last assistant message after each user turn (the "final" reply) + last_assistant = "" + for msg in messages: + role = (msg.get("role") or "").strip() + content = (msg.get("content") or "").strip() + tool = (msg.get("tool_name") or "").strip() + + if role == "user" and content: + n_user += 1 + user_prompts.append(content) + # Flush any pending assistant final + if last_assistant: + assistant_finals.append(last_assistant) + last_assistant = "" + elif role == "assistant" and content: + n_asst += 1 + last_assistant = content + if tool: + tools.append(tool) + + # Flush the last assistant message + if last_assistant: + assistant_finals.append(last_assistant) + + if n_user == 0 and n_asst == 0: + return None + + # Project matching + if not _project_matches(project, scope, invoked_project): + return None + + # Dedup + def _dedup(xs: List[str]) -> List[str]: + seen = set() + out: List[str] = [] + for x in xs: + if x not in seen: + seen.add(x) + out.append(x) + return out + + return SessionDigest( + session_id=session_id, + project=project, + started_at=_ts_from_epoch(session.get("started_at")), + ended_at=_ts_from_epoch(session.get("ended_at")), + user_prompts=user_prompts, + assistant_finals=assistant_finals[-5:], + tools_used=_dedup(tools), + files_touched=[], + feedback_signals=[], + n_user_turns=n_user, + n_assistant_turns=n_asst, + raw_path=f"{STATE_DB}:{session_id}", + ) + + +def _ts_from_epoch(epoch: Any) -> str: + """Convert a Unix epoch (float/int) to ISO 8601 string.""" + if epoch is None: + return "" + try: + from datetime import datetime, timezone + + dt = datetime.fromtimestamp(float(epoch), tz=timezone.utc) + return dt.isoformat() + except (TypeError, ValueError, OSError): + return "" + + +def _project_matches(project: str, scope: str, invoked: str) -> bool: + """Check whether ``project`` matches the scope.""" + if not invoked or scope == "all": + return True + if not project: + return True # no cwd → can't filter, accept + a = os.path.abspath(project) + b = os.path.abspath(invoked) + return a == b or a.startswith(b + os.sep) or b.startswith(a + os.sep) + + +def harvest_hermes( + *, + scope: str = "invoked", + invoked_project: str = "", + since_iso: Optional[str] = None, + limit: int = 0, + db_path: str = "", +) -> List[SessionDigest]: + """Walk ``~/.hermes/state.db`` and return matching digests. + + Parameters + ---------- + scope : str + ``"all"`` | ``"invoked"`` | list of paths + invoked_project : str + Used when ``scope == "invoked"``. + since_iso : str | None + ISO 8601; only sessions starting after this are kept. + limit : int + Cap number of digests (0 = no cap). + db_path : str + Override state.db path (default: ``~/.hermes/state.db``). + """ + db = db_path or STATE_DB + if not os.path.isfile(db): + return [] + + conn = sqlite3.connect(db) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + # Build query with optional since filter + where = "WHERE cwd IS NOT NULL AND cwd != '' AND ended_at IS NOT NULL" + params: List[Any] = [] + if since_iso: + since_epoch = _epoch_from_iso(since_iso) + if since_epoch is not None: + where += " AND ended_at >= ?" + params.append(since_epoch) + + cursor.execute( + f"""SELECT id, cwd, title, started_at, ended_at, model + FROM sessions + {where} + ORDER BY ended_at DESC + LIMIT ?""", + params + [(limit or 200)], + ) + + sessions = [dict(r) for r in cursor.fetchall()] + conn.close() + + # Filter engine sessions + sessions = _filter_engine_sessions(sessions) + + digests: List[SessionDigest] = [] + for s in sessions: + sid = s.get("id") or "" + msgs = _fetch_messages(db, sid) + digest = _build_digest( + s, msgs, + scope=scope, + invoked_project=invoked_project, + ) + if digest is None: + continue + digests.append(digest) + if limit and len(digests) >= limit: + break + + return digests + + +def _epoch_from_iso(iso: str) -> Optional[float]: + """Convert ISO 8601 string to Unix epoch. Returns None on failure.""" + try: + from datetime import datetime, timezone + + # Handle Z suffix + s = iso.replace("Z", "+00:00") + dt = datetime.fromisoformat(s) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.timestamp() + except (ValueError, TypeError): + return None diff --git a/skillopt_sleep/harvest_sources.py b/skillopt_sleep/harvest_sources.py index 501aa285..53347a84 100644 --- a/skillopt_sleep/harvest_sources.py +++ b/skillopt_sleep/harvest_sources.py @@ -1,10 +1,13 @@ """Source selection for SkillOpt-Sleep transcript harvesting.""" from __future__ import annotations +import os from typing import Optional +from skillopt_sleep.config import HERMES_HOME from skillopt_sleep.harvest import harvest from skillopt_sleep.harvest_codex import harvest_codex +from skillopt_sleep.harvest_hermes import harvest_hermes from skillopt_sleep.types import SessionDigest @@ -13,6 +16,14 @@ def harvest_for_config(cfg, *, since_iso: Optional[str] = None, limit: int = 0) scope = cfg.get("projects", "invoked") invoked_project = cfg.get("invoked_project", "") + if source == "hermes": + return harvest_hermes( + scope=scope, + invoked_project=invoked_project, + since_iso=since_iso, + limit=limit, + db_path=os.path.join(cfg.get("hermes_home", HERMES_HOME), "state.db"), + ) if source == "codex": return harvest_codex( cfg.codex_archived_sessions_dir, diff --git a/tests/test_sleep_engine.py b/tests/test_sleep_engine.py index aee9b7d5..80ca8826 100644 --- a/tests/test_sleep_engine.py +++ b/tests/test_sleep_engine.py @@ -1282,5 +1282,152 @@ def _fake_once(prompt, *, max_tokens=1024): self.assertIn("REDACTED", joined) +class TestHermesBackendCli(unittest.TestCase): + """Hermes CLI backend: command construction, error capture, output filtering.""" + + def test_backend_registered_in_get_backend(self): + """`get_backend("hermes")` returns HermesBackend, not MockBackend.""" + from skillopt_sleep.backend import HermesBackend, get_backend + + for alias in ("hermes", "hermes_chat", "hermes_cli"): + be = get_backend(alias) + self.assertIsInstance(be, HermesBackend, f"alias={alias}") + self.assertEqual(be.name, "hermes") + + def test_command_includes_profile_and_quiet_flags(self): + """The constructed command must include --profile, -Q, -q.""" + from skillopt_sleep.backend import HermesBackend + + be = HermesBackend(timeout=5) + be.hermes_profile = "test-profile" + captured = {} + + def fake_run(cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs.get("env", {}) + + class FakeProc: + stdout = "OK" + stderr = "" + returncode = 0 + + return FakeProc() + + with unittest.mock.patch("subprocess.run", side_effect=fake_run): + be._call("test prompt") + + cmd = captured["cmd"] + self.assertIn("--profile", cmd) + self.assertIn("test-profile", cmd) + self.assertIn("-Q", cmd) + self.assertIn("-q", cmd) + self.assertIn("test prompt", cmd) + self.assertEqual(captured["env"].get("HERMES_NO_COLOR"), "1") + + def test_cli_error_captured_in_last_call_error(self): + """Non-zero exit codes must set last_call_error, not return error text.""" + from skillopt_sleep.backend import HermesBackend + + be = HermesBackend(timeout=5) + + def fake_run(cmd, **kwargs): + class FakeProc: + stdout = "" + stderr = "Error: invalid profile" + returncode = 1 + + return FakeProc() + + with unittest.mock.patch("subprocess.run", side_effect=fake_run): + result = be._call("test prompt") + + self.assertEqual(result, "") + self.assertIn("invalid profile", be.last_call_error) + + def test_output_filters_boilerplate(self): + """CLI notices, warnings, session IDs, and tracebacks are stripped.""" + from skillopt_sleep.backend import HermesBackend + + be = HermesBackend(timeout=5) + + def fake_run(cmd, **kwargs): + class FakeProc: + stdout = ( + "Bitwarden Secrets Manager: applied 1 secret\n" + "Warning: Unknown toolsets: mcp-codegraph\n" + "\n" + "session_id: 20260709_000000_000000\n" + "42\n" + "Exception ignored in:\n" + "Traceback (most recent call last):\n" + " File \"x.py\", line 1, in f\n" + "RuntimeError: loop is closed\n" + ) + stderr = "" + returncode = 0 + + return FakeProc() + + with unittest.mock.patch("subprocess.run", side_effect=fake_run): + result = be._call("what is 6*7?") + + self.assertEqual(result.strip(), "42") + + def test_output_preserves_multiline_response(self): + """Multi-line model responses are preserved after boilerplate filtering.""" + from skillopt_sleep.backend import HermesBackend + + be = HermesBackend(timeout=5) + + def fake_run(cmd, **kwargs): + class FakeProc: + stdout = ( + "Warning: Unknown toolsets: mcp-codegraph, messaging\n" + "\n" + "Line one\n" + "Line two\n" + " indented line three\n" + ) + stderr = "" + returncode = 0 + + return FakeProc() + + with unittest.mock.patch("subprocess.run", side_effect=fake_run): + result = be._call("multi-line test") + + lines = result.strip().split("\n") + self.assertIn("Line one", lines) + self.assertIn("Line two", lines) + self.assertIn(" indented line three", lines) + + def test_hermes_home_overridable(self): + """SKILLOPT_SLEEP_HERMES_PROFILE and HERMES_BIN are read from env.""" + from skillopt_sleep.backend import HermesBackend + + env = { + "HERMES_BIN": "/custom/path/hermes", + "SKILLOPT_SLEEP_HERMES_PROFILE": "pro", + } + with unittest.mock.patch.dict(os.environ, env, clear=False): + be = HermesBackend(timeout=5) + self.assertEqual(be.hermes_bin, "/custom/path/hermes") + self.assertEqual(be.hermes_profile, "pro") + + def test_hermes_home_falls_back_to_default(self): + """Without env overrides, hermes_profile defaults to HERMES_TARGET_PROFILE or 'default'.""" + from skillopt_sleep.backend import HermesBackend + + env = os.environ.copy() + env.pop("HERMES_BIN", None) + env.pop("SKILLOPT_SLEEP_HERMES_PROFILE", None) + env.pop("HERMES_TARGET_PROFILE", None) + + with unittest.mock.patch.dict(os.environ, env, clear=True): + be = HermesBackend(timeout=5) + self.assertEqual(be.hermes_bin, "hermes") + self.assertEqual(be.hermes_profile, "default") + + if __name__ == "__main__": unittest.main(verbosity=2) From 4bc3f22a001363313d30658c1acb05adf20e0145 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabr=C3=ADcio=20Scaglioni?= Date: Thu, 9 Jul 2026 11:07:35 -0300 Subject: [PATCH 2/3] chore: add .codegraph/ to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4b907127..ca1e318f 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,4 @@ docs/让* tests/run_*.sh tests/launch_*.py *.launch.log +.codegraph/ \ No newline at end of file From 41e5868e4eca148e9e06e0558fdd50fd5c2ad46b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabr=C3=ADcio=20Scaglioni?= Date: Thu, 9 Jul 2026 11:21:28 -0300 Subject: [PATCH 3/3] feat(cli): add hermes to --backend and --source choices --- skillopt_sleep/__main__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) mode change 100644 => 100755 skillopt_sleep/__main__.py diff --git a/skillopt_sleep/__main__.py b/skillopt_sleep/__main__.py old mode 100644 new mode 100755 index 608487a2..ffb63340 --- a/skillopt_sleep/__main__.py +++ b/skillopt_sleep/__main__.py @@ -13,8 +13,8 @@ --max-tasks N cap mined tasks per run --target-skill-path PATH explicit live SKILL.md to stage/adopt --tasks-file PATH reviewed TaskRecord JSON file to replay instead of harvesting - --backend mock|claude|codex|copilot - --source claude|codex|auto + --backend mock|claude|codex|copilot|hermes + --source claude|codex|auto|hermes --model NAME --lookback-hours N --auto-adopt @@ -69,12 +69,12 @@ def _report_payload(rep, outcome) -> Dict[str, Any]: def _add_common(p: argparse.ArgumentParser) -> None: p.add_argument("--project", default="") p.add_argument("--scope", default="", choices=["", "all", "invoked"]) - p.add_argument("--backend", default="", choices=["", "mock", "claude", "codex", "copilot"]) + p.add_argument("--backend", default="", choices=["", "mock", "claude", "codex", "copilot", "hermes"]) p.add_argument("--model", default="") p.add_argument("--codex-path", default="", help="path to the real @openai/codex binary") p.add_argument("--claude-home", default="", help="override ~/.claude (also isolates state)") p.add_argument("--codex-home", default="", help="override ~/.codex for archived session harvest") - p.add_argument("--source", default="", choices=["", "claude", "codex", "auto"], + p.add_argument("--source", default="", choices=["", "claude", "codex", "auto", "hermes"], help="session transcript source") p.add_argument("--lookback-hours", type=int, default=None, help="harvest window in hours; 0 = scan full history")