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>
184 lines
6.5 KiB
Python
Executable file
184 lines
6.5 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""
|
|
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 `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.
|
|
"""
|
|
|
|
import io
|
|
import json
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
JUDGE_PROMPT = """You are a memory extraction judge. Analyze this exchange between a user and an AI assistant.
|
|
|
|
USER MESSAGE:
|
|
{user_message}
|
|
|
|
ASSISTANT RESPONSE:
|
|
{assistant_response}
|
|
|
|
Your job: determine if any of these learning events occurred:
|
|
1. USER CORRECTION — user corrected the assistant's mistake or misunderstanding
|
|
2. PREFERENCE — user stated a preference, habit, or "I like/prefer/want" statement
|
|
3. DECISION — a decision was reached about how to do something
|
|
4. FACT — user shared a durable fact about themselves, their team, tools, or environment
|
|
|
|
If ANY learning event occurred, return JSON:
|
|
{{"events": [{{"type": "correction|preference|decision|fact", "content": "concise fact to remember (one sentence)", "importance": 0.7, "expanded_keywords": "space-separated semantically related search terms for recall (minimum 5 words)", "supersedes": null}}]}}
|
|
|
|
If NO learning event occurred, return:
|
|
{{"events": []}}
|
|
|
|
Rules:
|
|
- Only extract DURABLE facts, not transient task details
|
|
- Corrections are highest value (0.8-0.9)
|
|
- Be conservative — false negatives are better than false positives
|
|
- "expanded_keywords" should include synonyms, related concepts, and adjacent topics that would help find this memory later
|
|
- "supersedes" should be a search query to find the old outdated memory, or null
|
|
- Return ONLY valid JSON, no other text"""
|
|
|
|
|
|
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:
|
|
# Graceful exit if claude CLI is not available
|
|
if not shutil.which("claude"):
|
|
return
|
|
|
|
try:
|
|
hook_input = json.load(sys.stdin)
|
|
except (json.JSONDecodeError, EOFError):
|
|
return
|
|
|
|
if isinstance(hook_input, dict) and hook_input.get("stop_hook_active", False):
|
|
return
|
|
|
|
transcript_path = ""
|
|
if isinstance(hook_input, dict):
|
|
transcript_path = hook_input.get("transcript_path", "")
|
|
|
|
if not transcript_path or not os.path.exists(transcript_path):
|
|
return
|
|
|
|
user_message = ""
|
|
assistant_response = ""
|
|
try:
|
|
MAX_TAIL_BYTES = 50_000
|
|
with open(transcript_path, "rb") as f:
|
|
f.seek(0, io.SEEK_END)
|
|
size = f.tell()
|
|
f.seek(max(0, size - MAX_TAIL_BYTES))
|
|
tail = f.read().decode("utf-8", errors="replace")
|
|
lines = tail.split("\n")
|
|
|
|
for line in reversed(lines):
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
entry = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
role = entry.get("role", "")
|
|
content = entry.get("content", "")
|
|
if isinstance(content, list):
|
|
content = " ".join(
|
|
b.get("text", "") for b in content
|
|
if isinstance(b, dict) and b.get("type") == "text"
|
|
)
|
|
content = str(content)[:2000]
|
|
if role == "assistant" and not assistant_response:
|
|
assistant_response = content
|
|
elif role == "user" and not user_message:
|
|
user_message = content
|
|
if user_message and assistant_response:
|
|
break
|
|
except Exception:
|
|
return
|
|
|
|
if not user_message or len(user_message.strip()) < 10:
|
|
return
|
|
|
|
prompt = JUDGE_PROMPT.format(
|
|
user_message=user_message,
|
|
assistant_response=assistant_response[:1000],
|
|
)
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
["claude", "-p", prompt, "--model", "haiku"],
|
|
capture_output=True, text=True, timeout=30,
|
|
env={**os.environ, "CLAUDECODE": ""},
|
|
)
|
|
if result.returncode != 0:
|
|
return
|
|
response_text = result.stdout.strip()
|
|
if response_text.startswith("```"):
|
|
lines = response_text.split("\n")
|
|
lines = [l for l in lines if not l.strip().startswith("```")]
|
|
response_text = "\n".join(lines).strip()
|
|
judge_result = json.loads(response_text)
|
|
events = judge_result.get("events", [])
|
|
if not events:
|
|
return
|
|
except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError):
|
|
return
|
|
|
|
category_map = {
|
|
"correction": "preferences",
|
|
"preference": "preferences",
|
|
"decision": "decisions",
|
|
"fact": "facts",
|
|
}
|
|
|
|
for event in events:
|
|
content = event.get("content", "")
|
|
if not content:
|
|
continue
|
|
event_type = event.get("type", "fact")
|
|
importance = max(0.0, min(1.0, float(event.get("importance", 0.7))))
|
|
category = category_map.get(event_type, "facts")
|
|
tags = f"auto-learned,{event_type}"
|
|
expanded_keywords = event.get("expanded_keywords", "")
|
|
|
|
try:
|
|
_store_via_homelab_cli(content, category, tags, importance, expanded_keywords)
|
|
except Exception:
|
|
pass # Never crash the async hook
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|