From 722f3541f7d9a138ae9860b83a2cd4550dd34f98 Mon Sep 17 00:00:00 2001 From: Grivn Date: Mon, 1 Jun 2026 07:47:56 +0000 Subject: [PATCH] feat(setup): add Hermes Agent hook integration Add Hermes Agent as a setup target using its native shell hook surface. The installer writes a mnemon skill, deploys Hermes-specific hook scripts, and patches ~/.hermes/config.yaml for session start, pre-LLM recall injection, post-response nudges, and optional session finalization guidance. The YAML patcher preserves unrelated Hermes config, replaces prior mnemon hook entries idempotently, and eject removes the owned skill, hooks, config entries, and transient state. Validation: go test ./internal/setup ./cmd, go build -o mnemon ., scripts/check_bilingual_sync.sh, and manual setup/eject smoke checks with an isolated HOME. --- README.md | 12 ++ cmd/setup.go | 133 +++++++++++++- docs/USAGE.md | 5 +- docs/zh/README.md | 11 ++ docs/zh/USAGE.md | 5 +- internal/setup/assets/assets.go | 17 +- internal/setup/assets/hermes/SKILL.md | 48 +++++ internal/setup/assets/hermes/compact.sh | 13 ++ internal/setup/assets/hermes/nudge.sh | 42 +++++ internal/setup/assets/hermes/prime.sh | 31 ++++ internal/setup/assets/hermes/remind.sh | 131 ++++++++++++++ internal/setup/detect.go | 37 +++- internal/setup/hermes.go | 225 ++++++++++++++++++++++++ internal/setup/hermes_test.go | 131 ++++++++++++++ 14 files changed, 829 insertions(+), 12 deletions(-) create mode 100644 internal/setup/assets/hermes/SKILL.md create mode 100644 internal/setup/assets/hermes/compact.sh create mode 100644 internal/setup/assets/hermes/nudge.sh create mode 100644 internal/setup/assets/hermes/prime.sh create mode 100644 internal/setup/assets/hermes/remind.sh create mode 100644 internal/setup/hermes.go create mode 100644 internal/setup/hermes_test.go diff --git a/README.md b/README.md index 328149c7..d9bbdc62 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,18 @@ to `.pi/`. The extension maps Mnemon's lifecycle reminders onto Pi events (`resources_discover`, `before_agent_start`, `agent_end`, `session_before_compact`). Start a new Pi session or run `/reload` to activate. +### [Hermes Agent](https://github.com/NousResearch/hermes-agent) + +```bash +mnemon setup --target hermes --yes +``` + +One command deploys the mnemon skill, prompt files, and Hermes shell hooks to +`~/.hermes/`. The integration uses Hermes' native lifecycle hooks: +`on_session_start`, `pre_llm_call`, `post_llm_call`, and optional +`on_session_finalize`. Hermes may prompt once to approve the installed shell +hooks. + ### [NanoClaw](https://github.com/qwibitai/nanoclaw) NanoClaw runs agents inside Linux containers. Use the `/add-mnemon` skill to integrate: diff --git a/cmd/setup.go b/cmd/setup.go index bd953428..cd79326e 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -24,13 +24,15 @@ var setupCmd = &cobra.Command{ By default, installs to project-local config (.claude/, .codex/, .openclaw/, .nanobot/, .pi/). Use --global to install to user-wide config (~/.claude/, ~/.codex/, ~/.openclaw/, ~/.nanobot/workspace/, ~/.pi/agent/). +Hermes Agent uses its native user config at ~/.hermes/. -Supported environments: Claude Code, Codex, OpenClaw, Nanobot, Pi. +Supported environments: Claude Code, Codex, OpenClaw, Nanobot, Pi, Hermes Agent. Examples: mnemon setup # Interactive: project-local install mnemon setup --global # Interactive: user-wide install mnemon setup --target claude-code # Non-interactive: Claude Code only + mnemon setup --target hermes # Non-interactive: Hermes Agent only mnemon setup --eject # Interactive: remove integrations mnemon setup --eject --target claude-code # Non-interactive: remove Claude Code only mnemon setup --yes # Auto-confirm all prompts`, @@ -38,7 +40,7 @@ Examples: } func init() { - setupCmd.Flags().StringVar(&setupTarget, "target", "", "target environment (claude-code, codex, openclaw, nanobot, pi)") + setupCmd.Flags().StringVar(&setupTarget, "target", "", "target environment (claude-code, codex, openclaw, nanobot, pi, hermes)") setupCmd.Flags().BoolVar(&setupEject, "eject", false, "remove mnemon integrations") setupCmd.Flags().BoolVar(&setupYes, "yes", false, "auto-confirm all prompts (CI-friendly)") setupCmd.Flags().BoolVar(&setupGlobal, "global", false, "install to user-wide config instead of project-local config") @@ -46,8 +48,8 @@ func init() { } func runSetup(cmd *cobra.Command, args []string) error { - if setupTarget != "" && setupTarget != "claude-code" && setupTarget != "codex" && setupTarget != "openclaw" && setupTarget != "nanobot" && setupTarget != "pi" { - return fmt.Errorf("invalid target %q (must be claude-code, codex, openclaw, nanobot, or pi)", setupTarget) + if setupTarget != "" && setupTarget != "claude-code" && setupTarget != "codex" && setupTarget != "openclaw" && setupTarget != "nanobot" && setupTarget != "pi" && setupTarget != "hermes" { + return fmt.Errorf("invalid target %q (must be claude-code, codex, openclaw, nanobot, pi, or hermes)", setupTarget) } envs := setup.DetectEnvironments(setupGlobal) @@ -83,7 +85,7 @@ func runInstallFlow(envs []setup.Environment) error { if len(detected) == 0 { fmt.Println("\nNo supported LLM CLI environments detected.") - fmt.Println("Install Claude Code, Codex, OpenClaw, Nanobot, or Pi, then run 'mnemon setup' again.") + fmt.Println("Install Claude Code, Codex, OpenClaw, Nanobot, Pi, or Hermes Agent, then run 'mnemon setup' again.") return nil } @@ -133,6 +135,8 @@ func installEnv(env *setup.Environment) error { err = installNanobot(env) case "pi": err = installPi(env) + case "hermes": + err = installHermes(env) } if err != nil { return err @@ -606,6 +610,118 @@ func installPi(env *setup.Environment) error { return nil } +// ─── Hermes Agent ─────────────────────────────────────────────────── + +func installHermes(env *setup.Environment) error { + configDir := env.ConfigDir + + fmt.Printf("\nSetting up Hermes Agent (%s)...\n", configDir) + + fmt.Println("\n[1/4] Skill") + if path, err := setup.HermesWriteSkill(configDir); err != nil { + setup.StatusError(0, 0, "Skill", err) + return err + } else { + setup.StatusOK(0, 0, "Skill", path) + } + + fmt.Println("\n[2/4] Prompts") + var promptPath string + if path, err := setup.WritePromptFiles(); err != nil { + setup.StatusError(0, 0, "Prompts", err) + return err + } else { + setup.StatusOK(0, 0, "Prompts", path) + promptPath = path + } + + fmt.Println("\n[3/4] Hooks") + sel := selectHermesOptionalHooks() + if path, err := setup.HermesWriteHook(configDir, "prime.sh", assets.HermesPrimeHook); err != nil { + setup.StatusError(0, 0, "Hook: prime", err) + return err + } else { + setup.StatusOK(0, 0, "Hook: prime", path) + } + if sel.Remind { + if path, err := setup.HermesWriteHook(configDir, "remind.sh", assets.HermesRemindHook); err != nil { + setup.StatusError(0, 0, "Hook: remind", err) + return err + } else { + setup.StatusOK(0, 0, "Hook: remind", path) + } + } + if sel.Nudge { + if path, err := setup.HermesWriteHook(configDir, "nudge.sh", assets.HermesNudgeHook); err != nil { + setup.StatusError(0, 0, "Hook: nudge", err) + return err + } else { + setup.StatusOK(0, 0, "Hook: nudge", path) + } + } + if sel.Compact { + if path, err := setup.HermesWriteHook(configDir, "compact.sh", assets.HermesCompactHook); err != nil { + setup.StatusError(0, 0, "Hook: compact", err) + return err + } else { + setup.StatusOK(0, 0, "Hook: compact", path) + } + } + + fmt.Println("\n[4/4] Config") + if path, err := setup.HermesRegisterHooks(configDir, sel); err != nil { + setup.StatusError(0, 0, "Config", err) + return err + } else { + setup.StatusUpdated(0, 0, "Config", path) + } + + var hookNames []string + hookNames = append(hookNames, "prime") + if sel.Remind { + hookNames = append(hookNames, "remind") + } + if sel.Nudge { + hookNames = append(hookNames, "nudge") + } + if sel.Compact { + hookNames = append(hookNames, "compact") + } + + fmt.Println() + fmt.Println("Setup complete!") + fmt.Printf(" Skill %s/skills/mnemon/SKILL.md\n", configDir) + fmt.Printf(" Hooks %s/config.yaml (%s)\n", configDir, strings.Join(hookNames, ", ")) + fmt.Printf(" Prompts %s/ (guide.md, skill.md)\n", promptPath) + fmt.Println() + fmt.Println("Start a new Hermes session to activate.") + fmt.Println("Hermes may prompt once to approve the installed shell hooks.") + fmt.Println("Run 'mnemon setup --eject --target hermes' to remove.") + + return nil +} + +func selectHermesOptionalHooks() setup.HookSelection { + sel := setup.HookSelection{Remind: true, Nudge: true, Compact: false} + + if setupYes || !setup.IsInteractive() { + return sel + } + + opts := []string{ + "Remind — recall relevant memories before each LLM call (recommended)", + "Nudge — queue remember guidance after each LLM response", + "Compact — queue preservation guidance on session finalization", + } + defs := []bool{true, true, false} + choices := setup.SelectMulti("Select hooks to enable", opts, defs) + + sel.Remind = choices[0] + sel.Nudge = choices[1] + sel.Compact = choices[2] + return sel +} + // ─── Eject ────────────────────────────────────────────────────────── func runEjectFlow(envs []setup.Environment) error { @@ -705,6 +821,13 @@ func ejectEnv(env *setup.Environment) error { if len(errs) > 0 { return errs[0] } + + case "hermes": + errs := setup.HermesEject(env.ConfigDir) + ejectMarkdown("AGENTS.md", "Remove memory guidance from ./AGENTS.md?") + if len(errs) > 0 { + return errs[0] + } } return nil } diff --git a/docs/USAGE.md b/docs/USAGE.md index f52fb1c4..120ea016 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -32,6 +32,7 @@ mnemon setup --target claude-code mnemon setup --target openclaw mnemon setup --target pi mnemon setup --target nanobot --global +mnemon setup --target hermes # Auto-confirm all prompts (CI-friendly) mnemon setup --yes @@ -43,8 +44,8 @@ mnemon setup --eject --target claude-code | Flag | Default | Description | |---|---|---| -| `--global` | `false` | Install to user-wide config instead of project-local (recommended for Nanobot: installs to `~/.nanobot/workspace/`; Pi installs to `~/.pi/agent/`) | -| `--target ` | (auto-detect) | Target environment: `claude-code`, `codex`, `openclaw`, `nanobot`, or `pi` | +| `--global` | `false` | Install to user-wide config instead of project-local (recommended for Nanobot: installs to `~/.nanobot/workspace/`; Pi installs to `~/.pi/agent/`; Hermes installs to `~/.hermes/`) | +| `--target ` | (auto-detect) | Target environment: `claude-code`, `codex`, `openclaw`, `nanobot`, `pi`, or `hermes` | | `--eject` | `false` | Remove mnemon integrations | | `--yes` | `false` | Auto-confirm all prompts | diff --git a/docs/zh/README.md b/docs/zh/README.md index f70626b7..b5f5a78b 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -111,6 +111,17 @@ mnemon setup --target pi --yes (`resources_discover`、`before_agent_start`、`agent_end`、 `session_before_compact`)。启动新的 Pi session 或运行 `/reload` 即可激活。 +### [Hermes Agent](https://github.com/NousResearch/hermes-agent) + +```bash +mnemon setup --target hermes --yes +``` + +一条命令将 mnemon skill、prompt 文件和 Hermes shell hooks 部署到 +`~/.hermes/`。该集成使用 Hermes 原生生命周期 hooks: +`on_session_start`、`pre_llm_call`、`post_llm_call`,以及可选的 +`on_session_finalize`。Hermes 可能会在首次运行时提示批准这些 shell hooks。 + ### [NanoClaw](https://github.com/qwibitai/nanoclaw) NanoClaw 在 Linux 容器内运行智能体。使用 `/add-mnemon` 技能集成: diff --git a/docs/zh/USAGE.md b/docs/zh/USAGE.md index 77b442f7..621dcc91 100644 --- a/docs/zh/USAGE.md +++ b/docs/zh/USAGE.md @@ -33,6 +33,7 @@ mnemon setup --target codex mnemon setup --target openclaw mnemon setup --target pi mnemon setup --target nanobot --global +mnemon setup --target hermes # 自动确认所有提示(CI 友好) mnemon setup --yes @@ -44,8 +45,8 @@ mnemon setup --eject --target claude-code | 标志 | 默认值 | 说明 | |---|---|---| -| `--global` | `false` | 安装到用户级配置而非项目本地(Nanobot 推荐安装到 `~/.nanobot/workspace/`;Pi 安装到 `~/.pi/agent/`) | -| `--target ` | (自动检测) | 目标环境:`claude-code`、`codex`、`openclaw`、`nanobot` 或 `pi` | +| `--global` | `false` | 安装到用户级配置而非项目本地(Nanobot 推荐安装到 `~/.nanobot/workspace/`;Pi 安装到 `~/.pi/agent/`;Hermes 安装到 `~/.hermes/`) | +| `--target ` | (自动检测) | 目标环境:`claude-code`、`codex`、`openclaw`、`nanobot`、`pi` 或 `hermes` | | `--eject` | `false` | 移除 mnemon 集成 | | `--yes` | `false` | 自动确认所有提示 | diff --git a/internal/setup/assets/assets.go b/internal/setup/assets/assets.go index 5227451e..5d9d7d2b 100644 --- a/internal/setup/assets/assets.go +++ b/internal/setup/assets/assets.go @@ -65,7 +65,22 @@ var PiSkill []byte //go:embed pi/mnemon.ts var PiExtension []byte +//go:embed hermes/SKILL.md +var HermesSkill []byte + +//go:embed hermes/prime.sh +var HermesPrimeHook []byte + +//go:embed hermes/remind.sh +var HermesRemindHook []byte + +//go:embed hermes/nudge.sh +var HermesNudgeHook []byte + +//go:embed hermes/compact.sh +var HermesCompactHook []byte + // All returns the embedded filesystem for inspection. // -//go:embed claude codex openclaw nanoclaw nanobot pi +//go:embed claude codex openclaw nanoclaw nanobot pi hermes var All embed.FS diff --git a/internal/setup/assets/hermes/SKILL.md b/internal/setup/assets/hermes/SKILL.md new file mode 100644 index 00000000..5e1a48c7 --- /dev/null +++ b/internal/setup/assets/hermes/SKILL.md @@ -0,0 +1,48 @@ +--- +name: mnemon +description: Persistent memory CLI for Hermes Agent. Store facts, recall past knowledge, link related memories, manage lifecycle. +--- + +# mnemon + +Use `mnemon` when durable memory can materially improve continuity across +Hermes sessions. Hooks may inject recalled context before an LLM call, but the +agent decides what is worth storing. + +## Workflow + +1. Recall when prior decisions, preferences, or facts may affect the current task: + `mnemon recall "" --limit 10` +2. Remember only stable, reusable knowledge: + `mnemon remember "" --cat --imp <1-5> --entities "e1,e2" --source agent` +3. Link related memories after reviewing candidates from `remember`: + `mnemon link --type --weight <0-1>` + +## Commands + +```bash +mnemon remember "" --cat --imp <1-5> --entities "e1,e2" --source agent +mnemon link --type --weight <0-1> [--meta ''] +mnemon recall "" --limit 10 +mnemon search "" --limit 10 +mnemon import --dry-run +mnemon import +mnemon forget +mnemon related --edge causal +mnemon gc --threshold 0.4 +mnemon gc --keep +mnemon status +mnemon log +mnemon store list +mnemon store create +mnemon store set +mnemon store remove +``` + +## Guardrails + +- Do not store secrets, passwords, tokens, private keys, or short-lived operational noise. +- Prefer concise insights over transcript dumps. +- Categories: `preference` · `decision` · `insight` · `fact` · `context` +- Edge types: `temporal` · `semantic` · `causal` · `entity` +- Max 8,000 chars per insight. diff --git a/internal/setup/assets/hermes/compact.sh b/internal/setup/assets/hermes/compact.sh new file mode 100644 index 00000000..b6b2f125 --- /dev/null +++ b/internal/setup/assets/hermes/compact.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# mnemon Hermes on_session_finalize hook. +# Queue compaction guidance for the next pre_llm_call. This preserves Hermes' +# non-blocking lifecycle while keeping memory decisions LLM-supervised. + +STATE_DIR="${HERMES_HOME:-$HOME/.hermes}/mnemon" +mkdir -p "$STATE_DIR" 2>/dev/null || true + +cat > "${STATE_DIR}/pending-nudge.txt" <<'EOF' 2>/dev/null || true +[mnemon] Session finalization occurred. Before relying on compressed or resumed context, preserve only critical continuity with mnemon remember when justified. Do not store transcript dumps. +EOF + +printf '{}\n' diff --git a/internal/setup/assets/hermes/nudge.sh b/internal/setup/assets/hermes/nudge.sh new file mode 100644 index 00000000..a14adf12 --- /dev/null +++ b/internal/setup/assets/hermes/nudge.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# mnemon Hermes post_llm_call hook. +# post_llm_call is observational, so queue a reminder for the next pre_llm_call +# instead of trying to force another model turn. + +INPUT_FILE=$(mktemp) +trap 'rm -f "$INPUT_FILE"' EXIT +cat > "$INPUT_FILE" + +python3 - "$INPUT_FILE" <<'PY' +import json +import os +from pathlib import Path +import sys + +try: + payload = json.loads(Path(sys.argv[1]).read_text() or "{}") +except Exception: + payload = {} + +extra = payload.get("extra") if isinstance(payload.get("extra"), dict) else {} +response = "" +for key in ("response_text", "response", "assistant_message"): + value = extra.get(key) + if isinstance(value, str): + response = value + break + +if "mnemon" not in response.lower() and "durable memory" not in response.lower(): + hermes_home = Path(os.environ.get("HERMES_HOME") or Path.home() / ".hermes") + state_dir = hermes_home / "mnemon" + try: + state_dir.mkdir(parents=True, exist_ok=True) + (state_dir / "pending-nudge.txt").write_text( + "[mnemon] Consider whether the previous exchange warrants durable memory. " + "Use mnemon remember only for stable decisions, preferences, facts, or insights.\n" + ) + except Exception: + pass + +print("{}") +PY diff --git a/internal/setup/assets/hermes/prime.sh b/internal/setup/assets/hermes/prime.sh new file mode 100644 index 00000000..9b9d474f --- /dev/null +++ b/internal/setup/assets/hermes/prime.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# mnemon Hermes on_session_start hook. +# Hermes shell hooks return JSON. Session hooks are observational in Hermes, so +# this records lightweight state for the next pre_llm_call injection. + +STATE_DIR="${HERMES_HOME:-$HOME/.hermes}/mnemon" +mkdir -p "$STATE_DIR" 2>/dev/null || true + +PROMPT_DIR="${MNEMON_DATA_DIR:-$HOME/.mnemon}/prompt" +if [ ! -f "${PROMPT_DIR}/guide.md" ] && [ -f "${HOME}/.mnemon/prompt/guide.md" ]; then + PROMPT_DIR="${HOME}/.mnemon/prompt" +fi + +{ + echo "[mnemon] Memory lifecycle is active for Hermes." + if command -v mnemon >/dev/null 2>&1; then + STATS=$(mnemon status 2>/dev/null || true) + if [ -n "$STATS" ]; then + INSIGHTS=$(echo "$STATS" | sed -n 's/.*"total_insights": *\([0-9]*\).*/\1/p' | head -1) + EDGES=$(echo "$STATS" | sed -n 's/.*"edge_count": *\([0-9]*\).*/\1/p' | head -1) + echo "[mnemon] Memory active (${INSIGHTS:-0} insights, ${EDGES:-0} edges)." + else + echo "[mnemon] Memory active." + fi + else + echo "[mnemon] Warning: mnemon not found in PATH." + fi + [ -f "${PROMPT_DIR}/guide.md" ] && cat "${PROMPT_DIR}/guide.md" +} > "${STATE_DIR}/prime-context.txt" 2>/dev/null || true + +printf '{}\n' diff --git a/internal/setup/assets/hermes/remind.sh b/internal/setup/assets/hermes/remind.sh new file mode 100644 index 00000000..0ddac32b --- /dev/null +++ b/internal/setup/assets/hermes/remind.sh @@ -0,0 +1,131 @@ +#!/bin/bash +# mnemon Hermes pre_llm_call hook. +# Reads Hermes' JSON payload on stdin and returns {"context": "..."} for +# injection into the next LLM call. + +INPUT_FILE=$(mktemp) +trap 'rm -f "$INPUT_FILE"' EXIT +cat > "$INPUT_FILE" + +python3 - "$INPUT_FILE" <<'PY' +import json +import os +import subprocess +import sys +from pathlib import Path + +payload_path = Path(sys.argv[1]) +try: + payload = json.loads(payload_path.read_text() or "{}") +except Exception: + payload = {} + +extra = payload.get("extra") if isinstance(payload.get("extra"), dict) else {} + + +def first_text(value): + if isinstance(value, str): + return value.strip() + if isinstance(value, list): + parts = [] + for item in value: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict): + text = item.get("text") or item.get("content") + if isinstance(text, str): + parts.append(text) + return "\n".join(parts).strip() + return "" + + +def current_query(): + for key in ("user_message", "message", "prompt", "input"): + text = first_text(extra.get(key)) + if text: + return text + history = extra.get("conversation_history") or extra.get("messages") + if isinstance(history, list): + for msg in reversed(history): + if not isinstance(msg, dict): + continue + if msg.get("role") == "user": + text = first_text(msg.get("content")) + if text: + return text + return "" + + +def read_state_file(name): + hermes_home = Path(os.environ.get("HERMES_HOME") or Path.home() / ".hermes") + path = hermes_home / "mnemon" / name + try: + text = path.read_text().strip() + path.unlink(missing_ok=True) + return text + except Exception: + return "" + + +def recall(query): + if not query: + return "" + try: + result = subprocess.run( + ["mnemon", "recall", query, "--limit", "5"], + capture_output=True, + text=True, + timeout=8, + check=False, + ) + except FileNotFoundError: + return "[mnemon] Warning: mnemon not found in PATH." + except Exception: + return "" + if result.returncode != 0 or not result.stdout.strip(): + return "" + try: + data = json.loads(result.stdout) + except Exception: + return result.stdout.strip()[:4000] + hits = data.get("results") if isinstance(data, dict) else None + if not hits: + return "" + lines = ["[mnemon recall] Relevant durable memories:"] + for hit in hits[:5]: + if not isinstance(hit, dict): + continue + content = (hit.get("content") or "").strip() + if not content: + insight = hit.get("insight") + if isinstance(insight, dict): + content = (insight.get("content") or "").strip() + if not content: + continue + cat = hit.get("category") or "" + score = hit.get("score") + prefix = f"- [{cat}] " if cat else "- " + if isinstance(score, (int, float)): + prefix = f"- [{cat} score={score:.3f}] " if cat else f"- [score={score:.3f}] " + lines.append(prefix + content) + return "\n".join(lines) if len(lines) > 1 else "" + + +parts = [] +prime = read_state_file("prime-context.txt") +if prime: + parts.append(prime) + +nudge = read_state_file("pending-nudge.txt") +if nudge: + parts.append(nudge) + +query = current_query() +recalled = recall(query) +if recalled: + parts.append(recalled) + +parts.append("[mnemon] Evaluate: recall needed? After responding, evaluate: remember needed?") + +print(json.dumps({"context": "\n\n".join(p for p in parts if p).strip()})) +PY diff --git a/internal/setup/detect.go b/internal/setup/detect.go index 9a0838c1..13138970 100644 --- a/internal/setup/detect.go +++ b/internal/setup/detect.go @@ -9,8 +9,8 @@ import ( // Environment describes a detected LLM CLI environment. type Environment struct { - Name string // "claude-code", "codex", "openclaw", "nanobot", "pi" - Display string // "Claude Code", "Codex", "OpenClaw", "Nanobot", "Pi" + Name string // "claude-code", "codex", "openclaw", "nanobot", "pi", "hermes" + Display string // "Claude Code", "Codex", "OpenClaw", "Nanobot", "Pi", "Hermes Agent" Detected bool // CLI binary or global config dir found BinPath string // exec.LookPath result Installed bool // mnemon integration present at ConfigDir @@ -34,6 +34,7 @@ func DetectEnvironments(global bool) []Environment { detectOpenClaw(global), detectNanobot(global), detectPi(global), + detectHermes(), } } @@ -244,3 +245,35 @@ func detectPi(global bool) Environment { return env } + +func detectHermes() Environment { + home := HomeDir() + configDir := filepath.Join(home, ".hermes") + + env := Environment{ + Name: "hermes", + Display: "Hermes Agent", + ConfigDir: configDir, + } + + if binPath, err := exec.LookPath("hermes"); err == nil { + env.Detected = true + env.BinPath = binPath + } + if _, err := os.Stat(configDir); err == nil { + env.Detected = true + } + + skillPath := filepath.Join(configDir, "skills", "mnemon", "SKILL.md") + if _, err := os.Stat(skillPath); err == nil { + env.Installed = true + } + + if env.BinPath != "" { + if out, err := exec.Command(env.BinPath, "--version").Output(); err == nil { + env.Version = cleanVersion(strings.TrimSpace(string(out))) + } + } + + return env +} diff --git a/internal/setup/hermes.go b/internal/setup/hermes.go new file mode 100644 index 00000000..fbca6f3c --- /dev/null +++ b/internal/setup/hermes.go @@ -0,0 +1,225 @@ +package setup + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + + "github.com/mnemon-dev/mnemon/internal/setup/assets" + "go.yaml.in/yaml/v3" +) + +// HermesWriteSkill writes the mnemon skill to the Hermes skills directory. +func HermesWriteSkill(configDir string) (string, error) { + skillDir := filepath.Join(configDir, "skills", "mnemon") + skillPath := filepath.Join(skillDir, "SKILL.md") + if err := os.MkdirAll(skillDir, 0755); err != nil { + return "", err + } + if err := os.WriteFile(skillPath, assets.HermesSkill, 0644); err != nil { + return "", err + } + return skillPath, nil +} + +// HermesWriteHook writes a shell hook script to the Hermes agent-hooks directory. +func HermesWriteHook(configDir, filename string, content []byte) (string, error) { + hooksDir := filepath.Join(configDir, "agent-hooks", "mnemon") + if err := os.MkdirAll(hooksDir, 0755); err != nil { + return "", err + } + hookPath := filepath.Join(hooksDir, filename) + if err := os.WriteFile(hookPath, content, 0755); err != nil { + return "", err + } + return hookPath, nil +} + +// HermesRegisterHooks registers selected Mnemon lifecycle hooks in config.yaml. +func HermesRegisterHooks(configDir string, sel HookSelection) (string, error) { + hooksDir := filepath.Join(configDir, "agent-hooks", "mnemon") + absHooksDir, err := filepath.Abs(hooksDir) + if err != nil { + return "", err + } + + configPath := filepath.Join(configDir, "config.yaml") + data, err := readYAMLFile(configPath) + if err != nil { + return "", err + } + addHermesHooks(data, absHooksDir, sel) + if err := writeYAMLFile(configPath, data); err != nil { + return "", err + } + return configPath, nil +} + +// HermesEject removes mnemon integration from the given Hermes config dir. +func HermesEject(configDir string) []error { + var errs []error + + fmt.Printf("\nRemoving Hermes Agent integration (%s)...\n", configDir) + + hooksDir := filepath.Join(configDir, "agent-hooks", "mnemon") + if err := os.RemoveAll(hooksDir); err != nil { + StatusError(1, 4, "Hooks", err) + errs = append(errs, err) + } else { + StatusOK(1, 4, "Hooks", hooksDir+" removed") + } + removeIfEmpty(filepath.Join(configDir, "agent-hooks")) + + configPath := filepath.Join(configDir, "config.yaml") + data, err := readYAMLFile(configPath) + if err != nil { + StatusError(2, 4, "Config", err) + errs = append(errs, err) + } else { + removeHermesHooks(data) + if err := writeOrRemoveYAMLFile(configPath, data); err != nil { + StatusError(2, 4, "Config", err) + errs = append(errs, err) + } else { + StatusOK(2, 4, "Config", configPath+" cleaned") + } + } + + skillDir := filepath.Join(configDir, "skills", "mnemon") + if err := os.RemoveAll(skillDir); err != nil { + StatusError(3, 4, "Skill", err) + errs = append(errs, err) + } else { + StatusOK(3, 4, "Skill", skillDir+" removed") + } + removeIfEmpty(filepath.Join(configDir, "skills")) + + stateDir := filepath.Join(configDir, "mnemon") + if err := os.RemoveAll(stateDir); err != nil { + StatusError(4, 4, "State", err) + errs = append(errs, err) + } else { + StatusOK(4, 4, "State", stateDir+" removed") + } + removeIfEmpty(configDir) + + return errs +} + +func readYAMLFile(path string) (map[string]any, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return make(map[string]any), nil + } + return nil, err + } + if len(bytes.TrimSpace(data)) == 0 { + return make(map[string]any), nil + } + + var out map[string]any + if err := yaml.Unmarshal(data, &out); err != nil { + return nil, err + } + if out == nil { + out = make(map[string]any) + } + return out, nil +} + +func writeYAMLFile(path string, data map[string]any) error { + out, err := yaml.Marshal(data) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + mode := os.FileMode(0600) + if info, err := os.Stat(path); err == nil { + mode = info.Mode().Perm() + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, out, mode); err != nil { + return err + } + return os.Rename(tmp, path) +} + +func writeOrRemoveYAMLFile(path string, data map[string]any) error { + if len(data) == 0 { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil + } + return writeYAMLFile(path, data) +} + +func addHermesHooks(data map[string]any, hooksDir string, sel HookSelection) { + removeHermesHooks(data) + hooks := ensureAnyMap(data, "hooks") + + appendHermesHook(hooks, "on_session_start", filepath.Join(hooksDir, "prime.sh"), 10) + if sel.Remind { + appendHermesHook(hooks, "pre_llm_call", filepath.Join(hooksDir, "remind.sh"), 10) + } + if sel.Nudge { + appendHermesHook(hooks, "post_llm_call", filepath.Join(hooksDir, "nudge.sh"), 10) + } + if sel.Compact { + appendHermesHook(hooks, "on_session_finalize", filepath.Join(hooksDir, "compact.sh"), 30) + } +} + +func removeHermesHooks(data map[string]any) { + hooks, ok := data["hooks"].(map[string]any) + if !ok { + return + } + for _, event := range []string{"on_session_start", "pre_llm_call", "post_llm_call", "on_session_finalize"} { + raw, ok := hooks[event] + if !ok { + continue + } + entries, ok := raw.([]any) + if !ok { + continue + } + kept := make([]any, 0, len(entries)) + for _, entry := range entries { + if !containsMnemon(entry) { + kept = append(kept, entry) + } + } + if len(kept) == 0 { + delete(hooks, event) + } else { + hooks[event] = kept + } + } + if len(hooks) == 0 { + delete(data, "hooks") + } +} + +func ensureAnyMap(data map[string]any, key string) map[string]any { + existing, ok := data[key].(map[string]any) + if ok { + return existing + } + next := make(map[string]any) + data[key] = next + return next +} + +func appendHermesHook(hooks map[string]any, event, command string, timeout int) { + entry := map[string]any{ + "command": command, + "timeout": timeout, + } + arr, _ := hooks[event].([]any) + hooks[event] = append(arr, entry) +} diff --git a/internal/setup/hermes_test.go b/internal/setup/hermes_test.go new file mode 100644 index 00000000..943e2470 --- /dev/null +++ b/internal/setup/hermes_test.go @@ -0,0 +1,131 @@ +package setup + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mnemon-dev/mnemon/internal/setup/assets" +) + +func TestHermesWriteSkillAndHooks(t *testing.T) { + dir := t.TempDir() + + skillPath, err := HermesWriteSkill(dir) + if err != nil { + t.Fatalf("write skill: %v", err) + } + if skillPath != filepath.Join(dir, "skills", "mnemon", "SKILL.md") { + t.Fatalf("skill path = %q", skillPath) + } + if data, err := os.ReadFile(skillPath); err != nil { + t.Fatalf("read skill: %v", err) + } else if !strings.Contains(string(data), "name: mnemon") { + t.Fatalf("unexpected skill content: %q", string(data)) + } + + hookPath, err := HermesWriteHook(dir, "remind.sh", assets.HermesRemindHook) + if err != nil { + t.Fatalf("write hook: %v", err) + } + if hookPath != filepath.Join(dir, "agent-hooks", "mnemon", "remind.sh") { + t.Fatalf("hook path = %q", hookPath) + } + info, err := os.Stat(hookPath) + if err != nil { + t.Fatalf("stat hook: %v", err) + } + if info.Mode().Perm() != 0755 { + t.Fatalf("hook permissions = %v, want 0755", info.Mode().Perm()) + } +} + +func TestHermesRegisterHooksPreservesUnrelatedConfig(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.yaml") + input := []byte(`model: + provider: openrouter +hooks: + pre_llm_call: + - command: /custom/inject.sh + timeout: 3 + post_llm_call: + - command: /old/mnemon/nudge.sh + timeout: 2 +`) + if err := os.WriteFile(configPath, input, 0644); err != nil { + t.Fatalf("write fixture: %v", err) + } + + if _, err := HermesRegisterHooks(dir, HookSelection{Remind: true, Nudge: true, Compact: true}); err != nil { + t.Fatalf("register hooks: %v", err) + } + + data, err := readYAMLFile(configPath) + if err != nil { + t.Fatalf("read yaml: %v", err) + } + model := data["model"].(map[string]any) + if model["provider"] != "openrouter" { + t.Fatalf("lost unrelated model config: %#v", data) + } + + hooks := data["hooks"].(map[string]any) + pre := hooks["pre_llm_call"].([]any) + if len(pre) != 2 { + t.Fatalf("expected custom pre_llm hook plus mnemon hook: %#v", pre) + } + if !containsMnemon(pre[1]) { + t.Fatalf("missing mnemon pre_llm hook: %#v", pre) + } + post := hooks["post_llm_call"].([]any) + if len(post) != 1 || !containsMnemon(post[0]) { + t.Fatalf("expected old mnemon hook replaced with new one: %#v", post) + } + if _, ok := hooks["on_session_finalize"]; !ok { + t.Fatalf("compact hook should be registered: %#v", hooks) + } +} + +func TestHermesEjectRemovesOnlyMnemonFilesAndHooks(t *testing.T) { + dir := t.TempDir() + if _, err := HermesWriteSkill(dir); err != nil { + t.Fatalf("write skill: %v", err) + } + if _, err := HermesWriteHook(dir, "remind.sh", assets.HermesRemindHook); err != nil { + t.Fatalf("write hook: %v", err) + } + configPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(configPath, []byte(`hooks: + pre_llm_call: + - command: /custom/inject.sh + - command: /old/mnemon/remind.sh +`), 0644); err != nil { + t.Fatalf("write config: %v", err) + } + + errs := HermesEject(dir) + if len(errs) > 0 { + t.Fatalf("eject errors: %v", errs) + } + if _, err := os.Stat(filepath.Join(dir, "skills", "mnemon")); !os.IsNotExist(err) { + t.Fatalf("mnemon skill should be removed, err=%v", err) + } + if _, err := os.Stat(filepath.Join(dir, "agent-hooks", "mnemon")); !os.IsNotExist(err) { + t.Fatalf("mnemon hooks should be removed, err=%v", err) + } + if _, err := os.Stat(filepath.Join(dir, "mnemon")); !os.IsNotExist(err) { + t.Fatalf("mnemon state should be removed, err=%v", err) + } + + data, err := readYAMLFile(configPath) + if err != nil { + t.Fatalf("read yaml: %v", err) + } + hooks := data["hooks"].(map[string]any) + pre := hooks["pre_llm_call"].([]any) + if len(pre) != 1 || containsMnemon(pre[0]) { + t.Fatalf("custom hook should be preserved and mnemon removed: %#v", pre) + } +}