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) + } +}