workstation: harden memory hooks — prune dead plugin refs + homelab-CLI-only store
All checks were successful
ci/woodpecker/push/default Pipeline was successful
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:
parent
aeed461591
commit
2169e0de5f
2 changed files with 58 additions and 55 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <home_dir>
|
||||
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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue