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
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue