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
66
.claude-plugin/plugin.json
Normal file
66
.claude-plugin/plugin.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
37
README.md
37
README.md
|
|
@ -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
10
commands/recall.md
Normal 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
11
commands/remember.md
Normal 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.
|
||||
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()
|
||||
14
mcp/memory-mcp.json
Normal file
14
mcp/memory-mcp.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
31
skills/memory-management/SKILL.md
Normal file
31
skills/memory-management/SKILL.md
Normal 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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue