Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/dotagents/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
Expand Down
3 changes: 3 additions & 0 deletions cmd/dotagents/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
`,
}
Expand Down
43 changes: 38 additions & 5 deletions cmd/dotagents/setup_scaffold.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -250,15 +253,24 @@ 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})
}
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},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Create the Hermes memory directory before vault import

When memsearch is enabled for a fresh Hermes installation, or whenever ~/.hermes/memories/ has not been initialized, this newly registered SessionStart hook calls vault_to_memory; if the vault profile contains an importable fact, its Path.write_text to ~/.hermes/memories/USER.md raises FileNotFoundError because neither setup nor the function creates the parent directory. The first-session bridge therefore fails instead of importing the facts; create the directory before writing and cover this fresh-profile failure mode.

AGENTS.md reference: AGENTS.md:L46-L46

Useful? React with 👍 / 👎.

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)
Expand Down Expand Up @@ -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 == "" {
Expand Down
2 changes: 1 addition & 1 deletion cmd/dotagents/setup_separation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
7 changes: 7 additions & 0 deletions docs/memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 6 additions & 5 deletions memory/lib/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
42 changes: 42 additions & 0 deletions memory/tests/test_sync.py
Original file line number Diff line number Diff line change
@@ -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()
12 changes: 12 additions & 0 deletions skills/dotagents/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading