Skip to content

Commit 2f86dee

Browse files
committed
fix: update hooks to nested format for Claude Code schema compatibility
Migrate hook installer from the deprecated flat format to the current nested schema (matcher group -> hooks array -> handler objects). Add legacy format detection and auto-migration so existing users upgrading do not end up with duplicate or broken entries.
1 parent 94f29dc commit 2f86dee

File tree

3 files changed

+184
-29
lines changed

3 files changed

+184
-29
lines changed

README.md

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Uses the `mem0ai` package directly as a library, supports both Claude's OAT toke
1515

1616
Python >= 3.10 and [uv](https://docs.astral.sh/uv/getting-started/installation/).
1717

18-
> **Authentication:** The default setup uses Claude (Anthropic) as the LLM for fact extraction. No API key needed the server automatically uses your Claude Code session token. For fully local setups, set `MEM0_PROVIDER=ollama`. See [Authentication](#authentication) for advanced options.
18+
> **Authentication:** The default setup uses Claude (Anthropic) as the LLM for fact extraction. No API key needed, the server automatically uses your Claude Code session token. For fully local setups, set `MEM0_PROVIDER=ollama`. See [Authentication](#authentication) for advanced options.
1919
2020
## Quick Start
2121

@@ -31,9 +31,9 @@ claude mcp add --scope user --transport stdio mem0 \
3131

3232
All defaults work out of the box: Qdrant on `localhost:6333`, Ollama embeddings on `localhost:11434` with `bge-m3` (1024 dims). Override any default via `--env` (see [Configuration](#configuration)).
3333

34-
`uvx` automatically downloads, installs, and runs the server in an isolated environment no manual installation needed. Claude Code launches it on demand when the MCP connection starts.
34+
`uvx` automatically downloads, installs, and runs the server in an isolated environment, no manual installation needed. Claude Code launches it on demand when the MCP connection starts.
3535

36-
The server auto-reads your OAT token from `~/.claude/.credentials.json` no manual token configuration needed.
36+
The server auto-reads your OAT token from `~/.claude/.credentials.json`, no manual token configuration needed.
3737

3838
### Fully Local (Ollama)
3939

@@ -84,21 +84,21 @@ Add these rules to your project's `CLAUDE.md` (or `~/.claude/CLAUDE.md` for glob
8484
```markdown
8585
# MCP Servers
8686

87-
- **mem0**: Persistent memory across sessions. At the start of each session, `search_memories` for relevant context before asking the user to re-explain anything. Use `add_memory` whenever you discover project architecture, coding conventions, debugging insights, key decisions, or user preferences. Use `update_memory` when prior context changes. Save information like: "This project uses PostgreSQL with Prisma", "Tests run with pytest -v", "Auth uses JWT validated in middleware". When in doubt, save it future sessions benefit from over-remembering.
87+
- **mem0**: Persistent memory across sessions. At the start of each session, `search_memories` for relevant context before asking the user to re-explain anything. Use `add_memory` whenever you discover project architecture, coding conventions, debugging insights, key decisions, or user preferences. Use `update_memory` when prior context changes. Save information like: "This project uses PostgreSQL with Prisma", "Tests run with pytest -v", "Auth uses JWT validated in middleware". When in doubt, save it, future sessions benefit from over-remembering.
8888
```
8989

90-
This gives Claude Code behavioral instructions to actively search and save memories during the session. For best results, combine with [Claude Code Hooks](#claude-code-hooks) the CLAUDE.md rules tell Claude *how to use* memory tools mid-session, while hooks handle the *automatic* injection and saving at session boundaries.
90+
This gives Claude Code behavioral instructions to actively search and save memories during the session. For best results, combine with [Claude Code Hooks](#claude-code-hooks), the CLAUDE.md rules tell Claude *how to use* memory tools mid-session, while hooks handle the *automatic* injection and saving at session boundaries.
9191

9292
## Claude Code Hooks
9393

94-
Session hooks automate memory at session boundaries injecting memories on startup and saving summaries on exit. This happens automatically without manual tool calls.
94+
Session hooks automate memory at session boundaries, injecting memories on startup and saving summaries on exit. This happens automatically without manual tool calls.
9595

9696
| Hook | Event | What it does |
9797
|------|-------|--------------|
9898
| `mem0-hook-context` | SessionStart (`startup`, `compact`) | Searches mem0 for project-relevant memories and injects them as `additionalContext` |
9999
| `mem0-hook-stop` | Stop | Reads the last ~3 user/assistant exchanges from the transcript and saves a summary to mem0 via `infer=True` |
100100

101-
Both hooks are non-fatal if mem0 is unreachable or any error occurs, Claude Code continues normally.
101+
Both hooks are non-fatal, if mem0 is unreachable or any error occurs, Claude Code continues normally.
102102

103103
### Install
104104

@@ -114,7 +114,7 @@ Or install globally (all projects):
114114
mem0-install-hooks --global
115115
```
116116

117-
This adds the hook entries to `.claude/settings.json`. The installer is idempotent running it twice won't create duplicates.
117+
This adds the hook entries to `.claude/settings.json`. The installer is idempotent, running it twice won't create duplicates.
118118

119119
### How it works
120120

@@ -136,10 +136,10 @@ Hooks and CLAUDE.md are complementary layers that work best together:
136136

137137
| Layer | Role | When |
138138
|-------|------|------|
139-
| **Hooks** | Automated data flow injects stored memories on startup, saves session summaries on exit | Session boundaries (start/stop) |
140-
| **CLAUDE.md** | Behavioral instructions tells Claude to actively search and save memories during the session | Throughout the session |
139+
| **Hooks** | Automated data flow, injects stored memories on startup, saves session summaries on exit | Session boundaries (start/stop) |
140+
| **CLAUDE.md** | Behavioral instructions, tells Claude to actively search and save memories during the session | Throughout the session |
141141

142-
Hooks alone give you passive recall (memories appear at startup) and passive saving (summaries saved at exit). CLAUDE.md instructions add active mid-session behavior Claude searches for relevant memories when encountering new topics, and saves important discoveries immediately rather than waiting for session end.
142+
Hooks alone give you passive recall (memories appear at startup) and passive saving (summaries saved at exit). CLAUDE.md instructions add active mid-session behavior, Claude searches for relevant memories when encountering new topics, and saves important discoveries immediately rather than waiting for session end.
143143

144144
For the best experience, use both. Hooks ensure memories flow in and out automatically at session boundaries, while CLAUDE.md ensures Claude actively engages with memory tools during the session.
145145

@@ -154,9 +154,9 @@ The server resolves an Anthropic token using a prioritized fallback chain:
154154
| 3 | `ANTHROPIC_API_KEY` env var | Standard pay-per-use API key |
155155
| 4 | Disabled | Warns and disables Anthropic LLM features |
156156

157-
**In Claude Code, priority 2 always wins** the credentials file exists as long as you're logged in. This means `ANTHROPIC_API_KEY` (priority 3) is never reached. To override the OAT token in Claude Code, use `MEM0_ANTHROPIC_TOKEN` (priority 1). `ANTHROPIC_API_KEY` is only useful for non-Claude-Code deployments (Docker, CI, standalone).
157+
**In Claude Code, priority 2 always wins**, the credentials file exists as long as you're logged in. This means `ANTHROPIC_API_KEY` (priority 3) is never reached. To override the OAT token in Claude Code, use `MEM0_ANTHROPIC_TOKEN` (priority 1). `ANTHROPIC_API_KEY` is only useful for non-Claude-Code deployments (Docker, CI, standalone).
158158

159-
**OAT tokens** (`sk-ant-oat...`) use your Claude subscription. The server automatically detects the token type and configures the SDK accordingly. OAT tokens are automatically refreshed before expiry: the server proactively checks the token lifetime and refreshes via the Anthropic OAuth endpoint when nearing expiry (default: 30 minutes). On authentication failures, a 3-step defensive strategy kicks inpiggybacking on Claude Code's credentials file, self-refreshing via OAuth, and wait-and-retry so long-running sessions survive token rotation seamlessly.
159+
**OAT tokens** (`sk-ant-oat...`) use your Claude subscription. The server automatically detects the token type and configures the SDK accordingly. OAT tokens are automatically refreshed before expiry: the server proactively checks the token lifetime and refreshes via the Anthropic OAuth endpoint when nearing expiry (default: 30 minutes). On authentication failures, a 3-step defensive strategy kicks in, piggybacking on Claude Code's credentials file, self-refreshing via OAuth, and wait-and-retry, so long-running sessions survive token rotation seamlessly.
160160

161161
**API keys** (`sk-ant-api...`) use standard pay-per-use billing.
162162

src/mem0_mcp_selfhosted/hooks.py

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -286,8 +286,63 @@ def stop_main() -> None:
286286

287287

288288
def _has_hook(hooks_list: list, command: str) -> bool:
289-
"""Check if a hook with the given command already exists."""
290-
return any(isinstance(h, dict) and h.get("command") == command for h in hooks_list)
289+
"""Check if a hook with the given command already exists.
290+
291+
Searches both the current nested format and the legacy flat format::
292+
293+
Nested: [{"matcher": "...", "hooks": [{"type": "command", "command": "..."}]}]
294+
Legacy: [{"matcher": "...", "command": "..."}]
295+
"""
296+
for group in hooks_list:
297+
if not isinstance(group, dict):
298+
continue
299+
# Current nested format
300+
for handler in group.get("hooks") or []:
301+
if isinstance(handler, dict) and handler.get("command") == command:
302+
return True
303+
# Legacy flat format (pre-nested schema)
304+
if group.get("command") == command:
305+
return True
306+
return False
307+
308+
309+
_HANDLER_KEYS = {"command", "timeout"}
310+
_GROUP_KEYS = {"matcher"}
311+
312+
313+
def _migrate_legacy_hooks(hooks_list: list) -> list:
314+
"""Convert legacy flat-format hooks to the nested format.
315+
316+
Flat entries (``{"command": "...", "timeout": ...}``) are converted to
317+
nested format (``{"hooks": [{"type": "command", ...}]}``). Already-nested
318+
entries are kept as-is. Non-dict entries are discarded. Unknown keys are
319+
forwarded to preserve any extra properties the user may have set.
320+
"""
321+
migrated = []
322+
for group in hooks_list:
323+
if not isinstance(group, dict):
324+
continue
325+
if "hooks" in group:
326+
# Already in nested format
327+
migrated.append(group)
328+
elif "command" in group:
329+
# Legacy flat format — convert, forwarding unknown keys to
330+
# group level so no user data is silently dropped.
331+
handler: dict = {"type": "command"}
332+
new_group: dict = {}
333+
for k, v in group.items():
334+
if k in _HANDLER_KEYS:
335+
handler[k] = v
336+
elif k in _GROUP_KEYS:
337+
new_group[k] = v
338+
else:
339+
new_group[k] = v
340+
new_group["hooks"] = [handler]
341+
migrated.append(new_group)
342+
else:
343+
# Unknown format — preserve as-is
344+
migrated.append(group)
345+
return migrated
291346

292347

293348
def install_main() -> None:
@@ -338,6 +393,12 @@ def install_main() -> None:
338393
settings["hooks"] = {}
339394

340395
hooks = settings["hooks"]
396+
397+
# Migrate any legacy flat-format hooks to nested format
398+
for event_key in ("SessionStart", "Stop"):
399+
if isinstance(hooks.get(event_key), list):
400+
hooks[event_key] = _migrate_legacy_hooks(hooks[event_key])
401+
341402
installed: list[str] = []
342403
skipped: list[str] = []
343404

@@ -349,8 +410,11 @@ def install_main() -> None:
349410
else:
350411
hooks["SessionStart"].append({
351412
"matcher": "startup|compact",
352-
"command": _HOOK_CONTEXT_CMD,
353-
"timeout": 15000,
413+
"hooks": [{
414+
"type": "command",
415+
"command": _HOOK_CONTEXT_CMD,
416+
"timeout": 15000,
417+
}],
354418
})
355419
installed.append(f"SessionStart ({_HOOK_CONTEXT_CMD})")
356420

@@ -361,8 +425,11 @@ def install_main() -> None:
361425
skipped.append(f"Stop ({_HOOK_STOP_CMD})")
362426
else:
363427
hooks["Stop"].append({
364-
"command": _HOOK_STOP_CMD,
365-
"timeout": 30000,
428+
"hooks": [{
429+
"type": "command",
430+
"command": _HOOK_STOP_CMD,
431+
"timeout": 30000,
432+
}],
366433
})
367434
installed.append(f"Stop ({_HOOK_STOP_CMD})")
368435

tests/unit/test_hooks.py

Lines changed: 98 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -526,7 +526,7 @@ def test_mem_add_raises_returns_nonfatal(self, tmp_path):
526526

527527
class TestInstallMain:
528528
def test_fresh_install(self, tmp_path):
529-
"""Fresh install creates settings.json with both hook entries."""
529+
"""Fresh install creates settings.json with both hook entries in nested format."""
530530
project_dir = tmp_path / "myproject"
531531
project_dir.mkdir()
532532

@@ -538,13 +538,23 @@ def test_fresh_install(self, tmp_path):
538538

539539
settings = json.loads(settings_path.read_text())
540540
assert "hooks" in settings
541+
542+
# SessionStart: matcher group with nested hooks array
541543
assert len(settings["hooks"]["SessionStart"]) == 1
542-
assert settings["hooks"]["SessionStart"][0]["command"] == "mem0-hook-context"
543-
assert settings["hooks"]["SessionStart"][0]["matcher"] == "startup|compact"
544-
assert settings["hooks"]["SessionStart"][0]["timeout"] == 15000
544+
ss_group = settings["hooks"]["SessionStart"][0]
545+
assert ss_group["matcher"] == "startup|compact"
546+
assert len(ss_group["hooks"]) == 1
547+
assert ss_group["hooks"][0]["type"] == "command"
548+
assert ss_group["hooks"][0]["command"] == "mem0-hook-context"
549+
assert ss_group["hooks"][0]["timeout"] == 15000
550+
551+
# Stop: matcher group with nested hooks array
545552
assert len(settings["hooks"]["Stop"]) == 1
546-
assert settings["hooks"]["Stop"][0]["command"] == "mem0-hook-stop"
547-
assert settings["hooks"]["Stop"][0]["timeout"] == 30000
553+
stop_group = settings["hooks"]["Stop"][0]
554+
assert len(stop_group["hooks"]) == 1
555+
assert stop_group["hooks"][0]["type"] == "command"
556+
assert stop_group["hooks"][0]["command"] == "mem0-hook-stop"
557+
assert stop_group["hooks"][0]["timeout"] == 30000
548558

549559
def test_idempotent_reinstall(self, tmp_path, capsys):
550560
"""Running install twice doesn't create duplicate entries."""
@@ -635,8 +645,8 @@ def test_existing_hooks_with_different_commands_not_matched(self, tmp_path):
635645

636646
existing = {
637647
"hooks": {
638-
"SessionStart": [{"command": "other-hook", "timeout": 5000}],
639-
"Stop": [{"command": "another-stop-hook", "timeout": 10000}],
648+
"SessionStart": [{"hooks": [{"type": "command", "command": "other-hook", "timeout": 5000}]}],
649+
"Stop": [{"hooks": [{"type": "command", "command": "another-stop-hook", "timeout": 10000}]}],
640650
}
641651
}
642652
(claude_dir / "settings.json").write_text(json.dumps(existing))
@@ -645,10 +655,15 @@ def test_existing_hooks_with_different_commands_not_matched(self, tmp_path):
645655
hooks.install_main()
646656

647657
settings = json.loads((claude_dir / "settings.json").read_text())
648-
# Both the original and new hooks should be present
658+
# Both the original and new matcher groups should be present
649659
assert len(settings["hooks"]["SessionStart"]) == 2
650660
assert len(settings["hooks"]["Stop"]) == 2
651-
commands = [h["command"] for h in settings["hooks"]["SessionStart"]]
661+
# Extract commands from nested hooks arrays
662+
commands = [
663+
handler["command"]
664+
for group in settings["hooks"]["SessionStart"]
665+
for handler in group.get("hooks", [])
666+
]
652667
assert "other-hook" in commands
653668
assert "mem0-hook-context" in commands
654669

@@ -694,3 +709,76 @@ def test_nonexistent_project_dir_exits_with_error(self, tmp_path, capsys):
694709
assert exc_info.value.code == 1
695710
captured = capsys.readouterr()
696711
assert "does not exist" in captured.err
712+
713+
def test_legacy_flat_format_migrated_on_reinstall(self, tmp_path, capsys):
714+
"""Old flat-format hooks are migrated to nested format without duplicates."""
715+
project_dir = tmp_path / "proj"
716+
claude_dir = project_dir / ".claude"
717+
claude_dir.mkdir(parents=True)
718+
719+
# Old flat format from previous package version
720+
existing = {
721+
"hooks": {
722+
"SessionStart": [{"command": "mem0-hook-context", "matcher": "startup|compact", "timeout": 15000}],
723+
"Stop": [{"command": "mem0-hook-stop", "timeout": 30000}],
724+
}
725+
}
726+
(claude_dir / "settings.json").write_text(json.dumps(existing))
727+
728+
with patch("sys.argv", ["mem0-install-hooks", "--project-dir", str(project_dir)]):
729+
hooks.install_main()
730+
731+
settings = json.loads((claude_dir / "settings.json").read_text())
732+
733+
# Should have exactly 1 entry per event (migrated, not duplicated)
734+
assert len(settings["hooks"]["SessionStart"]) == 1
735+
assert len(settings["hooks"]["Stop"]) == 1
736+
737+
# Migrated to nested format
738+
ss = settings["hooks"]["SessionStart"][0]
739+
assert ss["matcher"] == "startup|compact"
740+
assert ss["hooks"][0]["type"] == "command"
741+
assert ss["hooks"][0]["command"] == "mem0-hook-context"
742+
assert ss["hooks"][0]["timeout"] == 15000
743+
744+
stop = settings["hooks"]["Stop"][0]
745+
assert stop["hooks"][0]["type"] == "command"
746+
assert stop["hooks"][0]["command"] == "mem0-hook-stop"
747+
assert stop["hooks"][0]["timeout"] == 30000
748+
749+
captured = capsys.readouterr()
750+
assert "Already installed" in captured.out
751+
752+
def test_legacy_mixed_with_other_hooks_preserved(self, tmp_path):
753+
"""Migration preserves non-mem0 hooks alongside legacy mem0 hooks."""
754+
project_dir = tmp_path / "proj"
755+
claude_dir = project_dir / ".claude"
756+
claude_dir.mkdir(parents=True)
757+
758+
existing = {
759+
"hooks": {
760+
"SessionStart": [
761+
{"command": "other-hook", "timeout": 5000},
762+
{"command": "mem0-hook-context", "matcher": "startup|compact", "timeout": 15000},
763+
],
764+
}
765+
}
766+
(claude_dir / "settings.json").write_text(json.dumps(existing))
767+
768+
with patch("sys.argv", ["mem0-install-hooks", "--project-dir", str(project_dir)]):
769+
hooks.install_main()
770+
771+
settings = json.loads((claude_dir / "settings.json").read_text())
772+
# Both hooks migrated, no duplicates for mem0-hook-context
773+
assert len(settings["hooks"]["SessionStart"]) == 2
774+
commands = [
775+
handler["command"]
776+
for group in settings["hooks"]["SessionStart"]
777+
for handler in group.get("hooks", [])
778+
]
779+
assert "other-hook" in commands
780+
assert "mem0-hook-context" in commands
781+
782+
# Stop hook auto-installed since it wasn't in the original settings
783+
assert len(settings["hooks"]["Stop"]) == 1
784+
assert settings["hooks"]["Stop"][0]["hooks"][0]["command"] == "mem0-hook-stop"

0 commit comments

Comments
 (0)