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:
Viktor Barzin 2026-03-14 14:49:18 +00:00
parent 66bb407bae
commit 0d1cff3038
No known key found for this signature in database
GPG key ID: 0EB088298288D958
12 changed files with 653 additions and 2 deletions

View file

@ -0,0 +1,66 @@
{
"name": "claude-memory",
"version": "1.0.0",
"description": "Persistent memory with semantic search, compaction survival, and auto-learning",
"author": { "name": "Viktor Barzin" },
"keywords": ["memory", "persistence", "compaction", "recall", "remember"],
"commands": ["./commands/"],
"skills": ["./skills/"],
"mcpServers": "./mcp/memory-mcp.json",
"hooks": {
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/pre-compact-backup.sh",
"timeout": 30
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/post-compact-recovery.sh",
"timeout": 10
}
]
},
{
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/user-prompt-recall.py",
"timeout": 5
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/auto-learn.py",
"async": true
}
]
}
],
"PermissionRequest": [
{
"matcher": "mcp__.*claude_memory__memory_|mcp__.*claude_memory__secret_",
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/auto-allow-memory-tools.py",
"timeout": 3
}
]
}
]
}
}

View file

@ -2,6 +2,24 @@
A persistent memory layer for Claude Code that stores knowledge across sessions. Operates as an MCP (Model Context Protocol) server with optional PostgreSQL API backend, local SQLite cache with background sync, and Vault integration for secrets.
## Quick Install (Claude Code Plugin)
```bash
claude plugins install github:ViktorBarzin/claude-memory-mcp
# Works immediately with local SQLite (no server needed)
# For multi-device sync, set these env vars:
export MEMORY_API_URL="https://your-server.example.com"
export MEMORY_API_KEY="your-api-key"
```
The plugin provides:
- `/remember <fact>` and `/recall <query>` slash commands
- Auto-recall hook that checks memories before each response
- Auto-learn hook that extracts corrections and preferences after each response
- Compaction survival (memories are re-injected after context compaction)
- Auto-approve for all memory tool calls (no permission prompts)
## Architecture
```
@ -333,6 +351,25 @@ kubectl create secret generic claude-memory-secrets \
kubectl apply -f deploy/kubernetes/
```
## Plugin Hooks
When installed as a Claude Code plugin, these hooks run automatically:
| Hook | Event | Description |
|------|-------|-------------|
| `pre-compact-backup.sh` | PreCompact | Saves top 20 memories to a marker file before context compaction |
| `post-compact-recovery.sh` | UserPromptSubmit | Detects compaction marker and injects recovery context (one-time) |
| `user-prompt-recall.py` | UserPromptSubmit | Instructs Claude to call `memory_recall` before responding |
| `auto-learn.py` | Stop (async) | Uses haiku-as-judge to extract corrections/preferences from the conversation |
| `auto-allow-memory-tools.py` | PermissionRequest | Auto-approves all memory MCP tool calls without prompting |
### Debug Environment Variables
| Variable | Effect |
|----------|--------|
| `DEBUG_CLAUDE_MEMORY_HOOKS=1` | Enable debug logging to stderr for all hooks |
| `DISABLE_CLAUDE_MEMORY_AUTO_APPROVE=1` | Disable auto-approve (prompts for each tool call) |
## Development
```bash

10
commands/recall.md Normal file
View file

@ -0,0 +1,10 @@
---
allowed-tools: mcp__claude_memory__memory_recall, mcp__plugin_claude-memory_claude_memory__memory_recall
argument-hint: <query>
description: Search persistent memory
---
Search persistent memory for information matching $ARGUMENTS.
Use memory_recall with the query as context and generate an expanded_query with related search terms.
Present results clearly with when they were stored.

11
commands/remember.md Normal file
View file

@ -0,0 +1,11 @@
---
allowed-tools: mcp__claude_memory__memory_store, mcp__plugin_claude-memory_claude_memory__memory_store
argument-hint: <fact to remember>
description: Store a fact in persistent memory
---
Store the provided fact in persistent memory using the memory_store MCP tool.
Use $ARGUMENTS as the content to store.
Infer an appropriate category (facts, preferences, projects, people, decisions).
Confirm what was stored.

View 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
View 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
View 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
View 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

View 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()

14
mcp/memory-mcp.json Normal file
View file

@ -0,0 +1,14 @@
{
"mcpServers": {
"claude_memory": {
"type": "stdio",
"command": "python3",
"args": ["${CLAUDE_PLUGIN_ROOT}/src/claude_memory/mcp_server.py"],
"env": {
"MEMORY_API_URL": "${MEMORY_API_URL}",
"MEMORY_API_KEY": "${MEMORY_API_KEY}",
"PYTHONPATH": "${CLAUDE_PLUGIN_ROOT}/src"
}
}
}
}

View file

@ -0,0 +1,31 @@
---
name: memory-management
description: How Claude Memory manages persistent memory across sessions and compactions
---
# Memory Management
## Available MCP Tools
- `memory_store`: Save facts, preferences, decisions
- `memory_recall`: Retrieve relevant memories by context
- `memory_list`: List recent memories
- `memory_delete`: Delete a memory by ID
- `secret_get`: Retrieve decrypted content of a sensitive memory
## When to Store Memories
- User says "remember X" -> store immediately
- User shares preferences -> store with category "preferences"
- Important project context -> store with category "projects"
- Key decisions -> store with category "decisions"
- People details -> store with category "people"
## When to Recall Memories
- Before answering preference questions ("how do I like X?")
- When user references past conversations
- At session start (memories are injected via compaction recovery)
## Compaction Survival
Memory survives context compactions via:
1. PreCompact hook saves key memories to a marker file
2. UserPromptSubmit hook detects the marker and injects recovery context
3. SQLite database persists across all sessions

View file

@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
PROTOCOL_VERSION = "2024-11-05"
SERVER_NAME = "claude-memory"
SERVER_VERSION = "1.1.0"
SERVER_VERSION = "2.0.0"
# API configuration — support both MEMORY_* (primary) and CLAUDE_MEMORY_* (fallback) env vars
API_BASE_URL = os.environ.get("MEMORY_API_URL") or os.environ.get("CLAUDE_MEMORY_API_URL", "http://localhost:8080")
@ -72,7 +72,16 @@ def _get_db_path(db_path: str | None = None) -> str:
"MEMORY_DB",
os.path.join(memory_home, "memory", "memory.db"),
)
return os.path.expandvars(os.path.expanduser(db_path))
resolved = os.path.expandvars(os.path.expanduser(db_path))
# Migration fallback: if the new path doesn't exist but legacy metaclaw path does, use that
if not os.path.exists(resolved):
legacy_home = os.path.expanduser("~/.claude/metaclaw")
legacy_db = os.path.join(legacy_home, "memory", "memory.db")
if os.path.exists(legacy_db):
return legacy_db
return resolved
def _init_sqlite(db_path: str | None = None):