workstation: harden memory hooks — prune dead plugin refs + homelab-CLI-only store
All checks were successful
ci/woodpecker/push/default Pipeline was successful

wire-memory-hooks.py now PRUNES any settings.json hook still pointing at the
retired claude-memory plugin (plugins/claude-memory/hooks/) before the additive
pass. install_memory() rm -rf's that dir, so those entries are dangling — and a
missing UserPromptSubmit hook exits 2, a BLOCKING error that erases the prompt
and froze emo's sessions (2026-06-22). The plugin shares basenames with the
homelab hooks, so the old additive-only logic saw the dead plugin path as
"already present" and skipped installing the real ~/.claude/hooks/ copy; pruning
first fixes that. Verified against emo's exact original config: yields the
correct 4-hook set, drops the dead PermissionRequest entry, idempotent on rerun.

auto-learn.py now stores via the `homelab memory` CLI only — dropped the direct
HTTP path and the local-SQLite fallback (memory is homelab-CLI-only per Viktor;
never local files). No-ops silently when no API key is in env (e.g. ancamilea).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-06-22 09:24:42 +00:00
parent aeed461591
commit 2169e0de5f
2 changed files with 58 additions and 55 deletions

View file

@ -4,7 +4,8 @@ Stop hook (async): automatic learning extraction via haiku-as-judge.
After each Claude response, sends the user message + assistant response to After each Claude response, sends the user message + assistant response to
haiku to detect corrections, preferences, decisions, or facts worth storing. haiku to detect corrections, preferences, decisions, or facts worth storing.
If learning events are detected, stores them via the memory API (or SQLite fallback). If learning events are detected, stores them via the `homelab memory` CLI the
only sanctioned memory path on the devvm (no direct HTTP, no local SQLite).
Runs with async: true does NOT block the user. Runs with async: true does NOT block the user.
""" """
@ -16,14 +17,9 @@ import os
import shutil import shutil
import subprocess import subprocess
import sys import sys
import urllib.error
import urllib.request
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
API_BASE_URL = os.environ.get("MEMORY_API_URL") or os.environ.get("CLAUDE_MEMORY_API_URL", "")
API_KEY = os.environ.get("MEMORY_API_KEY") or os.environ.get("CLAUDE_MEMORY_API_KEY", "")
JUDGE_PROMPT = """You are a memory extraction judge. Analyze this exchange between a user and an AI assistant. JUDGE_PROMPT = """You are a memory extraction judge. Analyze this exchange between a user and an AI assistant.
USER MESSAGE: USER MESSAGE:
@ -53,46 +49,28 @@ Rules:
- Return ONLY valid JSON, no other text""" - Return ONLY valid JSON, no other text"""
def _api_request(method: str, path: str, body: dict | None = None) -> dict: def _store_via_homelab_cli(content, category, tags, importance, expanded_keywords):
url = f"{API_BASE_URL}{path}" """Store one memory via the homelab CLI — the only sanctioned memory path on
data = json.dumps(body).encode() if body else None the devvm (no direct HTTP, no local SQLite). The CLI defaults the API URL and
req = urllib.request.Request( reads CLAUDE_MEMORY_API_KEY / MEMORY_API_KEY from the environment; if neither
url, data=data, method=method, is set (e.g. a user without a minted key) it no-ops silently."""
headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}, homelab = shutil.which("homelab") or "/usr/local/bin/homelab"
) if not os.path.exists(homelab):
with urllib.request.urlopen(req, timeout=15) as resp: return
return json.loads(resp.read().decode()) if not (os.environ.get("CLAUDE_MEMORY_API_KEY") or os.environ.get("MEMORY_API_KEY")):
return
cmd = [
def _store_via_api(content, category, tags, importance, expanded_keywords): homelab, "memory", "store", content,
_api_request("POST", "/api/memories", { "--category", category,
"content": content, "category": category, "tags": tags, "--tags", tags,
"expanded_keywords": expanded_keywords, "importance": importance, "--importance", str(importance),
}) ]
if expanded_keywords:
# CLI wants comma-separated keywords; the judge emits space-separated terms.
def _store_via_sqlite(content, category, tags, importance, expanded_keywords): keywords = ",".join(expanded_keywords.replace(",", " ").split())
import sqlite3 if keywords:
from datetime import datetime, timezone cmd += ["--keywords", keywords]
subprocess.run(cmd, capture_output=True, text=True, timeout=15, env=os.environ)
memory_home = os.environ.get("MEMORY_HOME", os.path.expanduser("~/.claude/claude-memory"))
db_path = os.path.join(memory_home, "memory", "memory.db")
# Also check legacy path
if not os.path.exists(db_path):
legacy_db = os.path.join(os.path.expanduser("~/.claude/metaclaw"), "memory", "memory.db")
if os.path.exists(legacy_db):
db_path = legacy_db
conn = sqlite3.connect(db_path, timeout=10.0)
conn.execute("PRAGMA journal_mode=WAL")
now = datetime.now(timezone.utc).isoformat()
conn.execute(
"INSERT INTO memories (content, category, tags, importance, expanded_keywords, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(content, category, tags, importance, expanded_keywords, now, now),
)
conn.commit()
conn.close()
def main() -> None: def main() -> None:
@ -197,10 +175,7 @@ def main() -> None:
expanded_keywords = event.get("expanded_keywords", "") expanded_keywords = event.get("expanded_keywords", "")
try: try:
if API_KEY and API_BASE_URL: _store_via_homelab_cli(content, category, tags, importance, expanded_keywords)
_store_via_api(content, category, tags, importance, expanded_keywords)
else:
_store_via_sqlite(content, category, tags, importance, expanded_keywords)
except Exception: except Exception:
pass # Never crash the async hook pass # Never crash the async hook

View file

@ -1,11 +1,17 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Wire the homelab-memory hooks into a user's ~/.claude/settings.json, if-absent. """Wire the homelab-memory hooks into a user's ~/.claude/settings.json.
Part of the claude-memory MCP -> homelab CLI migration (all-users rollout). Part of the claude-memory MCP -> homelab CLI migration (all-users rollout).
Idempotent + ADDITIVE: only ADDS a hook group when no existing command references Two passes, idempotent, never touching `env` (the per-user MEMORY_API_KEY) or any
that hook script. Never removes/reorders existing hooks, and never touches `env` other setting:
(the per-user MEMORY_API_KEY) or any other setting. Safe to run on every reconcile (0) PRUNE any hook command still pointing at the retired claude-memory plugin
and on a user's already-populated config. (`plugins/claude-memory/hooks/`). install_memory() rm -rf's that dir, so
those entries are dangling and a missing UserPromptSubmit hook exits 2,
a BLOCKING error that erases the prompt and freezes the session (devvm emo
incident 2026-06-22). Must run BEFORE the additive pass: the plugin shares
basenames with the homelab hooks, so without pruning, the "already present"
check below matches the dead plugin path and skips the real install.
(1) ADD each homelab hook group when no existing command references its script.
Usage: wire-memory-hooks.py <home_dir> Usage: wire-memory-hooks.py <home_dir>
Exit 0 on success (changed or already-present); 1 only on an unreadable settings file. Exit 0 on success (changed or already-present); 1 only on an unreadable settings file.
@ -38,6 +44,28 @@ except (json.JSONDecodeError, OSError) as e:
hooks = data.setdefault("hooks", {}) hooks = data.setdefault("hooks", {})
changed = False changed = False
# (0) Prune dead claude-memory plugin hooks (see module docstring). Must precede
# the additive pass so shared basenames don't mask a needed install.
DEAD_REF = "plugins/claude-memory/hooks/"
for event in list(hooks.keys()):
new_groups = []
removed_any = False
for g in (hooks.get(event) or []):
original = g.get("hooks") or []
kept = [h for h in original if DEAD_REF not in (h.get("command", "") or "")]
if len(kept) != len(original):
removed_any = True
if kept:
new_groups.append({**g, "hooks": kept})
if removed_any:
changed = True
if new_groups:
hooks[event] = new_groups
else:
del hooks[event]
# (1) Additively wire each homelab hook, if no command already references it.
for event, basename, command, extra in WANT: for event, basename, command, extra in WANT:
groups = hooks.setdefault(event, []) groups = hooks.setdefault(event, [])
already = any( already = any(