Add Claude Code plugin scaffold for single-repo install
Consolidates plugin components (hooks, commands, skills, MCP config) into this repo so it can be installed with: claude plugins install github:ViktorBarzin/claude-memory-mcp - .claude-plugin/plugin.json: manifest with all hook events - mcp/memory-mcp.json: MCP server config using existing src/ - hooks/: compaction survival, auto-recall, auto-learn, auto-approve - commands/: /remember and /recall slash commands - skills/: memory-management skill - Bump MCP server to v2.0.0 with metaclaw migration fallback - Update README with quick install and plugin hooks docs
This commit is contained in:
parent
66bb407bae
commit
0d1cff3038
12 changed files with 653 additions and 2 deletions
69
hooks/auto-allow-memory-tools.py
Normal file
69
hooks/auto-allow-memory-tools.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Auto-allow hook for claude-memory plugin tools.
|
||||
|
||||
This PermissionRequest hook automatically allows any tool whose name matches
|
||||
the claude_memory MCP server pattern to proceed without user confirmation.
|
||||
|
||||
Environment variables:
|
||||
DEBUG_CLAUDE_MEMORY_HOOKS=1 Enable debug logging to stderr
|
||||
DISABLE_CLAUDE_MEMORY_AUTO_APPROVE=1 Disable auto-approve (for debugging)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
DEBUG = os.environ.get("DEBUG_CLAUDE_MEMORY_HOOKS", "").lower() in ("1", "true", "yes")
|
||||
DISABLED = os.environ.get("DISABLE_CLAUDE_MEMORY_AUTO_APPROVE", "").lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
)
|
||||
|
||||
# Match any tool from this plugin's MCP server, resilient to slug variations
|
||||
# e.g. mcp__plugin_claude-memory_claude_memory__memory_store
|
||||
# mcp__claude_memory__memory_recall
|
||||
TOOL_PATTERN = re.compile(r"mcp__.*claude_memory__(?:memory_|secret_)")
|
||||
|
||||
|
||||
def debug(msg: str) -> None:
|
||||
"""Print debug message to stderr if DEBUG is enabled."""
|
||||
if DEBUG:
|
||||
print(f"[claude-memory] {msg}", file=sys.stderr)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if DISABLED:
|
||||
debug("Auto-approve disabled via DISABLE_CLAUDE_MEMORY_AUTO_APPROVE")
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
input_data = json.load(sys.stdin)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error: Invalid JSON input: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
tool_name = input_data.get("tool_name", "")
|
||||
debug(f"Permission request for: {tool_name}")
|
||||
|
||||
if TOOL_PATTERN.search(tool_name):
|
||||
debug(f"Auto-allowing: {tool_name}")
|
||||
output = {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PermissionRequest",
|
||||
"decision": {
|
||||
"behavior": "allow",
|
||||
},
|
||||
}
|
||||
}
|
||||
json.dump(output, sys.stdout)
|
||||
else:
|
||||
debug(f"Not a claude-memory tool, passing through: {tool_name}")
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
209
hooks/auto-learn.py
Normal file
209
hooks/auto-learn.py
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
#!/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 memory API (or SQLite fallback).
|
||||
|
||||
Runs with async: true — does NOT block the user.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
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:
|
||||
{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 _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 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:
|
||||
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)
|
||||
except Exception:
|
||||
pass # Never crash the async hook
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
64
hooks/post-compact-recovery.sh
Executable file
64
hooks/post-compact-recovery.sh
Executable file
|
|
@ -0,0 +1,64 @@
|
|||
#!/bin/bash
|
||||
# UserPromptSubmit hook: Inject recovery context after compaction
|
||||
# This hook runs on each user prompt, but only injects context once after compaction.
|
||||
|
||||
# Read hook input from stdin
|
||||
INPUT=$(cat)
|
||||
|
||||
# Extract session ID
|
||||
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // .sessionId // "unknown"')
|
||||
|
||||
# Define marker path
|
||||
MEMORY_HOME="${MEMORY_HOME:-$HOME/.claude/claude-memory}"
|
||||
MARKER_DIR="${MEMORY_HOME}/state/compaction-markers"
|
||||
MARKER_FILE="${MARKER_DIR}/${SESSION_ID}.json"
|
||||
|
||||
# Fast path: no marker means no recent compaction, exit immediately
|
||||
if [ ! -f "$MARKER_FILE" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Read marker contents
|
||||
MARKER=$(cat "$MARKER_FILE")
|
||||
|
||||
# Validate JSON before processing
|
||||
if ! echo "$MARKER" | jq -e . >/dev/null 2>&1; then
|
||||
rm -f "$MARKER_FILE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract data from marker
|
||||
COMPACTED_AT=$(echo "$MARKER" | jq -r '.compactedAt // "unknown"')
|
||||
PERSONALITY=$(echo "$MARKER" | jq -r '.personalityReminder // ""')
|
||||
|
||||
# Build remembered facts summary (limit to ~500 chars)
|
||||
FACTS_SUMMARY=$(echo "$MARKER" | jq -r '
|
||||
.rememberedFacts[:10] |
|
||||
map("- [\(.category // "fact")] \(.content)") |
|
||||
join("\n")
|
||||
' 2>/dev/null || echo "")
|
||||
|
||||
# Build recovery context (kept under 1000 tokens)
|
||||
RECOVERY_CONTEXT="[Claude Memory Recovery - Context compacted at ${COMPACTED_AT}]
|
||||
|
||||
${PERSONALITY}
|
||||
|
||||
Key memories from before compaction:
|
||||
${FACTS_SUMMARY}
|
||||
|
||||
Use the memory_recall MCP tool if you need more context about past conversations."
|
||||
|
||||
# Output JSON with additional context for injection
|
||||
cat << EOF
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "UserPromptSubmit",
|
||||
"additionalContext": $(echo "$RECOVERY_CONTEXT" | jq -Rs .)
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Delete marker file (one-time injection)
|
||||
rm -f "$MARKER_FILE"
|
||||
|
||||
exit 0
|
||||
43
hooks/pre-compact-backup.sh
Executable file
43
hooks/pre-compact-backup.sh
Executable file
|
|
@ -0,0 +1,43 @@
|
|||
#!/bin/bash
|
||||
# PreCompact hook: Save key memories before compaction
|
||||
set -e
|
||||
|
||||
INPUT=$(cat)
|
||||
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // .sessionId // "unknown"')
|
||||
|
||||
MEMORY_HOME="${MEMORY_HOME:-$HOME/.claude/claude-memory}"
|
||||
MARKER_DIR="${MEMORY_HOME}/state/compaction-markers"
|
||||
MEMORY_DB="${MEMORY_HOME}/memory/memory.db"
|
||||
MARKER_FILE="${MARKER_DIR}/${SESSION_ID}.json"
|
||||
|
||||
mkdir -p "$MARKER_DIR"
|
||||
|
||||
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
# Try API first, fall back to SQLite
|
||||
REMEMBERED_FACTS="[]"
|
||||
if [ -n "${MEMORY_API_KEY:-${CLAUDE_MEMORY_API_KEY:-}}" ]; then
|
||||
API_KEY="${MEMORY_API_KEY:-${CLAUDE_MEMORY_API_KEY:-}}"
|
||||
API_URL="${MEMORY_API_URL:-${CLAUDE_MEMORY_API_URL:-}}"
|
||||
if [ -n "$API_URL" ]; then
|
||||
REMEMBERED_FACTS=$(curl -sf -H "Authorization: Bearer ${API_KEY}" \
|
||||
"${API_URL}/api/memories?limit=20" 2>/dev/null | \
|
||||
jq '[.memories[] | {content, category, importance}]' 2>/dev/null || echo "[]")
|
||||
fi
|
||||
elif [ -f "$MEMORY_DB" ]; then
|
||||
REMEMBERED_FACTS=$(sqlite3 -json "$MEMORY_DB" \
|
||||
"SELECT content, category, importance FROM memories ORDER BY importance DESC, created_at DESC LIMIT 20" 2>/dev/null || echo "[]")
|
||||
fi
|
||||
|
||||
if ! echo "$REMEMBERED_FACTS" | jq empty 2>/dev/null; then
|
||||
REMEMBERED_FACTS="[]"
|
||||
fi
|
||||
|
||||
jq -n \
|
||||
--arg sid "$SESSION_ID" \
|
||||
--arg ts "$TIMESTAMP" \
|
||||
--argjson facts "$REMEMBERED_FACTS" \
|
||||
'{sessionId: $sid, compactedAt: $ts, rememberedFacts: $facts}' \
|
||||
> "$MARKER_FILE"
|
||||
|
||||
exit 0
|
||||
88
hooks/user-prompt-recall.py
Normal file
88
hooks/user-prompt-recall.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
UserPromptSubmit hook: nudge Claude to check memories before responding.
|
||||
|
||||
Instead of searching SQLite directly (which can't generate expanded_query),
|
||||
this hook injects a prompt instruction telling Claude to call memory_recall
|
||||
with proper expanded_query. The model handles the semantic expansion.
|
||||
|
||||
Skips injection for short prompts, code blocks, and tool outputs.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
RECALL_INSTRUCTION = (
|
||||
"MANDATORY: Call memory_recall with context and expanded_query BEFORE responding "
|
||||
"to this message. This is a BLOCKING REQUIREMENT — do NOT answer, summarize, or "
|
||||
"take action until you have checked stored memories. The user's preferences, past "
|
||||
"corrections, and decisions are stored there and MUST inform your response.\n\n"
|
||||
"ONLY EXCEPTION: Skip memory_recall if the message is a direct technical command "
|
||||
"with zero personal context (e.g., 'fix this lint error', 'run tests', 'read file X'). "
|
||||
"Questions, discussions, opinions, design tasks, and anything involving preferences "
|
||||
"are NOT exceptions — check memory for those.\n\n"
|
||||
"NEVER mention the memory check to the user — not when you call it, not when you skip it, "
|
||||
"not when it returns nothing. It should be completely invisible. "
|
||||
"If it returns results, incorporate them naturally into your response."
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Read hook input from stdin
|
||||
try:
|
||||
hook_input = json.load(sys.stdin)
|
||||
except (json.JSONDecodeError, EOFError):
|
||||
return
|
||||
|
||||
# Extract user's prompt
|
||||
prompt = ""
|
||||
if isinstance(hook_input, dict):
|
||||
prompt = hook_input.get("prompt", "")
|
||||
if not prompt:
|
||||
prompt = hook_input.get("user_prompt", "")
|
||||
if not prompt:
|
||||
content = hook_input.get("content", "")
|
||||
if isinstance(content, str):
|
||||
prompt = content
|
||||
|
||||
if not prompt or len(prompt.strip()) < 10:
|
||||
return # Too short to warrant memory check
|
||||
|
||||
# Skip obviously irrelevant prompts
|
||||
stripped = prompt.strip()
|
||||
if (
|
||||
stripped.startswith("```")
|
||||
or stripped.startswith("{")
|
||||
or stripped.startswith("<")
|
||||
):
|
||||
return
|
||||
|
||||
# Skip if memory DB doesn't exist (no memories to recall)
|
||||
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 for migration
|
||||
legacy_home = os.path.expanduser("~/.claude/metaclaw")
|
||||
legacy_db = os.path.join(legacy_home, "memory", "memory.db")
|
||||
|
||||
if not os.path.exists(db_path) and not os.path.exists(legacy_db):
|
||||
return
|
||||
|
||||
# Inject the recall instruction
|
||||
output = json.dumps(
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "UserPromptSubmit",
|
||||
"additionalContext": RECALL_INSTRUCTION,
|
||||
}
|
||||
}
|
||||
)
|
||||
print(output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue