diff --git a/cmd/dotagents/hooks.go b/cmd/dotagents/hooks.go index 5064734..180a705 100644 --- a/cmd/dotagents/hooks.go +++ b/cmd/dotagents/hooks.go @@ -304,7 +304,7 @@ func managedMemoryNativeCommands(home string, root string, cfgs ...config) []str } } for _, commandRoot := range []string{root, filepath.Join(home, ".agents")} { - for _, script := range []string{"basic-session-start.py", "basic-session-end.py", "session-start.sh", "stop.sh", "session-end.sh"} { + for _, script := range []string{"basic-session-start.py", "basic-session-end.py", "session-start.sh", "stop.sh", "session-end.sh", "sync-vault-to-memory.sh", "sync-memory-to-vault.sh"} { add(managedMemoryHookCommand(commandRoot, home, script)) } } diff --git a/cmd/dotagents/hooks_test.go b/cmd/dotagents/hooks_test.go index 1919945..1fb7b17 100644 --- a/cmd/dotagents/hooks_test.go +++ b/cmd/dotagents/hooks_test.go @@ -294,8 +294,11 @@ func TestRemoveNativeManagedMemoryHooksCleansSupportedNativeConfigs(t *testing.T } }`, filepath.Join(home, ".hermes", "config.yaml"): `hooks: + on_session_start: + - command: ~/.agents/memory/hooks/sync-vault-to-memory.sh on_session_finalize: - command: ~/.agents/memory/hooks/session-end.sh + - command: ~/.agents/memory/hooks/sync-memory-to-vault.sh - command: echo hermes `, } diff --git a/cmd/dotagents/setup_scaffold.go b/cmd/dotagents/setup_scaffold.go index 54e9621..1e3ccaf 100644 --- a/cmd/dotagents/setup_scaffold.go +++ b/cmd/dotagents/setup_scaffold.go @@ -23,9 +23,11 @@ const ( ) var managedMemoryHookNames = map[string]struct{}{ - "memory-session-start": {}, - "memory-stop": {}, - "memory-session-end": {}, + "memory-session-start": {}, + "memory-stop": {}, + "memory-session-end": {}, + "memory-hermes-vault-to-memory": {}, + "memory-hermes-memory-to-vault": {}, } type setupIO struct { @@ -229,6 +231,7 @@ func applyMemoryTier(cfg *config, tier string, root string, home string) error { startTargets := memoryHookAgents(*cfg, "SessionStart") stopTargets := memoryHookAgents(*cfg, "Stop") endTargets := memoryHookAgents(*cfg, "SessionEnd") + hermesTargets := enabledAgentNames(*cfg, agentHermes) switch tier { case memoryTierOff: removeManagedMemoryHooks(cfg) @@ -250,8 +253,11 @@ func applyMemoryTier(cfg *config, tier string, root string, home string) error { return fmt.Errorf("configure memsearch: %w", err) } removeManagedMemoryHooks(cfg) - if len(startTargets) > 0 { - cfg.Hooks = append(cfg.Hooks, hookConfig{Name: "memory-session-start", Enabled: true, Event: "SessionStart", Command: managedMemoryHookCommand(root, home, "session-start.sh"), Timeout: 15, Agents: startTargets}) + // session-start.sh delegates to the Claude memsearch plugin, whose + // hookSpecificOutput response is not consumed by Hermes. Hermes imports + // the shared vault into its built-in memory instead. + if targets := withoutAgent(startTargets, agentHermes); len(targets) > 0 { + cfg.Hooks = append(cfg.Hooks, hookConfig{Name: "memory-session-start", Enabled: true, Event: "SessionStart", Command: managedMemoryHookCommand(root, home, "session-start.sh"), Timeout: 15, Agents: targets}) } if len(stopTargets) > 0 { cfg.Hooks = append(cfg.Hooks, hookConfig{Name: "memory-stop", Enabled: true, Event: "Stop", Command: managedMemoryHookCommand(root, home, "stop.sh"), Timeout: 15, Agents: stopTargets}) @@ -259,6 +265,12 @@ func applyMemoryTier(cfg *config, tier string, root string, home string) error { if len(endTargets) > 0 { cfg.Hooks = append(cfg.Hooks, hookConfig{Name: "memory-session-end", Enabled: true, Event: "SessionEnd", Command: managedMemoryHookCommand(root, home, "session-end.sh"), Timeout: 30, Agents: endTargets}) } + if len(hermesTargets) > 0 { + cfg.Hooks = append(cfg.Hooks, + hookConfig{Name: "memory-hermes-vault-to-memory", Enabled: true, Event: "SessionStart", Command: managedMemoryHookCommand(root, home, "sync-vault-to-memory.sh"), Timeout: 30, Agents: hermesTargets}, + hookConfig{Name: "memory-hermes-memory-to-vault", Enabled: true, Event: "SessionEnd", Command: managedMemoryHookCommand(root, home, "sync-memory-to-vault.sh"), Timeout: 30, Agents: hermesTargets}, + ) + } return nil default: return fmt.Errorf("unknown memory tier %q (want off, basic, or memsearch)", tier) @@ -293,6 +305,27 @@ func memoryHookAgents(cfg config, event string) []string { return agents } +func enabledAgentNames(cfg config, name string) []string { + var names []string + for _, agent := range cfg.Agents { + if agent.Enabled && normalizeAgentName(agent.Name) == name { + names = append(names, agent.Name) + } + } + sort.Strings(names) + return names +} + +func withoutAgent(agents []string, excluded string) []string { + filtered := make([]string, 0, len(agents)) + for _, agent := range agents { + if normalizeAgentName(agent) != excluded { + filtered = append(filtered, agent) + } + } + return filtered +} + func normalizeMemoryTier(tier string) string { trimmed := strings.ToLower(strings.TrimSpace(tier)) if trimmed == "" { diff --git a/cmd/dotagents/setup_separation_test.go b/cmd/dotagents/setup_separation_test.go index 145b731..6a754d7 100644 --- a/cmd/dotagents/setup_separation_test.go +++ b/cmd/dotagents/setup_separation_test.go @@ -174,7 +174,7 @@ func TestMemoryTierDefinitionsAndMemsearchDependency(t *testing.T) { if err := applyMemoryTier(&cfg, memoryTierMemsearch, root, home); err != nil { t.Fatal(err) } - if len(cfg.Hooks) != 4 || cfg.Hooks[1].Command != "~/.agents/memory/hooks/session-start.sh" || !stringSlicesEqual(cfg.Hooks[1].Agents, []string{agentClaudeCode, agentCodex, agentDroid, agentHermes}) || cfg.Hooks[2].Command != "~/.agents/memory/hooks/stop.sh" || !stringSlicesEqual(cfg.Hooks[2].Agents, []string{agentClaudeCode, agentCodex, agentDroid}) || cfg.Hooks[3].Command != "~/.agents/memory/hooks/session-end.sh" || !stringSlicesEqual(cfg.Hooks[3].Agents, []string{agentClaudeCode, agentCodex, agentDroid, agentHermes}) { + if len(cfg.Hooks) != 6 || cfg.Hooks[1].Command != "~/.agents/memory/hooks/session-start.sh" || !stringSlicesEqual(cfg.Hooks[1].Agents, []string{agentClaudeCode, agentCodex, agentDroid}) || cfg.Hooks[2].Command != "~/.agents/memory/hooks/stop.sh" || !stringSlicesEqual(cfg.Hooks[2].Agents, []string{agentClaudeCode, agentCodex, agentDroid}) || cfg.Hooks[3].Command != "~/.agents/memory/hooks/session-end.sh" || !stringSlicesEqual(cfg.Hooks[3].Agents, []string{agentClaudeCode, agentCodex, agentDroid, agentHermes}) || cfg.Hooks[4].Name != "memory-hermes-vault-to-memory" || cfg.Hooks[4].Command != "~/.agents/memory/hooks/sync-vault-to-memory.sh" || !stringSlicesEqual(cfg.Hooks[4].Agents, []string{agentHermes}) || cfg.Hooks[5].Name != "memory-hermes-memory-to-vault" || cfg.Hooks[5].Command != "~/.agents/memory/hooks/sync-memory-to-vault.sh" || !stringSlicesEqual(cfg.Hooks[5].Agents, []string{agentHermes}) { t.Fatalf("memsearch hooks = %#v", cfg.Hooks) } } diff --git a/docs/memory.md b/docs/memory.md index 8e1486c..b7e3121 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -12,6 +12,13 @@ Pick during `setup` (`--memory off|basic|memsearch`): | `basic` | appends a bounded digest of each session to `$KNOWLEDGE_DIR/sessions/YYYY-MM-DD.md` and injects recent digests as context at session start | Python 3 | | `memsearch` | full-text indexed search over the knowledge vault | `memsearch` on PATH (`uv tool install memsearch`) | +On Hermes, the `memsearch` tier bridges all three stores rather than replacing +Hermes memory: the vault is imported into `~/.hermes/memories/` at session +start, the finalized session is captured into the vault, and Hermes durable +memory is exported back to the vault at finalize. Hermes continues to use its +built-in memory injector and memory tool; memsearch provides the larger indexed +history. Run `dotagents doctor --e2e` and `hermes hooks doctor` after setup. + Memory data lives in your knowledge directory (default `~/Workspace/knowledge`, configurable via `KNOWLEDGE_DIR`), never in the tool repository. ## Tools diff --git a/memory/lib/sync.py b/memory/lib/sync.py index 48915e0..1af73e8 100755 --- a/memory/lib/sync.py +++ b/memory/lib/sync.py @@ -223,13 +223,14 @@ def rendered_size(entries: list[str]) -> int: else: break - hermes_user_path.write_text( - "\n§\n".join(lines) + "\n", - encoding="utf-8", - ) added = len(lines) - len(existing_entries) + if added: + hermes_user_path.write_text( + "\n§\n".join(lines) + "\n", + encoding="utf-8", + ) print(f"vault->memory: added {added} facts to {hermes_user_path} ({current_size}/{HERMES_USER_LIMIT} chars)") - changed = True + changed = added > 0 else: print(f"vault->memory: {hermes_user_path} up to date") diff --git a/memory/tests/test_sync.py b/memory/tests/test_sync.py new file mode 100644 index 0000000..d861913 --- /dev/null +++ b/memory/tests/test_sync.py @@ -0,0 +1,42 @@ +import importlib.util +import tempfile +import unittest +from pathlib import Path + + +SYNC_PATH = Path(__file__).parents[1] / "lib" / "sync.py" +SPEC = importlib.util.spec_from_file_location("dotagents_memory_sync", SYNC_PATH) +assert SPEC is not None +SYNC = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(SYNC) + + +class VaultToMemoryTests(unittest.TestCase): + def test_full_user_memory_is_not_rewritten_or_reported_changed(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + hermes_user = root / "hermes" / "USER.md" + vault_profile = root / "vault" / "profile" / "USER.md" + hermes_user.parent.mkdir(parents=True) + vault_profile.parent.mkdir(parents=True) + + existing = "x" * (SYNC.HERMES_USER_LIMIT - 1) + "\n" + hermes_user.write_text(existing, encoding="utf-8") + vault_profile.write_text("# Profile\n\n- a distinct durable fact that cannot fit\n", encoding="utf-8") + before_mtime = hermes_user.stat().st_mtime_ns + + changed = SYNC.vault_to_memory( + { + "hermes_user": hermes_user, + "vault_profile": vault_profile, + } + ) + + self.assertFalse(changed) + self.assertEqual(hermes_user.read_text(encoding="utf-8"), existing) + self.assertEqual(hermes_user.stat().st_mtime_ns, before_mtime) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/dotagents/SKILL.md b/skills/dotagents/SKILL.md index c08ae00..4a40a96 100644 --- a/skills/dotagents/SKILL.md +++ b/skills/dotagents/SKILL.md @@ -52,6 +52,18 @@ Memory tiers: - `basic` (default): Python-3-only bounded Markdown digests under `$KNOWLEDGE_DIR/sessions/` plus recent context at session start. - `memsearch`: the indexed SessionStart, Stop, and SessionEnd pipeline; requires `memsearch` on `PATH`. +For Hermes, the `memsearch` tier also keeps the built-in memory files and the +knowledge vault in sync: `sync-vault-to-memory.sh` runs at session start, +`session-end.sh` captures the session digest at finalize, and +`sync-memory-to-vault.sh` exports durable memory at finalize. The generic +`session-start.sh` is not registered for Hermes because its Claude-style +context response is not consumed by Hermes; Hermes injects the synchronized +built-in memory natively instead. + +Verify the complete setup with `dotagents doctor --e2e`, then run +`hermes hooks doctor`. Hermes hook approval is host-local and may require one +interactive approval after setup. + ## status Reports each configured harness as detected or not detected and compares the four managed surfaces with native state: