Skip to content

Commit 0a620df

Browse files
authored
fix(claude-code): centralize working memory hook
Centralize Claude Code Working Memory read-hook logic in a shared script, preserve fallback behavior when the plugin root is missing, harden WSL nmem.cmd bridging, and add regression coverage. Verified locally with direct hook E2E, Claude Code live E2E, plugin E2E static gate, Codex plugin validator, Hermes tests, sh -n, jq validation, and git diff --check.
1 parent e3406bd commit 0a620df

7 files changed

Lines changed: 272 additions & 4 deletions

File tree

integrations.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"name": "Claude Code",
99
"category": "coding",
1010
"type": "plugin",
11-
"version": "0.7.8",
11+
"version": "0.7.9",
1212
"directory": "nowledge-mem-claude-code-plugin",
1313
"transport": "cli",
1414
"capabilities": {

nowledge-mem-claude-code-plugin/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "nowledge-mem",
33
"description": "Personal knowledge graph for Claude Code \u2014 remembers decisions, searches past work, captures sessions",
4-
"version": "0.7.8",
4+
"version": "0.7.9",
55
"author": {
66
"name": "Nowledge Labs",
77
"email": "hello@nowledge-labs.ai",

nowledge-mem-claude-code-plugin/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ All notable changes to the Nowledge Mem Claude Code plugin will be documented in
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.7.9] - 2026-05-12
9+
10+
### Changed
11+
12+
- SessionStart and compact Working Memory reload now share one hook script instead of duplicating the same shell pipeline in `hooks.json`. Behavior is unchanged: project-space Working Memory is tried first when a space resolves, then default-space `nmem wm read`, then `~/ai-now/memory.md`.
13+
- Added regression coverage for the read hook's git-space resolution, `NMEM_SPACE` override, default-space fallback, and local file fallback.
14+
- The `~/ai-now/memory.md` fallback remains available even if Claude Code launches a hook without `CLAUDE_PLUGIN_ROOT`.
15+
816
## [0.7.8] - 2026-05-07
917

1018
### Changed

nowledge-mem-claude-code-plugin/hooks/hooks.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"hooks": [
88
{
99
"type": "command",
10-
"command": "command -v nmem >/dev/null 2>&1 || { command -v nmem.cmd >/dev/null 2>&1 && nmem(){ local q=''; for a in \"$@\"; do q=\"$q \\\"$a\\\"\"; done; cmd.exe /s /c \"\\\"nmem.cmd\\\"$q\"; }; }; PY=\"$(command -v python3 || command -v python || true)\"; SPACE=\"${NMEM_SPACE:-$(basename \"$(cd \"$(dirname \"$(git rev-parse --git-common-dir 2>/dev/null)\")\" 2>/dev/null && pwd)\" 2>/dev/null | tr '[:upper:]' '[:lower:]')}\"; { [ -n \"$PY\" ] && [ -n \"$SPACE\" ] && nmem --json wm read --space \"$SPACE\" 2>/dev/null | \"$PY\" -c \"import sys,json;d=json.load(sys.stdin);c=d.get('content','');print(c) if d.get('exists') and c else sys.exit(1)\" 2>/dev/null; } || { [ -n \"$PY\" ] && nmem --json wm read 2>/dev/null | \"$PY\" -c \"import sys,json;d=json.load(sys.stdin);c=d.get('content','');print(c) if c else sys.exit(1)\" 2>/dev/null; } || cat ~/ai-now/memory.md 2>/dev/null || true"
10+
"command": "SCRIPT=\"${CLAUDE_PLUGIN_ROOT:-}/scripts/nmem-hook-read.sh\"; { [ -f \"$SCRIPT\" ] && sh \"$SCRIPT\"; } || cat \"$HOME/ai-now/memory.md\" 2>/dev/null || true"
1111
}
1212
]
1313
},
@@ -16,7 +16,7 @@
1616
"hooks": [
1717
{
1818
"type": "command",
19-
"command": "command -v nmem >/dev/null 2>&1 || { command -v nmem.cmd >/dev/null 2>&1 && nmem(){ local q=''; for a in \"$@\"; do q=\"$q \\\"$a\\\"\"; done; cmd.exe /s /c \"\\\"nmem.cmd\\\"$q\"; }; }; PY=\"$(command -v python3 || command -v python || true)\"; SPACE=\"${NMEM_SPACE:-$(basename \"$(cd \"$(dirname \"$(git rev-parse --git-common-dir 2>/dev/null)\")\" 2>/dev/null && pwd)\" 2>/dev/null | tr '[:upper:]' '[:lower:]')}\"; { { [ -n \"$PY\" ] && [ -n \"$SPACE\" ] && nmem --json wm read --space \"$SPACE\" 2>/dev/null | \"$PY\" -c \"import sys,json;d=json.load(sys.stdin);c=d.get('content','');print(c) if d.get('exists') and c else sys.exit(1)\" 2>/dev/null; } || { [ -n \"$PY\" ] && nmem --json wm read 2>/dev/null | \"$PY\" -c \"import sys,json;d=json.load(sys.stdin);c=d.get('content','');print(c) if c else sys.exit(1)\" 2>/dev/null; } || cat ~/ai-now/memory.md 2>/dev/null || true; } && printf '\\n---\\nContext was compacted. If you discovered important insights, save them before continuing:\\n nmem m add \"<insight>\" --title \"<short title>\" --importance 0.8\\n'"
19+
"command": "SCRIPT=\"${CLAUDE_PLUGIN_ROOT:-}/scripts/nmem-hook-read.sh\"; { { [ -f \"$SCRIPT\" ] && sh \"$SCRIPT\"; } || cat \"$HOME/ai-now/memory.md\" 2>/dev/null || true; } && printf '\\n---\\nContext was compacted. If you discovered important insights, save them before continuing:\\n nmem m add \"<insight>\" --title \"<short title>\" --importance 0.8\\n'"
2020
}
2121
]
2222
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
#!/bin/sh
2+
# Best-effort Working Memory injection for Claude Code lifecycle hooks.
3+
4+
if ! command -v nmem >/dev/null 2>&1; then
5+
if command -v nmem.cmd >/dev/null 2>&1; then
6+
escape_cmd_arg() {
7+
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
8+
}
9+
10+
nmem() {
11+
q=""
12+
for a in "$@"; do
13+
q="$q \"$(escape_cmd_arg "$a")\""
14+
done
15+
cmd.exe /s /c "\"nmem.cmd\"$q"
16+
}
17+
fi
18+
fi
19+
20+
PY="$(command -v python3 2>/dev/null || command -v python 2>/dev/null || true)"
21+
22+
resolve_space() {
23+
if [ -n "${NMEM_SPACE:-}" ]; then
24+
printf '%s\n' "$NMEM_SPACE"
25+
return 0
26+
fi
27+
28+
common_dir="$(git rev-parse --git-common-dir 2>/dev/null)" || return 0
29+
[ -n "$common_dir" ] || return 0
30+
31+
case "$common_dir" in
32+
/*) common_path="$common_dir" ;;
33+
*) common_path="$(pwd -P)/$common_dir" ;;
34+
esac
35+
36+
common_parent="$(cd "$(dirname "$common_path")" 2>/dev/null && pwd -P)" || return 0
37+
basename "$common_parent" 2>/dev/null | tr '[:upper:]' '[:lower:]'
38+
}
39+
40+
SPACE="$(resolve_space)"
41+
42+
parse_existing_space_wm='import sys,json; d=json.load(sys.stdin); c=d.get("content",""); print(c) if d.get("exists") and c else sys.exit(1)'
43+
parse_default_wm='import sys,json; d=json.load(sys.stdin); c=d.get("content",""); print(c) if c else sys.exit(1)'
44+
45+
if command -v nmem >/dev/null 2>&1 && [ -n "$PY" ]; then
46+
if [ -n "$SPACE" ] \
47+
&& nmem --json wm read --space "$SPACE" 2>/dev/null \
48+
| "$PY" -c "$parse_existing_space_wm" 2>/dev/null; then
49+
exit 0
50+
fi
51+
52+
if nmem --json wm read 2>/dev/null \
53+
| "$PY" -c "$parse_default_wm" 2>/dev/null; then
54+
exit 0
55+
fi
56+
fi
57+
58+
cat "$HOME/ai-now/memory.md" 2>/dev/null || true
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import os
2+
import subprocess
3+
from pathlib import Path
4+
5+
6+
SCRIPT_PATH = Path(__file__).parent.parent / "scripts" / "nmem-hook-read.sh"
7+
8+
9+
def _write_fake_nmem(bin_dir: Path, body: str) -> Path:
10+
fake_nmem = bin_dir / "nmem"
11+
fake_nmem.write_text("#!/bin/sh\n" + body, encoding="utf-8")
12+
fake_nmem.chmod(0o755)
13+
return fake_nmem
14+
15+
16+
def _run_hook(tmp_path: Path, *, cwd: Path, env: dict[str, str]) -> subprocess.CompletedProcess[str]:
17+
hook_env = os.environ.copy()
18+
hook_env.update(env)
19+
hook_env["HOME"] = str(tmp_path / "home")
20+
(Path(hook_env["HOME"]) / "ai-now").mkdir(parents=True, exist_ok=True)
21+
return subprocess.run(
22+
["/bin/sh", str(SCRIPT_PATH)],
23+
cwd=str(cwd),
24+
env=hook_env,
25+
text=True,
26+
capture_output=True,
27+
timeout=15,
28+
)
29+
30+
31+
def test_read_hook_prefers_git_common_dir_space(tmp_path):
32+
bin_dir = tmp_path / "bin"
33+
bin_dir.mkdir()
34+
calls = tmp_path / "calls.log"
35+
_write_fake_nmem(
36+
bin_dir,
37+
f"""
38+
printf '%s\\n' "$*" >> "{calls}"
39+
case "$*" in
40+
*"--space examplerepo"*) printf '%s\\n' '{{"exists": true, "content": "space briefing"}}' ;;
41+
*) printf '%s\\n' '{{"exists": true, "content": "default briefing"}}' ;;
42+
esac
43+
""",
44+
)
45+
project = tmp_path / "ExampleRepo"
46+
subdir = project / "subdir"
47+
subdir.mkdir(parents=True)
48+
subprocess.run(["git", "init", "-q"], cwd=str(project), check=True)
49+
50+
result = _run_hook(
51+
tmp_path,
52+
cwd=subdir,
53+
env={"PATH": f"{bin_dir}:{os.environ['PATH']}", "NMEM_SPACE": ""},
54+
)
55+
56+
assert result.returncode == 0
57+
assert result.stdout.strip() == "space briefing"
58+
assert "--space examplerepo" in calls.read_text(encoding="utf-8")
59+
60+
61+
def test_read_hook_honors_nmem_space_override(tmp_path):
62+
bin_dir = tmp_path / "bin"
63+
bin_dir.mkdir()
64+
calls = tmp_path / "calls.log"
65+
_write_fake_nmem(
66+
bin_dir,
67+
f"""
68+
printf '%s\\n' "$*" >> "{calls}"
69+
case "$*" in
70+
*"--space Research Lane"*) printf '%s\\n' '{{"exists": true, "content": "env briefing"}}' ;;
71+
*) printf '%s\\n' '{{"exists": true, "content": "default briefing"}}' ;;
72+
esac
73+
""",
74+
)
75+
76+
result = _run_hook(
77+
tmp_path,
78+
cwd=tmp_path,
79+
env={"PATH": f"{bin_dir}:{os.environ['PATH']}", "NMEM_SPACE": "Research Lane"},
80+
)
81+
82+
assert result.returncode == 0
83+
assert result.stdout.strip() == "env briefing"
84+
assert "--space Research Lane" in calls.read_text(encoding="utf-8")
85+
86+
87+
def test_read_hook_falls_back_to_default_space_when_project_space_empty(tmp_path):
88+
bin_dir = tmp_path / "bin"
89+
bin_dir.mkdir()
90+
_write_fake_nmem(
91+
bin_dir,
92+
"""
93+
case "$*" in
94+
*"--space "*) printf '%s\\n' '{"exists": false, "content": ""}' ;;
95+
*) printf '%s\\n' '{"exists": true, "content": "default briefing"}' ;;
96+
esac
97+
""",
98+
)
99+
project = tmp_path / "repo"
100+
project.mkdir()
101+
subprocess.run(["git", "init", "-q"], cwd=str(project), check=True)
102+
103+
result = _run_hook(
104+
tmp_path,
105+
cwd=project,
106+
env={"PATH": f"{bin_dir}:{os.environ['PATH']}", "NMEM_SPACE": ""},
107+
)
108+
109+
assert result.returncode == 0
110+
assert result.stdout.strip() == "default briefing"
111+
112+
113+
def test_read_hook_falls_back_to_local_memory_file_without_nmem(tmp_path):
114+
memory_file = tmp_path / "home" / "ai-now" / "memory.md"
115+
memory_file.parent.mkdir(parents=True)
116+
memory_file.write_text("file briefing\n", encoding="utf-8")
117+
bin_dir = tmp_path / "no-nmem-bin"
118+
bin_dir.mkdir()
119+
(bin_dir / "cat").symlink_to("/bin/cat")
120+
121+
result = _run_hook(
122+
tmp_path,
123+
cwd=tmp_path,
124+
env={"PATH": str(bin_dir), "NMEM_SPACE": ""},
125+
)
126+
127+
assert result.returncode == 0
128+
assert result.stdout.strip() == "file briefing"
129+
130+
131+
def test_read_hook_invokes_windows_nmem_cmd_by_command_name(tmp_path):
132+
bin_dir = tmp_path / "bin"
133+
bin_dir.mkdir()
134+
calls = tmp_path / "cmd.log"
135+
136+
nmem_cmd = bin_dir / "nmem.cmd"
137+
nmem_cmd.write_text("", encoding="utf-8")
138+
nmem_cmd.chmod(0o755)
139+
140+
cmd_exe = bin_dir / "cmd.exe"
141+
cmd_exe.write_text(
142+
f"""#!/bin/sh
143+
printf '%s\\n' "$*" > "{calls}"
144+
case "$*" in
145+
*"nmem.cmd"*) printf '%s\\n' '{{"exists": true, "content": "cmd briefing"}}' ;;
146+
*) exit 1 ;;
147+
esac
148+
""",
149+
encoding="utf-8",
150+
)
151+
cmd_exe.chmod(0o755)
152+
153+
result = _run_hook(
154+
tmp_path,
155+
cwd=tmp_path,
156+
env={"PATH": f"{bin_dir}:/bin:/usr/bin", "NMEM_SPACE": 'project"2024'},
157+
)
158+
159+
assert result.returncode == 0
160+
assert result.stdout.strip() == "cmd briefing"
161+
command = calls.read_text(encoding="utf-8")
162+
assert '"nmem.cmd" "--json" "wm" "read"' in command
163+
assert '"project\\"2024"' in command

tests/plugin_e2e/test_key_plugins_e2e.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,10 @@ def test_key_plugin_static_contracts_are_declared():
195195
claude_hooks = _read_json(CLAUDE_PLUGIN / "hooks" / "hooks.json")["hooks"]
196196
assert claude_manifest["name"] == "nowledge-mem"
197197
assert {"SessionStart", "UserPromptSubmit", "PreCompact", "Stop"} <= set(claude_hooks)
198+
assert "nmem-hook-read.sh" in json.dumps(claude_hooks)
198199
assert "nmem-hook-save.py" in json.dumps(claude_hooks)
200+
assert "wm read" not in json.dumps(claude_hooks)
201+
assert (CLAUDE_PLUGIN / "scripts" / "nmem-hook-read.sh").exists()
199202
assert (CLAUDE_PLUGIN / "skills" / "save-thread" / "SKILL.md").exists()
200203

201204
codex_manifest = _read_json(CODEX_PLUGIN / ".codex-plugin" / "plugin.json")
@@ -243,6 +246,42 @@ def test_key_plugin_static_contracts_are_declared():
243246
assert "nowledge_mem_save_thread" in opencode_source
244247

245248

249+
def test_claude_read_hooks_keep_file_fallback_without_plugin_root(tmp_path):
250+
hooks = _read_json(CLAUDE_PLUGIN / "hooks" / "hooks.json")["hooks"]
251+
home = tmp_path / "home"
252+
memory_file = home / "ai-now" / "memory.md"
253+
memory_file.parent.mkdir(parents=True)
254+
memory_file.write_text("fallback briefing\n", encoding="utf-8")
255+
256+
env = {
257+
"HOME": str(home),
258+
"PATH": "/bin:/usr/bin",
259+
"CLAUDE_PLUGIN_ROOT": "",
260+
}
261+
startup_command = hooks["SessionStart"][0]["hooks"][0]["command"]
262+
startup = subprocess.run(
263+
["/bin/sh", "-c", startup_command],
264+
env=env,
265+
text=True,
266+
capture_output=True,
267+
timeout=15,
268+
)
269+
assert startup.returncode == 0
270+
assert startup.stdout.strip() == "fallback briefing"
271+
272+
compact_command = hooks["SessionStart"][1]["hooks"][0]["command"]
273+
compact = subprocess.run(
274+
["/bin/sh", "-c", compact_command],
275+
env=env,
276+
text=True,
277+
capture_output=True,
278+
timeout=15,
279+
)
280+
assert compact.returncode == 0
281+
assert "fallback briefing" in compact.stdout
282+
assert "Context was compacted" in compact.stdout
283+
284+
246285
def test_key_plugin_credentials_stay_out_of_static_runtime_urls():
247286
hermes_client = (HERMES_PLUGIN / "client.py").read_text(encoding="utf-8").lower()
248287
openclaw_client = (OPENCLAW_PLUGIN / "src" / "client.js").read_text(encoding="utf-8").lower()

0 commit comments

Comments
 (0)