feat: add Mem0 plugin for Claude Code and Cursor (#4518)
This commit is contained in:
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: PreToolUse (matcher: Write|Edit)
|
||||
#
|
||||
# Blocks writes to MEMORY.md and auto-memory files, redirecting Claude
|
||||
# to use the mem0 MCP add_memory tool instead.
|
||||
#
|
||||
# Input: JSON on stdin with tool_name, tool_input
|
||||
# Output: stderr message (exit 2 = block)
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 = allow the tool call
|
||||
# 2 = block the tool call (stderr is shown to Claude as feedback)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // ""' 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$FILE_PATH" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$FILE_PATH" in
|
||||
*/MEMORY.md|*/memory/*.md|*/.claude/*/memory/*)
|
||||
echo "BLOCKED: Do not write to $FILE_PATH. Use the mem0 MCP \`add_memory\` tool instead to persist memories. This project uses mem0 for all memory storage." >&2
|
||||
exit 2
|
||||
;;
|
||||
*)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
Executable
+239
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Capture session state via the Mem0 REST API.
|
||||
|
||||
Safety net for PreCompact and Stop hooks — reads the transcript JSONL,
|
||||
extracts structured session state, and stores it in Mem0 directly.
|
||||
|
||||
Used by:
|
||||
- PreCompact hook: Tags with "pre-compaction" (context about to be lost)
|
||||
- Stop hook: Tags with "session-end" (session ending, Claude can't respond)
|
||||
|
||||
Input: JSON on stdin with transcript_path, session_id, cwd
|
||||
Output: stderr logs only (exit 0 always — must not block)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
log = logging.getLogger("mem0-capture")
|
||||
log.setLevel(logging.DEBUG)
|
||||
_handler = logging.StreamHandler(sys.stderr)
|
||||
_handler.setFormatter(logging.Formatter("[mem0-capture] %(message)s"))
|
||||
log.addHandler(_handler)
|
||||
|
||||
API_URL = "https://api.mem0.ai"
|
||||
MAX_TAIL_LINES = 500
|
||||
MAX_USER_MESSAGES = 30
|
||||
MAX_BASH_COMMANDS = 20
|
||||
MAX_ASSISTANT_TEXT = 10000
|
||||
|
||||
|
||||
def tail_lines(filepath: str, n: int) -> list[str]:
|
||||
"""Read last n lines of a file efficiently."""
|
||||
try:
|
||||
with open(filepath, "rb") as f:
|
||||
f.seek(0, 2)
|
||||
file_size = f.tell()
|
||||
if file_size == 0:
|
||||
return []
|
||||
chunk_size = min(file_size, n * 4096)
|
||||
f.seek(max(0, file_size - chunk_size))
|
||||
data = f.read().decode("utf-8", errors="replace")
|
||||
return data.splitlines()[-n:]
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def parse_transcript(lines: list[str]) -> dict:
|
||||
"""Parse transcript JSONL lines and extract session state."""
|
||||
user_messages: list[str] = []
|
||||
files_modified: set[str] = set()
|
||||
bash_commands: list[str] = []
|
||||
last_assistant_text = ""
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
entry_type = entry.get("type")
|
||||
if entry_type not in ("user", "assistant"):
|
||||
continue
|
||||
if entry.get("isSidechain"):
|
||||
continue
|
||||
|
||||
message = entry.get("message", {})
|
||||
content_blocks = message.get("content", [])
|
||||
|
||||
if entry_type == "user":
|
||||
parts = []
|
||||
if isinstance(content_blocks, str):
|
||||
parts.append(content_blocks)
|
||||
elif isinstance(content_blocks, list):
|
||||
for block in content_blocks:
|
||||
if isinstance(block, str):
|
||||
parts.append(block)
|
||||
elif isinstance(block, dict) and block.get("type") == "text":
|
||||
parts.append(block.get("text", ""))
|
||||
text = "\n".join(parts).strip()
|
||||
if text and len(text) > 10 and not text.startswith("<"):
|
||||
user_messages.append(text)
|
||||
|
||||
elif entry_type == "assistant":
|
||||
for block in content_blocks:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
if block.get("type") == "text":
|
||||
text = block.get("text", "").strip()
|
||||
if text:
|
||||
last_assistant_text = text
|
||||
if block.get("type") == "tool_use":
|
||||
tool_name = block.get("name", "")
|
||||
tool_input = block.get("input", {})
|
||||
if tool_name in ("Write", "Edit"):
|
||||
fp = tool_input.get("file_path", "")
|
||||
if fp:
|
||||
files_modified.add(fp)
|
||||
elif tool_name == "Bash":
|
||||
cmd = tool_input.get("command", "")
|
||||
if cmd:
|
||||
bash_commands.append(cmd)
|
||||
|
||||
return {
|
||||
"user_messages": user_messages[-MAX_USER_MESSAGES:],
|
||||
"files_modified": sorted(files_modified),
|
||||
"bash_commands": bash_commands[-MAX_BASH_COMMANDS:],
|
||||
"last_assistant_text": last_assistant_text[:MAX_ASSISTANT_TEXT],
|
||||
}
|
||||
|
||||
|
||||
def build_content(state: dict, source: str) -> str:
|
||||
"""Build structured markdown from parsed state."""
|
||||
parts = [f"## Session State ({source})\n"]
|
||||
|
||||
if state["user_messages"]:
|
||||
parts.append("### What the user was working on")
|
||||
for msg in state["user_messages"]:
|
||||
truncated = msg[:5000] + "..." if len(msg) > 5000 else msg
|
||||
parts.append(f"- {truncated}")
|
||||
parts.append("")
|
||||
|
||||
if state["files_modified"]:
|
||||
parts.append("### Files modified this session")
|
||||
for fp in state["files_modified"]:
|
||||
parts.append(f"- `{fp}`")
|
||||
parts.append("")
|
||||
|
||||
if state["bash_commands"]:
|
||||
parts.append("### Recent commands")
|
||||
for cmd in state["bash_commands"]:
|
||||
truncated = cmd[:1000] + "..." if len(cmd) > 1000 else cmd
|
||||
parts.append(f"- `{truncated}`")
|
||||
parts.append("")
|
||||
|
||||
if state["last_assistant_text"]:
|
||||
parts.append("### Last context")
|
||||
parts.append(state["last_assistant_text"])
|
||||
parts.append("")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def store_memory(api_key: str, content: str, user_id: str, source: str) -> bool:
|
||||
"""Store session state as a memory via the Mem0 REST API."""
|
||||
body = {
|
||||
"messages": [
|
||||
{"role": "user", "content": content}
|
||||
],
|
||||
"user_id": user_id,
|
||||
"metadata": {
|
||||
"type": "session_state",
|
||||
"source": source,
|
||||
},
|
||||
}
|
||||
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{API_URL}/v1/memories/",
|
||||
data=data,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Token {api_key}",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
if resp.status in (200, 201):
|
||||
log.info("Session state stored successfully")
|
||||
return True
|
||||
log.warning("API returned status %d", resp.status)
|
||||
return False
|
||||
except urllib.error.URLError as e:
|
||||
log.warning("API call failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
source = "pre-compaction"
|
||||
for arg in sys.argv[1:]:
|
||||
if arg.startswith("--source="):
|
||||
source = arg.split("=", 1)[1]
|
||||
|
||||
api_key = os.environ.get("MEM0_API_KEY", "")
|
||||
if not api_key:
|
||||
log.debug("MEM0_API_KEY not set, skipping capture")
|
||||
return
|
||||
|
||||
try:
|
||||
hook_input = json.loads(sys.stdin.read())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
log.debug("No valid JSON on stdin")
|
||||
return
|
||||
|
||||
transcript_path = hook_input.get("transcript_path", "")
|
||||
if not transcript_path:
|
||||
log.debug("No transcript_path provided")
|
||||
return
|
||||
|
||||
user_id = os.environ.get("MEM0_USER_ID", os.environ.get("USER", "default"))
|
||||
|
||||
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
|
||||
if not lines:
|
||||
log.debug("Transcript empty or unreadable: %s", transcript_path)
|
||||
return
|
||||
|
||||
state = parse_transcript(lines)
|
||||
if not state["user_messages"] and not state["files_modified"]:
|
||||
log.debug("No meaningful session state to capture")
|
||||
return
|
||||
|
||||
content = build_content(state, source)
|
||||
|
||||
log.info(
|
||||
"Capturing session state: %d user msgs, %d files, %d commands",
|
||||
len(state["user_messages"]),
|
||||
len(state["files_modified"]),
|
||||
len(state["bash_commands"]),
|
||||
)
|
||||
|
||||
store_memory(api_key, content, user_id, source)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
log.error("Unexpected error: %s", e)
|
||||
sys.exit(0)
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: PreCompact
|
||||
#
|
||||
# Fires BEFORE context compaction. This is the last chance to capture
|
||||
# the full context before it gets compressed.
|
||||
#
|
||||
# Output: Text instructions injected into Claude's context.
|
||||
# Claude still has the full conversation and can write an accurate summary.
|
||||
# A companion Python script (on_pre_compact.py) also runs to capture
|
||||
# transcript state directly via the Mem0 REST API as a safety net.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cat <<'EOF'
|
||||
## CRITICAL: Pre-Compaction Session Summary
|
||||
|
||||
Context compaction is about to happen. You are about to lose most of your conversation history. You MUST store a comprehensive session summary NOW using the mem0 `add_memory` tool.
|
||||
|
||||
### Step 1: Store session summary
|
||||
|
||||
Call `add_memory` with a thorough summary covering ALL of the following:
|
||||
|
||||
```
|
||||
## Session Summary (Pre-Compaction)
|
||||
|
||||
### User's Goal
|
||||
[What the user originally asked for and their intent]
|
||||
|
||||
### What Was Accomplished
|
||||
[Numbered list of tasks completed, features built, bugs fixed]
|
||||
|
||||
### Key Decisions Made
|
||||
[Architectural choices, design decisions, trade-offs discussed]
|
||||
|
||||
### Files Created or Modified
|
||||
[List of important file paths with what changed in each]
|
||||
|
||||
### Current State
|
||||
[What is in progress RIGHT NOW — the task you were in the middle of]
|
||||
[Any pending items, blockers, or next steps]
|
||||
|
||||
### Important Context
|
||||
[User preferences observed, coding patterns, anything that would help
|
||||
the post-compaction agent continue without asking redundant questions]
|
||||
```
|
||||
|
||||
Include metadata: `{"type": "session_state", "source": "pre-compaction"}`
|
||||
|
||||
### Step 2: Store any unstored learnings
|
||||
|
||||
If there are learnings from this session that you haven't stored yet, store them as separate memories:
|
||||
- Failed approaches -> metadata `{"type": "anti_pattern"}`
|
||||
- Successful strategies -> metadata `{"type": "task_learning"}`
|
||||
- Architecture decisions -> metadata `{"type": "decision"}`
|
||||
|
||||
### Step 3: Acknowledge
|
||||
|
||||
After storing, briefly tell the user that session state has been saved and you're ready for compaction.
|
||||
|
||||
Do this NOW. Do not skip any section. The quality of this summary directly determines whether you can continue the user's task after compaction.
|
||||
EOF
|
||||
|
||||
exit 0
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: SessionStart (matcher: startup|resume|compact)
|
||||
#
|
||||
# Bootstraps mem0 context at the start of every session.
|
||||
# Output becomes part of Claude's context so it calls mem0 MCP tools.
|
||||
#
|
||||
# Input: JSON on stdin with session_id, source, transcript_path, model, cwd
|
||||
# Output: Text injected into Claude's context (exit 0)
|
||||
|
||||
# Intentionally omit -e so the script always outputs a bootstrap prompt
|
||||
# even if jq is missing or stdin is malformed.
|
||||
set -uo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
SOURCE=$(echo "$INPUT" | jq -r '.source // "startup"' 2>/dev/null || echo "startup")
|
||||
|
||||
if [ "$SOURCE" = "startup" ]; then
|
||||
cat <<'EOF'
|
||||
## Mem0 Session Bootstrap
|
||||
|
||||
You have access to persistent memory via the mem0 MCP tools. Before doing anything else:
|
||||
|
||||
1. Call `search_memories` with a query related to the current project or user request to load relevant context.
|
||||
2. Review the returned memories to understand what has been learned in prior sessions.
|
||||
3. If appropriate, call `get_memories` to browse all stored memories for this user.
|
||||
|
||||
IMPORTANT: Do NOT skip this step. Always bootstrap context first.
|
||||
EOF
|
||||
|
||||
elif [ "$SOURCE" = "resume" ]; then
|
||||
cat <<'EOF'
|
||||
## Mem0 Session Resumed
|
||||
|
||||
This is a resumed session. Your prior context is already loaded. Before continuing:
|
||||
|
||||
1. Call `search_memories` with a query related to the current task to refresh relevant memories.
|
||||
2. If significant time has passed, search for recent project-wide updates.
|
||||
|
||||
Continue where you left off.
|
||||
EOF
|
||||
|
||||
elif [ "$SOURCE" = "compact" ]; then
|
||||
cat <<'EOF'
|
||||
## Mem0 Post-Compaction Recovery
|
||||
|
||||
Context was just compacted. You may have lost important session context.
|
||||
|
||||
1. Call `search_memories` with queries related to what you were working on to reload relevant knowledge.
|
||||
2. Check for any session state memories that were saved before compaction.
|
||||
3. Continue working based on the recovered context.
|
||||
EOF
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: Stop
|
||||
#
|
||||
# Fires when Claude finishes responding.
|
||||
# Reminds Claude to store any unsaved learnings, then spawns a background
|
||||
# process to capture transcript state via the Mem0 REST API directly.
|
||||
#
|
||||
# Input: JSON on stdin with stop_hook_active, transcript_path, cwd
|
||||
# Output: Text that becomes Claude's context (exit 0), or nothing
|
||||
#
|
||||
# IMPORTANT: Check stop_hook_active to avoid infinite loops.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
INPUT=$(cat)
|
||||
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null || echo "false")
|
||||
|
||||
if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cat <<'EOF'
|
||||
Before finishing, check if there are important learnings from this interaction that should be persisted using the mem0 `add_memory` tool:
|
||||
|
||||
1. Were any significant decisions made? -> Store with metadata `{"type": "decision"}`
|
||||
2. Were any new patterns or strategies discovered? -> Store with metadata `{"type": "task_learning"}`
|
||||
3. Did any approach fail? -> Store with metadata `{"type": "anti_pattern"}`
|
||||
4. Did you learn anything about the user's preferences? -> Store with metadata `{"type": "user_preference"}`
|
||||
5. Were there environment/setup discoveries? -> Store with metadata `{"type": "environmental"}`
|
||||
|
||||
Memories can be as detailed as needed — include full context, reasoning, code snippets, file paths, and examples. Longer, searchable memories are more valuable than vague one-liners.
|
||||
|
||||
If nothing notable happened in this interaction, it's fine to skip. Only store genuinely useful learnings.
|
||||
EOF
|
||||
|
||||
# Capture transcript state in the background via Mem0 REST API
|
||||
echo "$INPUT" | python3 "$SCRIPT_DIR/on_pre_compact.py" --source=session-end 2>/dev/null &
|
||||
|
||||
exit 0
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: TaskCompleted
|
||||
#
|
||||
# Fires when a task is marked as completed. Reminds Claude to extract
|
||||
# and store learnings via the mem0 MCP tools.
|
||||
#
|
||||
# Input: JSON on stdin with task_id, task_subject, task_description
|
||||
# Output: Text that becomes feedback to the model (exit 0)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
TASK_SUBJECT=$(echo "$INPUT" | jq -r '.task_subject // "unknown task"' 2>/dev/null || echo "unknown task")
|
||||
|
||||
cat <<EOF
|
||||
Task completed: "$TASK_SUBJECT"
|
||||
|
||||
Extract key learnings from this completed task and store them using the mem0 \`add_memory\` tool:
|
||||
|
||||
1. What strategy worked well? -> Store with metadata \`{"type": "task_learning"}\`
|
||||
2. Were there failed approaches before finding the solution? -> Store with metadata \`{"type": "anti_pattern"}\`
|
||||
3. Were there architectural decisions? -> Store with metadata \`{"type": "decision"}\`
|
||||
4. Any new conventions or patterns established? -> Store with metadata \`{"type": "convention"}\`
|
||||
|
||||
Memories can be as detailed as needed — include full context, reasoning, code snippets, and examples.
|
||||
Only store genuinely useful learnings — skip if the task was trivial.
|
||||
EOF
|
||||
|
||||
exit 0
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: UserPromptSubmit
|
||||
#
|
||||
# Fires on every user message. Searches mem0 for relevant memories
|
||||
# and injects them into Claude's context before processing.
|
||||
#
|
||||
# Input: JSON on stdin with prompt, session_id, cwd, transcript_path
|
||||
# Output: Matching memories as context text (exit 0)
|
||||
#
|
||||
# Skips search for very short prompts (< 20 chars) and when
|
||||
# MEM0_API_KEY is not set. Uses a 3s timeout to minimize latency.
|
||||
|
||||
# Intentionally omit -e so the script always exits 0 even if
|
||||
# curl or jq fail — must never block the user's prompt.
|
||||
set -uo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
PROMPT=$(echo "$INPUT" | jq -r '.prompt // ""' 2>/dev/null || echo "")
|
||||
|
||||
# Skip trivial prompts — not worth a network call
|
||||
if [ ${#PROMPT} -lt 20 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
API_KEY="${MEM0_API_KEY:-}"
|
||||
if [ -z "$API_KEY" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
USER_ID="${MEM0_USER_ID:-${USER:-default}}"
|
||||
|
||||
# Build request body safely via jq to avoid injection
|
||||
BODY=$(jq -n --arg query "$PROMPT" --arg user_id "$USER_ID" \
|
||||
'{query: $query, filters: {user_id: $user_id}, top_k: 5}')
|
||||
|
||||
# Search mem0 for memories relevant to this prompt
|
||||
RESPONSE=$(curl -s --max-time 3 \
|
||||
-X POST "https://api.mem0.ai/v2/memories/search/" \
|
||||
-H "Authorization: Token $API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$BODY" \
|
||||
2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$RESPONSE" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract memories from response (API returns a flat array)
|
||||
MEMORIES=$(echo "$RESPONSE" | jq -r '
|
||||
if type == "array" then . else .results // [] end |
|
||||
if length == 0 then empty else
|
||||
"## Relevant memories from mem0\n\n" +
|
||||
(map(select(.memory != null) | "- " + .memory) | join("\n"))
|
||||
end
|
||||
' 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$MEMORIES" ]; then
|
||||
echo "$MEMORIES"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Reference in New Issue
Block a user