From 2169e0de5fd762a394026afbd2ec2b6a59ba7f8e Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Mon, 22 Jun 2026 09:24:42 +0000 Subject: [PATCH] =?UTF-8?q?workstation:=20harden=20memory=20hooks=20?= =?UTF-8?q?=E2=80=94=20prune=20dead=20plugin=20refs=20+=20homelab-CLI-only?= =?UTF-8?q?=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../workstation/claude-hooks/auto-learn.py | 75 +++++++------------ .../claude-hooks/wire-memory-hooks.py | 38 ++++++++-- 2 files changed, 58 insertions(+), 55 deletions(-) diff --git a/scripts/workstation/claude-hooks/auto-learn.py b/scripts/workstation/claude-hooks/auto-learn.py index 8460035e..174431f9 100755 --- a/scripts/workstation/claude-hooks/auto-learn.py +++ b/scripts/workstation/claude-hooks/auto-learn.py @@ -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 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. """ @@ -16,14 +17,9 @@ import os import shutil import subprocess import sys -import urllib.error -import urllib.request 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. USER MESSAGE: @@ -53,46 +49,28 @@ Rules: - Return ONLY valid JSON, no other text""" -def _api_request(method: str, path: str, body: dict | None = None) -> dict: - url = f"{API_BASE_URL}{path}" - data = json.dumps(body).encode() if body else None - req = urllib.request.Request( - url, data=data, method=method, - headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}, - ) - with urllib.request.urlopen(req, timeout=15) as resp: - return json.loads(resp.read().decode()) - - -def _store_via_api(content, category, tags, importance, expanded_keywords): - _api_request("POST", "/api/memories", { - "content": content, "category": category, "tags": tags, - "expanded_keywords": expanded_keywords, "importance": importance, - }) - - -def _store_via_sqlite(content, category, tags, importance, expanded_keywords): - import sqlite3 - from datetime import datetime, timezone - - 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 _store_via_homelab_cli(content, category, tags, importance, expanded_keywords): + """Store one memory via the homelab CLI — the only sanctioned memory path on + the devvm (no direct HTTP, no local SQLite). The CLI defaults the API URL and + reads CLAUDE_MEMORY_API_KEY / MEMORY_API_KEY from the environment; if neither + is set (e.g. a user without a minted key) it no-ops silently.""" + homelab = shutil.which("homelab") or "/usr/local/bin/homelab" + if not os.path.exists(homelab): + return + if not (os.environ.get("CLAUDE_MEMORY_API_KEY") or os.environ.get("MEMORY_API_KEY")): + return + cmd = [ + homelab, "memory", "store", content, + "--category", category, + "--tags", tags, + "--importance", str(importance), + ] + if expanded_keywords: + # CLI wants comma-separated keywords; the judge emits space-separated terms. + keywords = ",".join(expanded_keywords.replace(",", " ").split()) + if keywords: + cmd += ["--keywords", keywords] + subprocess.run(cmd, capture_output=True, text=True, timeout=15, env=os.environ) def main() -> None: @@ -197,10 +175,7 @@ def main() -> None: expanded_keywords = event.get("expanded_keywords", "") try: - if API_KEY and API_BASE_URL: - _store_via_api(content, category, tags, importance, expanded_keywords) - else: - _store_via_sqlite(content, category, tags, importance, expanded_keywords) + _store_via_homelab_cli(content, category, tags, importance, expanded_keywords) except Exception: pass # Never crash the async hook diff --git a/scripts/workstation/claude-hooks/wire-memory-hooks.py b/scripts/workstation/claude-hooks/wire-memory-hooks.py index 506b665f..c33b504c 100644 --- a/scripts/workstation/claude-hooks/wire-memory-hooks.py +++ b/scripts/workstation/claude-hooks/wire-memory-hooks.py @@ -1,11 +1,17 @@ #!/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). -Idempotent + ADDITIVE: only ADDS a hook group when no existing command references -that hook script. Never removes/reorders existing hooks, and never touches `env` -(the per-user MEMORY_API_KEY) or any other setting. Safe to run on every reconcile -and on a user's already-populated config. +Two passes, idempotent, never touching `env` (the per-user MEMORY_API_KEY) or any +other setting: + (0) PRUNE any hook command still pointing at the retired claude-memory plugin + (`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 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", {}) 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: groups = hooks.setdefault(event, []) already = any(